fix(person-mention): name the hover-card region and announce its busy state
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 <div>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 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -49,12 +60,19 @@ const notesExcerpt = $derived.by(() => {
|
||||
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'}
|
||||
<div data-testid="person-hover-card-skeleton" class="skeleton">
|
||||
<div
|
||||
data-testid="person-hover-card-skeleton"
|
||||
class="skeleton"
|
||||
role="status"
|
||||
aria-label={m.person_mention_loading()}
|
||||
>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user