a11y(transcription): hide visible @mention empty-state from AT and fold empty-query check
Round-4 polish from Leonie (S-2), Felix (#3), Sara (#4): - Add aria-hidden="true" to the visible empty-state <p> so VoiceOver does not double-announce — the persistent sr-only live region is now the sole AT source of truth (NVDA already de-duped, VoiceOver did not). - Extract `searchQuery.trim() === ''` into an `isQueryEmpty` $derived; both the announcer branch and the visible empty-state branch now read from the single intent-named alias. - Cover the singular branch of the persistent live region (1 item -> "1 Person gefunden" / "1 person found" / "1 persona encontrada"). Plural was already covered; this closes the missing-branch gap. - Extend the existing "no aria-live on visible <p>" test to also assert aria-hidden="true" so a regression on the AT-source-of-truth contract goes red immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,11 @@ let {
|
|||||||
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
||||||
let userHasEdited = $state(false);
|
let userHasEdited = $state(false);
|
||||||
|
|
||||||
|
// Intent-revealing alias used by both the persistent aria-live announcer and
|
||||||
|
// the visible empty-state copy. Folding the duplicated rule into one $derived
|
||||||
|
// keeps the two branches in lockstep. Felix #3 on PR #629 round 4.
|
||||||
|
const isQueryEmpty = $derived(searchQuery.trim() === '');
|
||||||
|
|
||||||
// Mirror the editor's typed text until the user takes ownership.
|
// Mirror the editor's typed text until the user takes ownership.
|
||||||
//
|
//
|
||||||
// Why `$state + $effect` (not `$derived`): `searchQuery` is also written by
|
// Why `$state + $effect` (not `$derived`): `searchQuery` is also written by
|
||||||
@@ -203,9 +208,7 @@ function selectItem(item: Person) {
|
|||||||
-->
|
-->
|
||||||
<p class="sr-only" aria-live="polite">
|
<p class="sr-only" aria-live="polite">
|
||||||
{#if model.items.length === 0}
|
{#if model.items.length === 0}
|
||||||
{searchQuery.trim() === ''
|
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
||||||
? m.person_mention_search_prompt()
|
|
||||||
: m.person_mention_popup_empty()}
|
|
||||||
{:else if model.items.length === 1}
|
{:else if model.items.length === 1}
|
||||||
{m.person_mention_results_count_singular()}
|
{m.person_mention_results_count_singular()}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -215,12 +218,14 @@ function selectItem(item: Person) {
|
|||||||
{#if model.items.length === 0}
|
{#if model.items.length === 0}
|
||||||
<!--
|
<!--
|
||||||
Visible empty-state copy — visual-only. The persistent sr-only <p>
|
Visible empty-state copy — visual-only. The persistent sr-only <p>
|
||||||
above is the announcer. Leonie #3 on PR #629 round 3.
|
above is the sole AT announcer; this one is hidden from screen readers
|
||||||
|
via aria-hidden="true" so VoiceOver does not double-announce
|
||||||
|
(NVDA de-dups, VoiceOver does not). Leonie S-2 on PR #629 round 4.
|
||||||
|
Do NOT add an aria-live attribute here — that would re-introduce
|
||||||
|
the duplicate announcement.
|
||||||
-->
|
-->
|
||||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
<p aria-hidden="true" class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||||
{searchQuery.trim() === ''
|
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
||||||
? m.person_mention_search_prompt()
|
|
||||||
: m.person_mention_popup_empty()}
|
|
||||||
</p>
|
</p>
|
||||||
<!--
|
<!--
|
||||||
Empty-state escape hatch — without it the transcriber has to close
|
Empty-state escape hatch — without it the transcriber has to close
|
||||||
|
|||||||
@@ -198,7 +198,25 @@ describe('MentionDropdown — search input', () => {
|
|||||||
expect(live!.textContent ?? '').toContain('3');
|
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 () => {
|
it('announces the singular form when exactly one item is present (Sara #4 on PR #629)', async () => {
|
||||||
|
render(MentionDropdown, {
|
||||||
|
props: {
|
||||||
|
model: baseModel({
|
||||||
|
items: [makePerson('p1', 'Anna Schmidt')]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const listbox = document.querySelector('[role="listbox"]');
|
||||||
|
expect(listbox).not.toBeNull();
|
||||||
|
const live = listbox!.querySelector('p[aria-live="polite"]');
|
||||||
|
expect(live).not.toBeNull();
|
||||||
|
// Singular branch — "1 Person gefunden" / "1 person found" / "1 persona encontrada"
|
||||||
|
// (locale-dependent; resolved via the Paraglide message helper).
|
||||||
|
expect(live!.textContent ?? '').toContain(m.person_mention_results_count_singular());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the visible empty-state copy without its own aria-live and hides it from AT (Leonie #3 on PR #629 round 3; Leonie S-2 round 4)', async () => {
|
||||||
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
|
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
|
||||||
|
|
||||||
// Visible empty-state <p> exists with the empty-result copy ...
|
// Visible empty-state <p> exists with the empty-result copy ...
|
||||||
@@ -208,6 +226,10 @@ describe('MentionDropdown — search input', () => {
|
|||||||
// ... but it must NOT carry its own aria-live (the persistent sr-only
|
// ... but it must NOT carry its own aria-live (the persistent sr-only
|
||||||
// region above the conditional is the announcer now).
|
// region above the conditional is the announcer now).
|
||||||
expect(empty!.hasAttribute('aria-live')).toBe(false);
|
expect(empty!.hasAttribute('aria-live')).toBe(false);
|
||||||
|
// ... and it MUST be hidden from screen readers via aria-hidden="true"
|
||||||
|
// so VoiceOver does not double-announce (the persistent sr-only region
|
||||||
|
// is the sole AT source of truth). Leonie S-2 on PR #629 round 4.
|
||||||
|
expect(empty!.getAttribute('aria-hidden')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user