diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 540ca28e..53087e87 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -449,6 +449,8 @@ "person_mention_search_prompt": "Namen eingeben…", "person_mention_btn_label": "Person verlinken", "person_mention_create_new": "Neue Person anlegen", + "person_mention_results_count_singular": "1 Person gefunden", + "person_mention_results_count_plural": "{count} Personen gefunden", "transcription_editor_aria_label": "Transkriptionstext", "person_born_name_prefix": "geb.", "page_title_home": "Archiv", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 227dec4e..b50a5330 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -449,6 +449,8 @@ "person_mention_search_prompt": "Enter a name…", "person_mention_btn_label": "Link person", "person_mention_create_new": "Create new person", + "person_mention_results_count_singular": "1 person found", + "person_mention_results_count_plural": "{count} persons found", "transcription_editor_aria_label": "Transcription text", "person_born_name_prefix": "née", "page_title_home": "Archive", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 5fc8abf7..d731d104 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -449,6 +449,8 @@ "person_mention_search_prompt": "Escribe un nombre…", "person_mention_btn_label": "Vincular persona", "person_mention_create_new": "Crear nueva persona", + "person_mention_results_count_singular": "1 persona encontrada", + "person_mention_results_count_plural": "{count} personas encontradas", "transcription_editor_aria_label": "Texto de transcripción", "person_born_name_prefix": "n.", "page_title_home": "Archivo", diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 0cc4378c..b0d1202a 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -193,13 +193,31 @@ function selectItem(item: Person) { /> + +

+ {#if model.items.length === 0} + {searchQuery.trim() === '' + ? m.person_mention_search_prompt() + : m.person_mention_popup_empty()} + {:else if model.items.length === 1} + {m.person_mention_results_count_singular()} + {:else} + {m.person_mention_results_count_plural({ count: model.items.length })} + {/if} +

{#if model.items.length === 0} -

+

{searchQuery.trim() === '' ? m.person_mention_search_prompt() : m.person_mention_popup_empty()} diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts index 0499cf7b..47554642 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -47,15 +47,25 @@ describe('MentionDropdown', () => { it('shows the "enter a name" prompt when the search field is empty', async () => { render(MentionDropdown, { props: { model: baseModel() } }); - await expect.element(page.getByText(m.person_mention_search_prompt())).toBeVisible(); - await expect.element(page.getByText(m.person_mention_popup_empty())).not.toBeInTheDocument(); + // Scope to the visible empty-state

(text-ink-3) — the persistent + // sr-only aria-live region above also contains the same prompt copy. + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt()); + expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_popup_empty()); }); it('shows "no persons found" when the search has a query but the list is empty', async () => { render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } }); - await expect.element(page.getByText(m.person_mention_popup_empty())).toBeVisible(); - await expect.element(page.getByText(m.person_mention_search_prompt())).not.toBeInTheDocument(); + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty()); + expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_search_prompt()); }); it('shows the create-new escape hatch link in the empty state', async () => { @@ -156,16 +166,50 @@ describe('MentionDropdown — search input', () => { expect(input.className).toContain('min-h-[44px]'); }); - it('announces empty-state copy via aria-live="polite" (Leonie FINDING-MENTION-002 on PR #629)', async () => { + it('renders a persistent aria-live="polite" region (does not remount on items transition; Leonie #3 on PR #629)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); const listbox = document.querySelector('[role="listbox"]'); expect(listbox).not.toBeNull(); const live = listbox!.querySelector('p[aria-live="polite"]'); expect(live).not.toBeNull(); + // Empty + empty-query → "Namen eingeben…" prompt expect(live!.textContent ?? '').toContain(m.person_mention_search_prompt()); }); + it('announces the result count in the persistent live region when items populate (Leonie #3 on PR #629)', async () => { + render(MentionDropdown, { + props: { + model: baseModel({ + items: [ + makePerson('p1', 'Anna Schmidt'), + makePerson('p2', 'Bert Meier'), + makePerson('p3', 'Carl Vogel') + ] + }) + } + }); + + const listbox = document.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const live = listbox!.querySelector('p[aria-live="polite"]'); + expect(live).not.toBeNull(); + // Populated → "3 Personen gefunden" (plural) + expect(live!.textContent ?? '').toContain('3'); + }); + + it('keeps the visible empty-state copy without its own aria-live (the persistent live region announces; Leonie #3 on PR #629)', async () => { + render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } }); + + // Visible empty-state

exists with the empty-result copy ... + const empty = document.querySelector('p.text-ink-3') as HTMLElement | null; + expect(empty).not.toBeNull(); + expect(empty!.textContent ?? '').toContain(m.person_mention_popup_empty()); + // ... but it must NOT carry its own aria-live (the persistent sr-only + // region above the conditional is the announcer now). + expect(empty!.hasAttribute('aria-live')).toBe(false); + }); + it('renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index d70c0e09..bdbb1b74 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -166,8 +166,15 @@ describe('PersonMentionEditor — typeahead', () => { await userEvent.type(page.getByRole('textbox'), '@xyz'); - await vi.waitFor(async () => { - await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + // The visible empty-state

(text-ink-3) shows the copy. The persistent + // sr-only aria-live region also contains the same copy, so we scope to the + // visible element to avoid a multi-match resolution in expect.element. + await vi.waitFor(() => { + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain('Keine Personen gefunden'); }); }); @@ -290,7 +297,13 @@ describe('PersonMentionEditor — whitespace-only query', () => { await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); - await expect.element(page.getByText(m.person_mention_search_prompt())).toBeInTheDocument(); + // Scope to the visible empty-state

(text-ink-3) — the persistent + // sr-only aria-live region above contains the same copy. + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt()); expect(fetchMock).not.toHaveBeenCalled(); }); }); @@ -340,8 +353,14 @@ describe('PersonMentionEditor — server failure', () => { // Pins current silent-failure behaviour. The day someone implements a // distinct error UX (toast / "Suche fehlgeschlagen" copy), this test - // goes red and forces them to update the assertion. - await expect.element(page.getByText(m.person_mention_popup_empty())).toBeInTheDocument(); + // goes red and forces them to update the assertion. Scope to the + // visible

(text-ink-3) — the persistent sr-only live region + // above contains the same copy. + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty()); }); it('on a fetch reject (network failure) keeps the dropdown open with the empty-state copy', async () => { @@ -352,7 +371,11 @@ describe('PersonMentionEditor — server failure', () => { await userEvent.type(page.getByRole('textbox'), '@Aug'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); - await expect.element(page.getByText(m.person_mention_popup_empty())).toBeInTheDocument(); + const visibleEmptyP = document.querySelector( + '[role="listbox"] p.text-ink-3' + ) as HTMLElement | null; + expect(visibleEmptyP).not.toBeNull(); + expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty()); }); });