a11y(transcription): persistent aria-live region for @mention dropdown

The aria-live region previously lived inside {#if items.length === 0} so
it remounted whenever items transitioned between empty and populated —
VoiceOver in particular swallows announcements from freshly-mounted live
regions, and the "N persons found" announcement was missing entirely on
the populated branch. Move the live region above the conditional so the
element persists, and announce a localized "1 person found" / "N persons
found" count on the populated branch. The visible empty-state <p> stays
as a visual cue (no aria-live). Leonie #3 on PR #629 round 3.

Adds person_mention_results_count_singular / _plural in de/en/es.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-20 00:13:47 +02:00
committed by marcel
parent e0b2db061b
commit 790f870cac
6 changed files with 106 additions and 15 deletions

View File

@@ -449,6 +449,8 @@
"person_mention_search_prompt": "Namen eingeben…", "person_mention_search_prompt": "Namen eingeben…",
"person_mention_btn_label": "Person verlinken", "person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen", "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", "transcription_editor_aria_label": "Transkriptionstext",
"person_born_name_prefix": "geb.", "person_born_name_prefix": "geb.",
"page_title_home": "Archiv", "page_title_home": "Archiv",

View File

@@ -449,6 +449,8 @@
"person_mention_search_prompt": "Enter a name…", "person_mention_search_prompt": "Enter a name…",
"person_mention_btn_label": "Link person", "person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new 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", "transcription_editor_aria_label": "Transcription text",
"person_born_name_prefix": "née", "person_born_name_prefix": "née",
"page_title_home": "Archive", "page_title_home": "Archive",

View File

@@ -449,6 +449,8 @@
"person_mention_search_prompt": "Escribe un nombre…", "person_mention_search_prompt": "Escribe un nombre…",
"person_mention_btn_label": "Vincular persona", "person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva 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", "transcription_editor_aria_label": "Texto de transcripción",
"person_born_name_prefix": "n.", "person_born_name_prefix": "n.",
"page_title_home": "Archivo", "page_title_home": "Archivo",

View File

@@ -193,13 +193,31 @@ function selectItem(item: Person) {
/> />
</div> </div>
</div> </div>
<!--
Persistent aria-live region — lives ABOVE the conditional branches so the
element never unmounts when items transition between empty and populated.
VoiceOver in particular swallows announcements from freshly-mounted live
regions, and the previous (conditional-inside) markup silently dropped
the "N persons found" announcement when results populated. Leonie #3 on
PR #629 round 3.
-->
<p class="sr-only" aria-live="polite">
{#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}
</p>
{#if model.items.length === 0} {#if model.items.length === 0}
<!-- <!--
Single live region so screen readers announce the transition between Visible empty-state copy — visual-only. The persistent sr-only <p>
"Namen eingeben…" (empty search) and "Keine Personen gefunden" above is the announcer. Leonie #3 on PR #629 round 3.
(searched but empty). Leonie FINDING-MENTION-002 on PR #629.
--> -->
<p class="px-3 py-2.5 font-sans text-sm text-ink-3" aria-live="polite"> <p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{searchQuery.trim() === '' {searchQuery.trim() === ''
? m.person_mention_search_prompt() ? m.person_mention_search_prompt()
: m.person_mention_popup_empty()} : m.person_mention_popup_empty()}

View File

@@ -47,15 +47,25 @@ describe('MentionDropdown', () => {
it('shows the "enter a name" prompt when the search field is empty', async () => { it('shows the "enter a name" prompt when the search field is empty', async () => {
render(MentionDropdown, { props: { model: baseModel() } }); render(MentionDropdown, { props: { model: baseModel() } });
await expect.element(page.getByText(m.person_mention_search_prompt())).toBeVisible(); // Scope to the visible empty-state <p> (text-ink-3) — the persistent
await expect.element(page.getByText(m.person_mention_popup_empty())).not.toBeInTheDocument(); // 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 () => { it('shows "no persons found" when the search has a query but the list is empty', async () => {
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } }); render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
await expect.element(page.getByText(m.person_mention_popup_empty())).toBeVisible(); const visibleEmptyP = document.querySelector(
await expect.element(page.getByText(m.person_mention_search_prompt())).not.toBeInTheDocument(); '[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 () => { 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]'); 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() } }); render(MentionDropdown, { props: { model: baseModel() } });
const listbox = document.querySelector('[role="listbox"]'); const listbox = document.querySelector('[role="listbox"]');
expect(listbox).not.toBeNull(); expect(listbox).not.toBeNull();
const live = listbox!.querySelector('p[aria-live="polite"]'); const live = listbox!.querySelector('p[aria-live="polite"]');
expect(live).not.toBeNull(); expect(live).not.toBeNull();
// Empty + empty-query → "Namen eingeben…" prompt
expect(live!.textContent ?? '').toContain(m.person_mention_search_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 <p> 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 () => { 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() } }); render(MentionDropdown, { props: { model: baseModel() } });

View File

@@ -166,8 +166,15 @@ describe('PersonMentionEditor — typeahead', () => {
await userEvent.type(page.getByRole('textbox'), '@xyz'); await userEvent.type(page.getByRole('textbox'), '@xyz');
await vi.waitFor(async () => { // The visible empty-state <p> (text-ink-3) shows the copy. The persistent
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); // 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 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 <p> (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(); expect(fetchMock).not.toHaveBeenCalled();
}); });
}); });
@@ -340,8 +353,14 @@ describe('PersonMentionEditor — server failure', () => {
// Pins current silent-failure behaviour. The day someone implements a // Pins current silent-failure behaviour. The day someone implements a
// distinct error UX (toast / "Suche fehlgeschlagen" copy), this test // distinct error UX (toast / "Suche fehlgeschlagen" copy), this test
// goes red and forces them to update the assertion. // goes red and forces them to update the assertion. Scope to the
await expect.element(page.getByText(m.person_mention_popup_empty())).toBeInTheDocument(); // visible <p> (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 () => { 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 userEvent.type(page.getByRole('textbox'), '@Aug');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); 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());
}); });
}); });