diff --git a/frontend/src/lib/actions/clickOutside.svelte.spec.ts b/frontend/src/lib/actions/clickOutside.svelte.spec.ts new file mode 100644 index 00000000..81917cb0 --- /dev/null +++ b/frontend/src/lib/actions/clickOutside.svelte.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, afterEach } from 'vitest'; + +const { clickOutside } = await import('./clickOutside'); + +describe('clickOutside action', () => { + const nodes: HTMLElement[] = []; + + function makeNode(): HTMLElement { + const node = document.createElement('div'); + document.body.appendChild(node); + nodes.push(node); + return node; + } + + afterEach(() => { + nodes.forEach((n) => n.remove()); + nodes.length = 0; + }); + + it('registers a capture-phase click listener on mount', () => { + const node = makeNode(); + const original = document.addEventListener.bind(document); + let registered = false; + document.addEventListener = (type: string, _fn: unknown, opts: unknown) => { + if (type === 'click' && opts === true) registered = true; + original(type as string, _fn as EventListener, opts as boolean); + }; + clickOutside(node); + expect(registered).toBe(true); + document.addEventListener = original; + }); + + it('dispatches clickoutside when clicking outside the node', () => { + const node = makeNode(); + const outside = makeNode(); + let fired = false; + node.addEventListener('clickoutside', () => (fired = true)); + clickOutside(node); + outside.click(); + expect(fired).toBe(true); + }); + + it('does not dispatch clickoutside when clicking inside the node', () => { + const node = makeNode(); + const child = document.createElement('span'); + node.appendChild(child); + let fired = false; + node.addEventListener('clickoutside', () => (fired = true)); + clickOutside(node); + child.click(); + expect(fired).toBe(false); + }); + + it('removes the listener on destroy', () => { + const node = makeNode(); + const outside = makeNode(); + let count = 0; + node.addEventListener('clickoutside', () => count++); + const { destroy } = clickOutside(node); + destroy(); + outside.click(); + expect(count).toBe(0); + }); +}); diff --git a/frontend/src/lib/actions/clickOutside.ts b/frontend/src/lib/actions/clickOutside.ts new file mode 100644 index 00000000..ef7fc3a2 --- /dev/null +++ b/frontend/src/lib/actions/clickOutside.ts @@ -0,0 +1,15 @@ +export function clickOutside(node: HTMLElement): { destroy: () => void } { + function handleClick(event: MouseEvent) { + if (node && !node.contains(event.target as Node) && !event.defaultPrevented) { + node.dispatchEvent(new CustomEvent('clickoutside')); + } + } + + document.addEventListener('click', handleClick, true); + + return { + destroy() { + document.removeEventListener('click', handleClick, true); + } + }; +} diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte index 5fc1171f..536ef97b 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte +++ b/frontend/src/lib/components/PersonMultiSelect.svelte @@ -1,6 +1,7 @@ @@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) { {/each} -
+
(showDropdown = false)}>
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index c17c1e81..8e9cf930 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte'; import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; +import { clickOutside } from '$lib/actions/clickOutside'; type Person = components['schemas']['Person']; interface Props { @@ -118,23 +119,9 @@ function selectPerson(person: Person) { showDropdown = false; onchange?.(person.id!); } - -function clickOutside(node: HTMLElement) { - const handleClick = (event: MouseEvent) => { - if (node && !node.contains(event.target as Node) && !event.defaultPrevented) { - showDropdown = false; - } - }; - document.addEventListener('click', handleClick, true); - return { - destroy() { - document.removeEventListener('click', handleClick, true); - } - }; -} -
+
(showDropdown = false)}>