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));
|
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(() => {
|
const omitTickYear = $derived.by(() => {
|
||||||
if (filled.length === 0 || filled[0].month.length === 4) return false;
|
if (filled.length === 0 || filled[0].month.length === 4) return false;
|
||||||
const firstYear = filled[0].month.slice(0, 4);
|
const firstYear = filled[0].month.slice(0, 4);
|
||||||
@@ -306,6 +319,10 @@ const omitTickYear = $derived.by(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="absolute top-2 right-2 flex items-center gap-1">
|
||||||
{#if isZoomed}
|
{#if isZoomed}
|
||||||
<button
|
<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', () => {
|
describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||||
function pointerDown(el: HTMLElement) {
|
function pointerDown(el: HTMLElement) {
|
||||||
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
|
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
|
||||||
|
|||||||
Reference in New Issue
Block a user