Files
familienarchiv/frontend/src/routes/search/DisambiguationPicker.svelte
Marcel 8429b1e9f8 fix(search): derive disambiguation trigger aria-label from match count (#763 review)
The trigger hardcoded the multiple-people label for every count, so a
single did-you-mean picker announced "Mehrere Personen gefunden" to
screen readers while sighted users saw one name and a "Meintest du …?"
heading. Derive the trigger's accessible name from persons.length: a
single suggestion reuses the heading prop, two or more keep the
multiple-people label. Visible truncated name span unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00

103 lines
2.7 KiB
Svelte

<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,
heading,
showCue,
onSelect
}: {
persons: PersonHint[];
heading: string;
showCue: boolean;
onSelect: (person: PersonHint) => void;
} = $props();
let open = $state(false);
let triggerEl = $state<HTMLButtonElement>();
let listEl = $state<HTMLUListElement>();
const panelId = 'disambiguation-panel';
const headingId = 'disambiguation-heading';
const names = $derived(persons.map((person) => person.displayName).join(', '));
const triggerLabel = $derived(
persons.length === 1 ? heading : m.search_disambiguation_trigger_label()
);
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={triggerLabel}
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>
{#if showCue}
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
{/if}
</button>
{#if open}
<div
id={panelId}
class="absolute left-0 z-10 mt-1 min-w-[12rem] rounded-sm border border-line bg-surface py-1 shadow-md"
>
<p id={headingId} class="px-4 py-1.5 text-sm font-bold text-ink">{heading}</p>
<ul bind:this={listEl} aria-labelledby={headingId}>
{#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>
</div>
{/if}
</div>