learning_ai_common_plat/packages/accessibility/src/index.ts
2026-05-05 01:15:38 -07:00

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')}`;
}