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:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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() } });
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user