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_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken",
|
||||||
"search_disambiguation_cue": "(auswählen…)",
|
"search_disambiguation_cue": "(auswählen…)",
|
||||||
"search_disambiguation_heading": "Person auswählen",
|
"search_disambiguation_heading": "Person auswählen",
|
||||||
|
"search_disambiguation_did_you_mean": "Meintest du {name}?",
|
||||||
"search_disambiguation_select_label": "{name} auswählen",
|
"search_disambiguation_select_label": "{name} auswählen",
|
||||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"search_disambiguation_trigger_label": "Several people found — click to choose",
|
"search_disambiguation_trigger_label": "Several people found — click to choose",
|
||||||
"search_disambiguation_cue": "(choose…)",
|
"search_disambiguation_cue": "(choose…)",
|
||||||
"search_disambiguation_heading": "Choose a person",
|
"search_disambiguation_heading": "Choose a person",
|
||||||
|
"search_disambiguation_did_you_mean": "Did you mean {name}?",
|
||||||
"search_disambiguation_select_label": "Select {name}",
|
"search_disambiguation_select_label": "Select {name}",
|
||||||
"error_validation_error": "The input is invalid.",
|
"error_validation_error": "The input is invalid.",
|
||||||
"error_internal_error": "An unexpected error occurred.",
|
"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_trigger_label": "Se encontraron varias personas — haga clic para elegir",
|
||||||
"search_disambiguation_cue": "(elegir…)",
|
"search_disambiguation_cue": "(elegir…)",
|
||||||
"search_disambiguation_heading": "Elegir una persona",
|
"search_disambiguation_heading": "Elegir una persona",
|
||||||
|
"search_disambiguation_did_you_mean": "¿Quería decir {name}?",
|
||||||
"search_disambiguation_select_label": "Seleccionar {name}",
|
"search_disambiguation_select_label": "Seleccionar {name}",
|
||||||
"error_validation_error": "La entrada no es válida.",
|
"error_validation_error": "La entrada no es válida.",
|
||||||
"error_internal_error": "Se ha producido un error inesperado.",
|
"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 showNlView = $derived(smartMode && nlSubmitted);
|
||||||
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
|
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() {
|
function hasAdvancedFilters() {
|
||||||
return (
|
return (
|
||||||
@@ -442,6 +451,8 @@ $effect(() => {
|
|||||||
{#if nlIsAmbiguous}
|
{#if nlIsAmbiguous}
|
||||||
<DisambiguationPicker
|
<DisambiguationPicker
|
||||||
persons={nlInterpretation.ambiguousPersons}
|
persons={nlInterpretation.ambiguousPersons}
|
||||||
|
heading={disambiguationHeading}
|
||||||
|
showCue={showDisambiguationCue}
|
||||||
onSelect={selectDisambiguated}
|
onSelect={selectDisambiguated}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -6,14 +6,24 @@ import type { components } from '$lib/generated/api';
|
|||||||
|
|
||||||
type PersonHint = components['schemas']['PersonHint'];
|
type PersonHint = components['schemas']['PersonHint'];
|
||||||
|
|
||||||
let { persons, onSelect }: { persons: PersonHint[]; onSelect: (person: PersonHint) => void } =
|
let {
|
||||||
$props();
|
persons,
|
||||||
|
heading,
|
||||||
|
showCue,
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
persons: PersonHint[];
|
||||||
|
heading: string;
|
||||||
|
showCue: boolean;
|
||||||
|
onSelect: (person: PersonHint) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let triggerEl = $state<HTMLButtonElement>();
|
let triggerEl = $state<HTMLButtonElement>();
|
||||||
let listEl = $state<HTMLUListElement>();
|
let listEl = $state<HTMLUListElement>();
|
||||||
|
|
||||||
const panelId = 'disambiguation-panel';
|
const panelId = 'disambiguation-panel';
|
||||||
|
const headingId = 'disambiguation-heading';
|
||||||
const names = $derived(persons.map((person) => person.displayName).join(', '));
|
const names = $derived(persons.map((person) => person.displayName).join(', '));
|
||||||
|
|
||||||
async function openPicker() {
|
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"
|
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="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>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<ul
|
<div
|
||||||
bind:this={listEl}
|
|
||||||
id={panelId}
|
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"
|
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)}
|
<p id={headingId} class="px-4 py-1.5 text-sm font-bold text-ink">{heading}</p>
|
||||||
<li>
|
<ul bind:this={listEl} aria-labelledby={headingId}>
|
||||||
<button
|
{#each persons as person (person.id)}
|
||||||
type="button"
|
<li>
|
||||||
aria-label={m.search_disambiguation_select_label({ name: person.displayName })}
|
<button
|
||||||
onclick={() => select(person)}
|
type="button"
|
||||||
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"
|
aria-label={m.search_disambiguation_select_label({ name: person.displayName })}
|
||||||
>
|
onclick={() => select(person)}
|
||||||
{person.displayName}
|
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"
|
||||||
</button>
|
>
|
||||||
</li>
|
{person.displayName}
|
||||||
{/each}
|
</button>
|
||||||
</ul>
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const persons: PersonHint[] = [
|
|||||||
{ id: 'w2', displayName: 'Walter Müller' }
|
{ id: 'w2', displayName: 'Walter Müller' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const multiProps = { persons, heading: 'Person auswählen', showCue: true };
|
||||||
|
|
||||||
function pressEscape() {
|
function pressEscape() {
|
||||||
(document.activeElement as HTMLElement).dispatchEvent(
|
(document.activeElement as HTMLElement).dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||||
@@ -21,7 +23,7 @@ function pressEscape() {
|
|||||||
|
|
||||||
describe('DisambiguationPicker', () => {
|
describe('DisambiguationPicker', () => {
|
||||||
it('opens the picker and shows a select option per ambiguous person', async () => {
|
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 page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
.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 () => {
|
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 page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
.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 () => {
|
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/ });
|
const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ });
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
await expect
|
await expect
|
||||||
@@ -52,7 +54,7 @@ describe('DisambiguationPicker', () => {
|
|||||||
|
|
||||||
it('does not call onSelect when dismissed without choosing', async () => {
|
it('does not call onSelect when dismissed without choosing', async () => {
|
||||||
const onSelect = vi.fn();
|
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: /Mehrere Personen gefunden/ }).click();
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||||
@@ -63,9 +65,35 @@ describe('DisambiguationPicker', () => {
|
|||||||
|
|
||||||
it('calls onSelect with the chosen person', async () => {
|
it('calls onSelect with the chosen person', async () => {
|
||||||
const onSelect = vi.fn();
|
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: /Mehrere Personen gefunden/ }).click();
|
||||||
await page.getByRole('button', { name: 'Walter Müller auswählen' }).click();
|
await page.getByRole('button', { name: 'Walter Müller auswählen' }).click();
|
||||||
expect(onSelect).toHaveBeenCalledWith(persons[1]);
|
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