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 = {
|
||||
role: 'progressbar';
|
||||
'aria-label': string;
|
||||
@ -24,7 +104,7 @@ export type ProgressbarA11yProps = {
|
||||
export function progressLabel(
|
||||
label: string,
|
||||
valuePct: number,
|
||||
description: string,
|
||||
description: string
|
||||
): ProgressbarA11yProps {
|
||||
return {
|
||||
role: 'progressbar',
|
||||
@ -67,7 +147,7 @@ export function timerLabel(
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
status: string,
|
||||
status: string
|
||||
): TimerA11yProps {
|
||||
return {
|
||||
'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`,
|
||||
@ -87,7 +167,7 @@ export function sliderLabel(
|
||||
label: string,
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
max: number
|
||||
): SliderA11yProps {
|
||||
return {
|
||||
role: 'slider',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user