From 0ef4f4f07cb99540868574b0f1a0081a859b9dd3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 01:14:25 +0200 Subject: [PATCH] feat(search): case-appropriate disambiguation picker copy (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/routes/documents/+page.svelte | 13 ++++- .../routes/search/DisambiguationPicker.svelte | 51 ++++++++++++------- .../DisambiguationPicker.svelte.spec.ts | 38 ++++++++++++-- 6 files changed, 80 insertions(+), 25 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5ea1b00a..e53f9583 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index be3af0ce..84be1557 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index c5fb2fc8..7a40b82c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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.", diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 7e836964..7d494b24 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -57,7 +57,16 @@ let nlResult = $state(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} {:else} diff --git a/frontend/src/routes/search/DisambiguationPicker.svelte b/frontend/src/routes/search/DisambiguationPicker.svelte index b99fea10..e365cdeb 100644 --- a/frontend/src/routes/search/DisambiguationPicker.svelte +++ b/frontend/src/routes/search/DisambiguationPicker.svelte @@ -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(); let listEl = $state(); 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" > {names} - {m.search_disambiguation_cue()} + {#if showCue} + {m.search_disambiguation_cue()} + {/if} {#if open} -
    - {#each persons as person (person.id)} -
  • - -
  • - {/each} -
+

{heading}

+
    + {#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 index 5b87a996..2f90f0aa 100644 --- a/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts +++ b/frontend/src/routes/search/DisambiguationPicker.svelte.spec.ts @@ -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(); + }); });