From 6dd60571e3fa3a5e22013ff0ce0fd3faadb2677f Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 09:00:15 +0200 Subject: [PATCH] fix(person-mention): name the hover-card region and announce its busy state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leonie FINDING-02/03 + Elicit NFR concern + Sara #4: role="region" with no aria-label is an axe-core warning, and the pulsing-bars skeleton carries no semantics for SR clients. - Add aria-label to the region root: person displayName when loaded, localised "Lade Person…" while loading. Region always has a name. - Add aria-busy="true" while loading; cleared on loaded/error so the state change is announced via aria-live="polite". - Add role="status" + aria-label on the skeleton so SR clients hear "Lade Person" rather than three silent
s. - New Paraglide key person_mention_loading in de/en/es. Five new tests pin: aria-busy true while loading, aria-busy unset/false when loaded, aria-label is displayName when loaded, aria-label is the loading label while loading. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../src/lib/components/PersonHoverCard.svelte | 20 +++++++- .../components/PersonHoverCard.svelte.spec.ts | 48 +++++++++++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index f1b189ce..8ef11f81 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -423,6 +423,7 @@ "person_mention_open_link": "Zur Person", "person_mention_hover_hint": "Klick öffnet Seite", "person_mention_load_error": "Person konnte nicht geladen werden.", + "person_mention_loading": "Lade Person…", "person_mention_popup_empty": "Keine Personen gefunden", "person_mention_btn_label": "Person verlinken", "person_mention_create_new": "Neue Person anlegen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 02111802..c0909263 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -423,6 +423,7 @@ "person_mention_open_link": "Open person", "person_mention_hover_hint": "Click opens the page", "person_mention_load_error": "Could not load person.", + "person_mention_loading": "Loading person…", "person_mention_popup_empty": "No persons found", "person_mention_btn_label": "Link person", "person_mention_create_new": "Create new person", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 5d416017..4b2fcdaf 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -423,6 +423,7 @@ "person_mention_open_link": "Ir a la persona", "person_mention_hover_hint": "Clic abre la página", "person_mention_load_error": "No se pudo cargar la persona.", + "person_mention_loading": "Cargando persona…", "person_mention_popup_empty": "No se encontraron personas", "person_mention_btn_label": "Vincular persona", "person_mention_create_new": "Crear nueva persona", diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte index 3374de23..74c36260 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -41,6 +41,17 @@ const notesExcerpt = $derived.by(() => { if (notes.length <= NOTES_MAX) return notes; return notes.slice(0, NOTES_MAX) + '…'; }); + +// Accessible name for the region landmark — required by WCAG 1.3.1. +// Falls back to a localised loading label so axe-core never sees an unnamed +// region (Leonie FINDING-02 / Elicit NFR concern). +const ariaLabel = $derived( + state.status === 'loaded' ? state.person.displayName : m.person_mention_loading() +); + +// aria-busy="true" while loading so SR clients know the region's contents +// will change. Cleared on loaded/error so the new content is announced. +const ariaBusy = $derived(state.status === 'loading');
{ id={cardId} role="region" aria-live="polite" + aria-label={ariaLabel} + aria-busy={ariaBusy ? 'true' : undefined} style:position="absolute" style:top={`${position.top}px`} style:left={`${position.left}px`} > {#if state.status === 'loading'} -
+
diff --git a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts index ac8bb400..0d9dd955 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts +++ b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts @@ -246,6 +246,54 @@ describe('PersonHoverCard — accessibility', () => { expect(root.getAttribute('aria-live')).toBe('polite'); }); + it('sets aria-busy="true" while loading so SR announces the state change on load', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.getAttribute('aria-busy')).toBe('true'); + }); + + it('does not set aria-busy when loaded (so the loaded content is announced)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + // aria-busy is either absent or "false" + const busy = root.getAttribute('aria-busy'); + expect(busy === null || busy === 'false').toBe(true); + }); + + it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz'); + }); + + it('names the region with a generic loading label while loading', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + // Region must have an accessible name in every state — axe-core flags + // role="region" without aria-label / aria-labelledby. + expect(root.getAttribute('aria-label')).toBeTruthy(); + }); + it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => { render(PersonHoverCard, { personId: 'p-aug',