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:
Marcel
2026-05-20 07:15:15 +02:00
committed by marcel
parent 0af43043ba
commit d47326d01c
2 changed files with 36 additions and 9 deletions

View File

@@ -43,6 +43,11 @@ let {
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
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.
//
// 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">
{#if model.items.length === 0}
{searchQuery.trim() === ''
? m.person_mention_search_prompt()
: m.person_mention_popup_empty()}
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
{:else if model.items.length === 1}
{m.person_mention_results_count_singular()}
{:else}
@@ -215,12 +218,14 @@ function selectItem(item: Person) {
{#if model.items.length === 0}
<!--
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">
{searchQuery.trim() === ''
? m.person_mention_search_prompt()
: m.person_mention_popup_empty()}
<p aria-hidden="true" class="px-3 py-2.5 font-sans text-sm text-ink-3">
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
</p>
<!--
Empty-state escape hatch — without it the transcriber has to close