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_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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user