205 lines
5.1 KiB
TypeScript
205 lines
5.1 KiB
TypeScript
export type AlertA11yProps = {
|
|
role: 'alert';
|
|
'aria-live': 'assertive' | 'polite';
|
|
'aria-label': string;
|
|
};
|
|
|
|
export function alertLabel(level: string, description: string): AlertA11yProps {
|
|
return {
|
|
role: 'alert',
|
|
'aria-live': level === 'danger' ? 'assertive' : 'polite',
|
|
'aria-label': description,
|
|
};
|
|
}
|
|
|
|
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;
|
|
'aria-valuenow': number;
|
|
'aria-valuemin': number;
|
|
'aria-valuemax': number;
|
|
'aria-valuetext': string;
|
|
};
|
|
|
|
export function progressLabel(
|
|
label: string,
|
|
valuePct: number,
|
|
description: string
|
|
): ProgressbarA11yProps {
|
|
return {
|
|
role: 'progressbar',
|
|
'aria-label': label,
|
|
'aria-valuenow': valuePct,
|
|
'aria-valuemin': 0,
|
|
'aria-valuemax': 100,
|
|
'aria-valuetext': description,
|
|
};
|
|
}
|
|
|
|
export type AriaLabelOnly = { 'aria-label': string };
|
|
|
|
export function streakLabel(days: number): AriaLabelOnly {
|
|
return { 'aria-label': `${days} day streak` };
|
|
}
|
|
|
|
export type ButtonA11yProps = {
|
|
'aria-label': string;
|
|
'aria-roledescription'?: string;
|
|
};
|
|
|
|
export function buttonLabel(text: string, hint?: string): ButtonA11yProps {
|
|
return {
|
|
'aria-label': text,
|
|
...(hint ? { 'aria-roledescription': hint } : {}),
|
|
};
|
|
}
|
|
|
|
export function achievementLabel(name: string, description: string): AriaLabelOnly {
|
|
return { 'aria-label': `Achievement: ${name} — ${description}` };
|
|
}
|
|
|
|
export type TimerA11yProps = {
|
|
'aria-label': string;
|
|
'aria-live': 'polite';
|
|
};
|
|
|
|
export function timerLabel(
|
|
hours: number,
|
|
minutes: number,
|
|
seconds: number,
|
|
status: string
|
|
): TimerA11yProps {
|
|
return {
|
|
'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`,
|
|
'aria-live': 'polite',
|
|
};
|
|
}
|
|
|
|
export type SliderA11yProps = {
|
|
role: 'slider';
|
|
'aria-label': string;
|
|
'aria-valuenow': number;
|
|
'aria-valuemin': number;
|
|
'aria-valuemax': number;
|
|
};
|
|
|
|
export function sliderLabel(
|
|
label: string,
|
|
value: number,
|
|
min: number,
|
|
max: number
|
|
): SliderA11yProps {
|
|
return {
|
|
role: 'slider',
|
|
'aria-label': label,
|
|
'aria-valuenow': value,
|
|
'aria-valuemin': min,
|
|
'aria-valuemax': max,
|
|
};
|
|
}
|
|
|
|
function plural(n: number, singular: string, pluralForm: string): string {
|
|
const word = n === 1 ? singular : pluralForm;
|
|
return `${n} ${word}`;
|
|
}
|
|
|
|
/**
|
|
* Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes".
|
|
*/
|
|
export function formatDurationForA11y(hours: number): string {
|
|
const totalMinutes = Math.round(hours * 60);
|
|
const h = Math.floor(totalMinutes / 60);
|
|
const m = totalMinutes % 60;
|
|
|
|
if (h === 0 && m === 0) {
|
|
return '0 minutes';
|
|
}
|
|
if (m === 0) {
|
|
return plural(h, 'hour', 'hours');
|
|
}
|
|
if (h === 0) {
|
|
return plural(m, 'minute', 'minutes');
|
|
}
|
|
return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`;
|
|
}
|