From b1e959412f19b95bdff5a3fb0b2cfea731dbbd58 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 22:02:46 +0200 Subject: [PATCH 01/32] feat(frontend): add xs breakpoint (375px) to Tailwind @theme Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/layout.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 15e7d081..4f7bfe0e 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -5,6 +5,9 @@ /* ─── 2. Raw palette — never used directly in components ──────────────────── */ @theme { + /* Breakpoints */ + --breakpoint-xs: 375px; + /* Brand palette constants */ --palette-navy: #012851; --palette-mint: #a1dcd8; -- 2.49.1 From b5a68e69e2f9b5cc5abf026215c9352f8ce0a8bd Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 31 Mar 2026 22:34:54 +0200 Subject: [PATCH 02/32] refactor(actions): extract clickOutside to shared module, replace 5 inline copies Co-Authored-By: Claude Sonnet 4.6 --- .../lib/actions/clickOutside.svelte.spec.ts | 64 +++++++++++++++++++ frontend/src/lib/actions/clickOutside.ts | 15 +++++ .../lib/components/PersonMultiSelect.svelte | 17 +---- .../src/lib/components/PersonTypeahead.svelte | 17 +---- frontend/src/lib/components/TagInput.svelte | 17 +---- frontend/src/routes/UserMenu.svelte | 16 +---- .../CorrespondentSuggestionsDropdown.svelte | 16 +---- 7 files changed, 90 insertions(+), 72 deletions(-) create mode 100644 frontend/src/lib/actions/clickOutside.svelte.spec.ts create mode 100644 frontend/src/lib/actions/clickOutside.ts 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)}>