Files
consentos/apps/banner/src/a11y.ts
James Cottrill fbf26453f2 feat: initial public release
ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
2026-04-14 09:18:18 +00:00

120 lines
3.4 KiB
TypeScript

/**
* Accessibility utilities for the consent banner.
*
* Provides focus trapping, keyboard navigation, and screen reader
* announcements for WCAG 2.1 AA compliance.
*/
/**
* Trap focus within a container element.
* Returns a cleanup function to remove the event listener.
*/
export function trapFocus(container: HTMLElement): () => void {
function handleKeydown(e: KeyboardEvent): void {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements(container);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
// Shift+Tab: wrap from first to last
if (document.activeElement === first || container.shadowRoot?.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab: wrap from last to first
if (document.activeElement === last || container.shadowRoot?.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
container.addEventListener('keydown', handleKeydown);
return () => container.removeEventListener('keydown', handleKeydown);
}
/**
* Set up Escape key to dismiss the banner.
* Returns a cleanup function.
*/
export function onEscape(
container: HTMLElement,
callback: () => void,
): () => void {
function handleKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
e.preventDefault();
callback();
}
}
container.addEventListener('keydown', handleKeydown);
return () => container.removeEventListener('keydown', handleKeydown);
}
/**
* Get all focusable elements within a container, including shadow DOM.
*/
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
// Check shadow root first
const root = container.shadowRoot ?? container;
return Array.from(root.querySelectorAll<HTMLElement>(selector));
}
/**
* Move focus to the first focusable element in the banner.
*/
export function focusFirst(container: HTMLElement): void {
const elements = getFocusableElements(container);
if (elements.length > 0) {
elements[0].focus();
}
}
/**
* Create a visually hidden live region for screen reader announcements.
* Returns the element so you can update its textContent.
*/
export function createLiveRegion(root: HTMLElement | ShadowRoot): HTMLElement {
const region = document.createElement('div');
region.setAttribute('role', 'status');
region.setAttribute('aria-live', 'polite');
region.setAttribute('aria-atomic', 'true');
region.className = 'cmp-sr-only';
root.appendChild(region);
return region;
}
/**
* Announce a message to screen readers via a live region.
*/
export function announce(liveRegion: HTMLElement, message: string): void {
// Clear then set to ensure the screen reader picks up the change
liveRegion.textContent = '';
requestAnimationFrame(() => {
liveRegion.textContent = message;
});
}
/**
* Check if the user prefers reduced motion.
*/
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}