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:
Marcel
2026-04-29 09:00:15 +02:00
parent 3365f5845e
commit 6dd60571e3
5 changed files with 70 additions and 1 deletions

View File

@@ -423,6 +423,7 @@
"person_mention_open_link": "Zur Person", "person_mention_open_link": "Zur Person",
"person_mention_hover_hint": "Klick öffnet Seite", "person_mention_hover_hint": "Klick öffnet Seite",
"person_mention_load_error": "Person konnte nicht geladen werden.", "person_mention_load_error": "Person konnte nicht geladen werden.",
"person_mention_loading": "Lade Person…",
"person_mention_popup_empty": "Keine Personen gefunden", "person_mention_popup_empty": "Keine Personen gefunden",
"person_mention_btn_label": "Person verlinken", "person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen", "person_mention_create_new": "Neue Person anlegen",

View File

@@ -423,6 +423,7 @@
"person_mention_open_link": "Open person", "person_mention_open_link": "Open person",
"person_mention_hover_hint": "Click opens the page", "person_mention_hover_hint": "Click opens the page",
"person_mention_load_error": "Could not load person.", "person_mention_load_error": "Could not load person.",
"person_mention_loading": "Loading person…",
"person_mention_popup_empty": "No persons found", "person_mention_popup_empty": "No persons found",
"person_mention_btn_label": "Link person", "person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new person", "person_mention_create_new": "Create new person",

View File

@@ -423,6 +423,7 @@
"person_mention_open_link": "Ir a la persona", "person_mention_open_link": "Ir a la persona",
"person_mention_hover_hint": "Clic abre la página", "person_mention_hover_hint": "Clic abre la página",
"person_mention_load_error": "No se pudo cargar la persona.", "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_popup_empty": "No se encontraron personas",
"person_mention_btn_label": "Vincular persona", "person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva persona", "person_mention_create_new": "Crear nueva persona",

View File

@@ -41,6 +41,17 @@ const notesExcerpt = $derived.by(() => {
if (notes.length <= NOTES_MAX) return notes; if (notes.length <= NOTES_MAX) return notes;
return notes.slice(0, NOTES_MAX) + '…'; 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> </script>
<div <div
@@ -49,12 +60,19 @@ const notesExcerpt = $derived.by(() => {
id={cardId} id={cardId}
role="region" role="region"
aria-live="polite" aria-live="polite"
aria-label={ariaLabel}
aria-busy={ariaBusy ? 'true' : undefined}
style:position="absolute" style:position="absolute"
style:top={`${position.top}px`} style:top={`${position.top}px`}
style:left={`${position.left}px`} style:left={`${position.left}px`}
> >
{#if state.status === 'loading'} {#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> <div class="bar"></div>
<div class="bar"></div> <div class="bar"></div>

View File

@@ -246,6 +246,54 @@ describe('PersonHoverCard — accessibility', () => {
expect(root.getAttribute('aria-live')).toBe('polite'); 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 () => { it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
render(PersonHoverCard, { render(PersonHoverCard, {
personId: 'p-aug', personId: 'p-aug',