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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user