From fa41394e66c5e7fe1e6b5cef4420294c65766a96 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:43:27 +0200 Subject: [PATCH] feat(search): add DisambiguationPicker single-select disclosure (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessible disclosure: aria-expanded/aria-controls trigger, focus moves into the option list on open, Escape and click-outside close and return focus to the trigger, selecting a candidate emits onSelect. Single-select (GET re-run) per the resolved #738 open decision — backend has no multi-sender OR param. 5 vitest-browser-svelte specs. Co-Authored-By: Claude Opus 4.8 --- .../routes/search/DisambiguationPicker.svelte | 86 +++++++++++++++++++ .../DisambiguationPicker.svelte.spec.ts | 71 +++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 frontend/src/routes/search/DisambiguationPicker.svelte create mode 100644 frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte b/frontend/src/routes/search/DisambiguationPicker.svelte new file mode 100644 index 00000000..b99fea10 --- /dev/null +++ b/frontend/src/routes/search/DisambiguationPicker.svelte @@ -0,0 +1,86 @@ + + + + +
open && closePicker()}> + + + {#if open} +
    + {#each persons as person (person.id)} +
  • + +
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts new file mode 100644 index 00000000..5b87a996 --- /dev/null +++ b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DisambiguationPicker from './DisambiguationPicker.svelte'; +import type { components } from '$lib/generated/api'; + +type PersonHint = components['schemas']['PersonHint']; + +afterEach(() => cleanup()); + +const persons: PersonHint[] = [ + { id: 'w1', displayName: 'Walter Raddatz' }, + { id: 'w2', displayName: 'Walter Müller' } +]; + +function pressEscape() { + (document.activeElement as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); +} + +describe('DisambiguationPicker', () => { + it('opens the picker and shows a select option per ambiguous person', async () => { + render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await expect + .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) + .toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Walter Müller auswählen' })) + .toBeInTheDocument(); + }); + + it('moves focus into the picker list on open', async () => { + render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await expect + .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) + .toHaveFocus(); + }); + + it('returns focus to the trigger when closed with Escape', async () => { + render(DisambiguationPicker, { persons, onSelect: vi.fn() }); + const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ }); + await trigger.click(); + await expect + .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) + .toHaveFocus(); + pressEscape(); + await expect.element(trigger).toHaveFocus(); + }); + + it('does not call onSelect when dismissed without choosing', async () => { + const onSelect = vi.fn(); + render(DisambiguationPicker, { persons, onSelect }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await expect + .element(page.getByRole('button', { name: 'Walter Raddatz auswählen' })) + .toHaveFocus(); + pressEscape(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('calls onSelect with the chosen person', async () => { + const onSelect = vi.fn(); + render(DisambiguationPicker, { persons, onSelect }); + await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click(); + await page.getByRole('button', { name: 'Walter Müller auswählen' }).click(); + expect(onSelect).toHaveBeenCalledWith(persons[1]); + }); +});