feat(search): add DisambiguationPicker single-select disclosure (#739)
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 <noreply@anthropic.com>
This commit is contained in:
86
frontend/src/routes/search/DisambiguationPicker.svelte
Normal file
86
frontend/src/routes/search/DisambiguationPicker.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type PersonHint = components['schemas']['PersonHint'];
|
||||||
|
|
||||||
|
let { persons, onSelect }: { persons: PersonHint[]; onSelect: (person: PersonHint) => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let triggerEl = $state<HTMLButtonElement>();
|
||||||
|
let listEl = $state<HTMLUListElement>();
|
||||||
|
|
||||||
|
const panelId = 'disambiguation-panel';
|
||||||
|
const names = $derived(persons.map((person) => person.displayName).join(', '));
|
||||||
|
|
||||||
|
async function openPicker() {
|
||||||
|
open = true;
|
||||||
|
await tick();
|
||||||
|
listEl?.querySelector<HTMLButtonElement>('button')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
open = false;
|
||||||
|
triggerEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (open) closePicker();
|
||||||
|
else openPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(person: PersonHint) {
|
||||||
|
open = false;
|
||||||
|
onSelect(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && open) {
|
||||||
|
event.stopPropagation();
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div class="relative inline-block" use:clickOutside onclickoutside={() => open && closePicker()}>
|
||||||
|
<button
|
||||||
|
bind:this={triggerEl}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-label={m.search_disambiguation_trigger_label()}
|
||||||
|
onclick={toggle}
|
||||||
|
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
<span class="max-w-[8rem] truncate sm:max-w-[12rem]">{names}</span>
|
||||||
|
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<ul
|
||||||
|
bind:this={listEl}
|
||||||
|
id={panelId}
|
||||||
|
aria-label={m.search_disambiguation_heading()}
|
||||||
|
class="absolute left-0 z-10 mt-1 min-w-[12rem] rounded-sm border border-line bg-surface py-1 shadow-md"
|
||||||
|
>
|
||||||
|
{#each persons as person (person.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.search_disambiguation_select_label({ name: person.displayName })}
|
||||||
|
onclick={() => select(person)}
|
||||||
|
class="flex min-h-[44px] w-full items-center px-4 text-left text-sm text-ink outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
{person.displayName}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user