feat(transcription): decouple @mention display text from person search (#380) #629
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -193,13 +193,31 @@ function selectItem(item: Person) {
|
||||
/>
|
||||
</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}
|
||||
<!--
|
||||
Single live region so screen readers announce the transition between
|
||||
"Namen eingeben…" (empty search) and "Keine Personen gefunden"
|
||||
(searched but empty). Leonie FINDING-MENTION-002 on PR #629.
|
||||
Visible empty-state copy — visual-only. The persistent sr-only <p>
|
||||
above is the announcer. Leonie #3 on PR #629 round 3.
|
||||
-->
|
||||
<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() === ''
|
||||
? m.person_mention_search_prompt()
|
||||
: 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 () => {
|
||||
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 <p> (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 <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 () => {
|
||||
render(MentionDropdown, { props: { model: baseModel() } });
|
||||
|
||||
|
||||
@@ -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 <p> (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 <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();
|
||||
});
|
||||
});
|
||||
@@ -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 <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 () => {
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user