feat(documents): aria-live drag preview for screen readers (#385)

Adds a visually-hidden polite live region whose text reflects the
current drag range using the existing timeline_dragging_aria_live
i18n key. Closes Leonie's WCAG follow-the-drag-preview gap and turns
the previously orphaned i18n key into used markup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-08 09:56:20 +02:00
parent ea106e9414
commit 5028082da4
2 changed files with 64 additions and 0 deletions

View File

@@ -225,6 +225,19 @@ const dragWindowRightPct = $derived.by(() => {
});
const tickIndices = $derived(tickIndicesFor(filled));
// While dragging, expose the live preview range to assistive tech via a
// polite live region. Empty text outside drag avoids announcing residual state.
const dragLiveMessage = $derived.by(() => {
if (!isDragging || dragLowIndex === null || dragHighIndex === null) return '';
const fromLabel = filled[dragLowIndex]?.month;
const toLabel = filled[dragHighIndex]?.month;
if (!fromLabel || !toLabel) return '';
return m.timeline_dragging_aria_live({
from: formatTickLabel(fromLabel, getLocale()),
to: formatTickLabel(toLabel, getLocale())
});
});
const omitTickYear = $derived.by(() => {
if (filled.length === 0 || filled[0].month.length === 4) return false;
const firstYear = filled[0].month.slice(0, 4);
@@ -306,6 +319,10 @@ const omitTickYear = $derived.by(() => {
</div>
</div>
<div class="sr-only" aria-live="polite" data-testid="timeline-aria-live">
{dragLiveMessage}
</div>
<div class="absolute top-2 right-2 flex items-center gap-1">
{#if isZoomed}
<button

View File

@@ -342,6 +342,53 @@ describe('TimelineDensityFilter — accessibility', () => {
});
});
describe('TimelineDensityFilter — aria-live during drag', () => {
function pointerDown(el: HTMLElement) {
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }));
}
function pointerEnter(el: HTMLElement) {
el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1 }));
}
function pointerUp(el: HTMLElement) {
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
}
it('renders a polite aria-live region whose text reflects the dragged range', async () => {
render(
TimelineDensityFilter,
makeProps({
density: [
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 3 }
],
minDate: '1915-08-01',
maxDate: '1915-10-31'
})
);
const live = document.querySelector('[data-testid="timeline-aria-live"]') as HTMLElement;
expect(live).not.toBeNull();
expect(live.getAttribute('aria-live')).toBe('polite');
expect(live.textContent?.trim() ?? '').toBe('');
const bars = document.querySelectorAll(
'[data-testid="timeline-bar"]'
) as NodeListOf<HTMLElement>;
pointerDown(bars[0]);
pointerEnter(bars[2]);
await tick();
const text = live.textContent ?? '';
expect(text).toContain(formatTickLabel('1915-08', getLocale()));
expect(text).toContain(formatTickLabel('1915-10', getLocale()));
pointerUp(bars[2]);
await tick();
expect(live.textContent?.trim() ?? '').toBe('');
});
});
describe('TimelineDensityFilter — drag-to-select-range', () => {
function pointerDown(el: HTMLElement) {
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });