feat(search): case-appropriate disambiguation picker copy (#763)
A 1-item picker now reads "Meintest du …?" (a single direct match auto-selects and never reaches the picker), while ≥2 keeps the "Person auswählen" framing. The prompt lives in a visible, non-truncated panel heading (the trigger span clips at 320px), and the "(auswählen…)" cue is dropped for the 1-item case. DisambiguationPicker takes heading + showCue props; the page derives both from ambiguousPersons.length. New search_disambiguation_did_you_mean key in de/en/es. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken",
|
||||
"search_disambiguation_cue": "(auswählen…)",
|
||||
"search_disambiguation_heading": "Person auswählen",
|
||||
"search_disambiguation_did_you_mean": "Meintest du {name}?",
|
||||
"search_disambiguation_select_label": "{name} auswählen",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"search_disambiguation_trigger_label": "Several people found — click to choose",
|
||||
"search_disambiguation_cue": "(choose…)",
|
||||
"search_disambiguation_heading": "Choose a person",
|
||||
"search_disambiguation_did_you_mean": "Did you mean {name}?",
|
||||
"search_disambiguation_select_label": "Select {name}",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"search_disambiguation_trigger_label": "Se encontraron varias personas — haga clic para elegir",
|
||||
"search_disambiguation_cue": "(elegir…)",
|
||||
"search_disambiguation_heading": "Elegir una persona",
|
||||
"search_disambiguation_did_you_mean": "¿Quería decir {name}?",
|
||||
"search_disambiguation_select_label": "Seleccionar {name}",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
|
||||
@@ -57,7 +57,16 @@ let nlResult = $state<DocumentSearchResult | null>(null);
|
||||
|
||||
const showNlView = $derived(smartMode && nlSubmitted);
|
||||
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
|
||||
const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0);
|
||||
const ambiguousPersons = $derived(nlInterpretation?.ambiguousPersons ?? []);
|
||||
const nlIsAmbiguous = $derived(ambiguousPersons.length > 0);
|
||||
// A 1-item picker is always a "did you mean …?" suggestion (a single direct match auto-selects
|
||||
// and never reaches the picker); ≥2 keeps the "choose a person" framing and the action cue.
|
||||
const disambiguationHeading = $derived(
|
||||
ambiguousPersons.length === 1
|
||||
? m.search_disambiguation_did_you_mean({ name: ambiguousPersons[0].displayName })
|
||||
: m.search_disambiguation_heading()
|
||||
);
|
||||
const showDisambiguationCue = $derived(ambiguousPersons.length >= 2);
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
return (
|
||||
@@ -442,6 +451,8 @@ $effect(() => {
|
||||
{#if nlIsAmbiguous}
|
||||
<DisambiguationPicker
|
||||
persons={nlInterpretation.ambiguousPersons}
|
||||
heading={disambiguationHeading}
|
||||
showCue={showDisambiguationCue}
|
||||
onSelect={selectDisambiguated}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -6,14 +6,24 @@ import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonHint = components['schemas']['PersonHint'];
|
||||
|
||||
let { persons, onSelect }: { persons: PersonHint[]; onSelect: (person: PersonHint) => void } =
|
||||
$props();
|
||||
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(', '));
|
||||
|
||||
async function openPicker() {
|
||||
@@ -59,28 +69,31 @@ function onKeydown(event: KeyboardEvent) {
|
||||
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>
|
||||
{#if showCue}
|
||||
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<ul
|
||||
bind:this={listEl}
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -13,6 +13,8 @@ const persons: PersonHint[] = [
|
||||
{ id: 'w2', displayName: 'Walter Müller' }
|
||||
];
|
||||
|
||||
const multiProps = { persons, heading: 'Person auswählen', showCue: true };
|
||||
|
||||
function pressEscape() {
|
||||
(document.activeElement as HTMLElement).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
@@ -21,7 +23,7 @@ function pressEscape() {
|
||||
|
||||
describe('DisambiguationPicker', () => {
|
||||
it('opens the picker and shows a select option per ambiguous person', async () => {
|
||||
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
@@ -32,7 +34,7 @@ describe('DisambiguationPicker', () => {
|
||||
});
|
||||
|
||||
it('moves focus into the picker list on open', async () => {
|
||||
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
@@ -40,7 +42,7 @@ describe('DisambiguationPicker', () => {
|
||||
});
|
||||
|
||||
it('returns focus to the trigger when closed with Escape', async () => {
|
||||
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ });
|
||||
await trigger.click();
|
||||
await expect
|
||||
@@ -52,7 +54,7 @@ describe('DisambiguationPicker', () => {
|
||||
|
||||
it('does not call onSelect when dismissed without choosing', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(DisambiguationPicker, { persons, onSelect });
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect });
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||
@@ -63,9 +65,35 @@ describe('DisambiguationPicker', () => {
|
||||
|
||||
it('calls onSelect with the chosen person', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(DisambiguationPicker, { persons, onSelect });
|
||||
render(DisambiguationPicker, { ...multiProps, 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]);
|
||||
});
|
||||
|
||||
it('renders the supplied heading as a visible panel heading', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||
await expect.element(page.getByText('Meintest du Clara Cramer?')).toBeVisible();
|
||||
});
|
||||
|
||||
it('suppresses the cue when showCue is false', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('(auswählen…)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the cue when showCue is true', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await expect.element(page.getByText('(auswählen…)')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user