feat(accessibility): add focus trap helpers
This commit is contained in:
parent
c49fb7977d
commit
42f60cb286
@ -12,6 +12,86 @@ export function alertLabel(level: string, description: string): AlertA11yProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||||
|
element =>
|
||||||
|
!element.hasAttribute('disabled') &&
|
||||||
|
element.getAttribute('aria-hidden') !== 'true' &&
|
||||||
|
element.offsetParent !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trapFocusKeydown(event: KeyboardEvent, container: HTMLElement): void {
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
|
||||||
|
const focusable = getFocusableElements(container);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusFirstElement(container: HTMLElement, selector = FOCUSABLE_SELECTOR): void {
|
||||||
|
const preferred = container.querySelector<HTMLElement>(selector);
|
||||||
|
const fallback = getFocusableElements(container)[0];
|
||||||
|
(preferred ?? fallback)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScreenReaderPoliteness = 'assertive' | 'polite';
|
||||||
|
|
||||||
|
export function announceToScreenReader(
|
||||||
|
message: string,
|
||||||
|
politeness: ScreenReaderPoliteness = 'polite'
|
||||||
|
): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
const id = `bytelyst-sr-announcer-${politeness}`;
|
||||||
|
let announcer = document.getElementById(id);
|
||||||
|
if (!announcer) {
|
||||||
|
announcer = document.createElement('div');
|
||||||
|
announcer.id = id;
|
||||||
|
announcer.setAttribute('aria-live', politeness);
|
||||||
|
announcer.setAttribute('aria-atomic', 'true');
|
||||||
|
announcer.style.position = 'absolute';
|
||||||
|
announcer.style.width = '1px';
|
||||||
|
announcer.style.height = '1px';
|
||||||
|
announcer.style.margin = '-1px';
|
||||||
|
announcer.style.padding = '0';
|
||||||
|
announcer.style.overflow = 'hidden';
|
||||||
|
announcer.style.clip = 'rect(0 0 0 0)';
|
||||||
|
announcer.style.whiteSpace = 'nowrap';
|
||||||
|
announcer.style.border = '0';
|
||||||
|
document.body.appendChild(announcer);
|
||||||
|
}
|
||||||
|
|
||||||
|
announcer.textContent = '';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (announcer) {
|
||||||
|
announcer.textContent = message;
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
export type ProgressbarA11yProps = {
|
export type ProgressbarA11yProps = {
|
||||||
role: 'progressbar';
|
role: 'progressbar';
|
||||||
'aria-label': string;
|
'aria-label': string;
|
||||||
@ -24,7 +104,7 @@ export type ProgressbarA11yProps = {
|
|||||||
export function progressLabel(
|
export function progressLabel(
|
||||||
label: string,
|
label: string,
|
||||||
valuePct: number,
|
valuePct: number,
|
||||||
description: string,
|
description: string
|
||||||
): ProgressbarA11yProps {
|
): ProgressbarA11yProps {
|
||||||
return {
|
return {
|
||||||
role: 'progressbar',
|
role: 'progressbar',
|
||||||
@ -67,7 +147,7 @@ export function timerLabel(
|
|||||||
hours: number,
|
hours: number,
|
||||||
minutes: number,
|
minutes: number,
|
||||||
seconds: number,
|
seconds: number,
|
||||||
status: string,
|
status: string
|
||||||
): TimerA11yProps {
|
): TimerA11yProps {
|
||||||
return {
|
return {
|
||||||
'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`,
|
'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`,
|
||||||
@ -87,7 +167,7 @@ export function sliderLabel(
|
|||||||
label: string,
|
label: string,
|
||||||
value: number,
|
value: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number
|
||||||
): SliderA11yProps {
|
): SliderA11yProps {
|
||||||
return {
|
return {
|
||||||
role: 'slider',
|
role: 'slider',
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user