Accessibility
The accessibility module provides focus management, announcements, keyboard-navigation helpers, media-preference signals, and development-time audits. It follows WAI-ARIA best practices and WCAG guidelines.
import {
announceToScreenReader,
auditA11y,
clearAnnouncements,
getFocusableElements,
prefersColorScheme,
prefersContrast,
prefersReducedMotion,
releaseFocus,
rovingTabIndex,
skipLink,
trapFocus,
} from '@bquery/bquery/a11y';Focus Traps
trapFocus()
Traps keyboard focus within a container element, creating a focus cycle for modals, dialogs, drawers, and similar overlay patterns. Tab and Shift+Tab cycle through focusable elements inside the container without escaping.
function trapFocus(container: HTMLElement, options?: TrapFocusOptions): FocusTrapHandle;| Parameter | Type | Description |
|---|---|---|
container | HTMLElement | The element to trap focus within |
options | TrapFocusOptions | Optional configuration |
TrapFocusOptions
interface TrapFocusOptions {
/** Initial focus target — an HTMLElement or CSS selector. */
initialFocus?: HTMLElement | string;
/** Element to return focus to when released — an HTMLElement or CSS selector. */
returnFocus?: HTMLElement | string;
/** Whether pressing Escape releases the trap. Default: `true` */
escapeDeactivates?: boolean;
/** Callback invoked when Escape is pressed (only when `escapeDeactivates` is `true`). */
onEscape?: () => void;
}FocusTrapHandle
interface FocusTrapHandle {
/** Release the focus trap and restore focus. */
release: () => void;
/** Whether the trap is currently active. */
active: boolean;
}Examples
Basic modal trap:
const dialog = document.querySelector('#dialog')!;
const trap = trapFocus(dialog, {
initialFocus: '#close-button',
escapeDeactivates: true,
});
// Tab/Shift+Tab now cycles within #dialog
console.log(trap.active); // true
// Later: release the trap
trap.release();
console.log(trap.active); // falseWith return focus and escape handler:
const openButton = document.querySelector('#open')!;
const modal = document.querySelector('#modal')!;
const trap = trapFocus(modal, {
initialFocus: '#modal-title',
returnFocus: openButton,
escapeDeactivates: true,
onEscape: () => {
modal.hidden = true;
trap.release();
},
});releaseFocus()
Convenience function for releasing a focus trap. Prefer using handle.release() directly.
function releaseFocus(handle: FocusTrapHandle): void;releaseFocus(trap); // Same as trap.release()getFocusableElements()
Returns all focusable elements within a container. Includes links, buttons, inputs, textareas, selects, elements with [tabindex], [contenteditable], <details>, and <audio>/<video> with controls.
function getFocusableElements(container: Element): HTMLElement[];const dialog = document.querySelector('#dialog')!;
const focusable = getFocusableElements(dialog);
console.log(focusable.length); // Number of focusable elements insideLive-Region Announcements
announceToScreenReader()
Announces a message to screen readers via an ARIA live region. The module manages the live region elements automatically.
function announceToScreenReader(message: string, priority?: AnnouncePriority): void;| Parameter | Type | Default | Description |
|---|---|---|---|
message | string | — | The text to announce |
priority | AnnouncePriority | 'polite' | Urgency level: 'polite' waits for idle, 'assertive' interrupts |
type AnnouncePriority = 'polite' | 'assertive';Examples
// Polite announcement (waits for screen reader to finish current speech)
announceToScreenReader('Profile saved');
// Assertive announcement (interrupts current speech)
announceToScreenReader('Form failed to submit', 'assertive');
// Used after async operations
async function saveProfile() {
try {
await fetch('/api/profile', { method: 'POST' });
announceToScreenReader('Profile saved successfully');
} catch {
announceToScreenReader('Failed to save profile', 'assertive');
}
}clearAnnouncements()
Removes all live region elements and pending announcements. Useful for test cleanup.
function clearAnnouncements(): void;import { clearAnnouncements } from '@bquery/bquery/a11y';
afterEach(() => {
clearAnnouncements();
});Roving Tabindex
rovingTabIndex()
Implements the WAI-ARIA roving tabindex pattern for keyboard navigation in composite widgets like toolbars, tab lists, and menus. Only the active item has tabindex="0" — all others have tabindex="-1".
function rovingTabIndex(
container: HTMLElement,
itemSelector: string,
options?: RovingTabIndexOptions
): RovingTabIndexHandle;| Parameter | Type | Description |
|---|---|---|
container | HTMLElement | The composite widget container |
itemSelector | string | CSS selector for navigable items |
options | RovingTabIndexOptions | Optional configuration |
RovingTabIndexOptions
interface RovingTabIndexOptions {
/** Whether arrow keys wrap around at the ends. Default: `true` */
wrap?: boolean;
/** Navigation axis. Default: `'vertical'` */
orientation?: 'horizontal' | 'vertical' | 'both';
/** Callback when an item is activated (focused). */
onActivate?: (element: Element, index: number) => void;
}'horizontal'→ Left/Right arrow keys'vertical'→ Up/Down arrow keys'both'→ All four arrow keys
Keyboard shortcuts:
- Arrow keys — move between items
Home— jump to first itemEnd— jump to last item
RovingTabIndexHandle
interface RovingTabIndexHandle {
/** Remove keyboard listeners and clean up. */
destroy: () => void;
/** Programmatically focus an item by index. */
focusItem: (index: number) => void;
/** Get the currently focused item index. */
activeIndex: () => number;
}Examples
Horizontal toolbar:
const toolbar = document.querySelector('#toolbar')!;
const roving = rovingTabIndex(toolbar, 'button', {
orientation: 'horizontal',
wrap: true,
onActivate: (el, index) => {
console.log(`Button ${index} focused:`, el);
},
});
// Programmatically focus the third button
roving.focusItem(2);
// Get current active index
console.log(roving.activeIndex()); // 2
// Clean up
roving.destroy();Vertical menu:
const menu = document.querySelector('[role="menu"]')!;
const roving = rovingTabIndex(menu, '[role="menuitem"]', {
orientation: 'vertical',
wrap: false,
});
roving.destroy();Tab list:
const tabList = document.querySelector('[role="tablist"]')!;
const roving = rovingTabIndex(tabList, '[role="tab"]', {
orientation: 'horizontal',
wrap: true,
onActivate: (el) => {
// Activate the associated tab panel
const panelId = el.getAttribute('aria-controls');
if (panelId) {
document.querySelectorAll('[role="tabpanel"]').forEach((p) => {
(p as HTMLElement).hidden = p.id !== panelId;
});
}
},
});Skip Links
skipLink()
Creates a visually-hidden "Skip to content" link that becomes visible on focus. Follows WCAG 2.4.1 (Bypass Blocks).
function skipLink(targetSelector: string, options?: SkipLinkOptions): SkipLinkHandle;| Parameter | Type | Description |
|---|---|---|
targetSelector | string | CSS selector for the main content target (e.g., '#main') |
options | SkipLinkOptions | Optional configuration |
SkipLinkOptions
interface SkipLinkOptions {
/** Link text. Default: `'Skip to main content'` */
text?: string;
/** CSS class for the skip link element. Default: `'bq-skip-link'` */
className?: string;
}SkipLinkHandle
interface SkipLinkHandle {
/** Remove the skip link from the DOM. */
destroy: () => void;
/** The anchor element, or `null` if destroyed. */
element: HTMLAnchorElement | null;
}Examples
const handle = skipLink('#main-content');
// A hidden <a> is prepended to <body>
// Pressing Tab shows it; clicking navigates to #main-content
handle.destroy();Custom text and class:
const handle = skipLink('#content', {
text: 'Skip navigation',
className: 'my-skip-link',
});Reactive Media Preferences
These composables return reactive signals that track OS/browser media preferences. Each signal updates automatically when the user changes their preference.
prefersReducedMotion()
Tracks the (prefers-reduced-motion: reduce) media query.
function prefersReducedMotion(): MediaPreferenceSignal<boolean>;import { effect } from '@bquery/bquery/reactive';
const reduced = prefersReducedMotion();
effect(() => {
if (reduced.value) {
console.log('User prefers reduced motion');
}
});
// Clean up when no longer needed
reduced.destroy();prefersColorScheme()
Tracks the (prefers-color-scheme) media query.
function prefersColorScheme(): MediaPreferenceSignal<ColorScheme>;type ColorScheme = 'light' | 'dark';const scheme = prefersColorScheme();
effect(() => {
document.documentElement.dataset.theme = scheme.value;
});
scheme.destroy();prefersContrast()
Tracks the (prefers-contrast) media query.
function prefersContrast(): MediaPreferenceSignal<ContrastPreference>;type ContrastPreference = 'no-preference' | 'more' | 'less' | 'custom';const contrast = prefersContrast();
effect(() => {
if (contrast.value === 'more') {
document.documentElement.classList.add('high-contrast');
}
});
contrast.destroy();MediaPreferenceSignal<T>
All media preference functions return this type:
interface MediaPreferenceSignal<T> extends ReadonlySignal<T> {
/** Release the underlying media query listener. */
destroy(): void;
}Accessibility Audit
auditA11y()
Performs a development-time accessibility audit on the current document or a specific container. Checks for common WCAG violations.
function auditA11y(container?: Element): AuditResult;| Parameter | Type | Default | Description |
|---|---|---|---|
container | Element | document.body | The subtree to audit |
AuditResult
interface AuditResult {
/** All findings from the audit. */
findings: AuditFinding[];
/** Number of error-severity findings. */
errors: number;
/** Number of warning-severity findings. */
warnings: number;
/** `true` if no errors were found. */
passed: boolean;
}AuditFinding
interface AuditFinding {
/** Severity: `'error'`, `'warning'`, or `'info'`. */
severity: AuditSeverity;
/** Human-readable description. */
message: string;
/** The element with the issue. */
element: Element;
/** Rule identifier. */
rule: string;
}type AuditSeverity = 'error' | 'warning' | 'info';Audit Rules
The audit checks for:
| Rule | Severity | What it checks |
|---|---|---|
| Missing alt text | error | <img> without alt attribute |
| Missing form labels | error | <input>, <textarea>, <select> without associated <label> or aria-label |
| Empty buttons | warning | <button> with no text content or aria-label |
| Empty links | warning | <a> with no text content or aria-label |
| Heading hierarchy | warning | Skipped heading levels (e.g., <h1> followed by <h3>) |
| Missing ARIA references | warning | aria-describedby, aria-labelledby, etc. pointing to non-existent IDs |
| Missing landmarks | info | No <main>, <nav>, or ARIA landmark roles found |
Examples
Audit the entire page:
const result = auditA11y();
if (!result.passed) {
console.warn(`Found ${result.errors} errors and ${result.warnings} warnings`);
console.table(
result.findings.map((f) => ({
severity: f.severity,
rule: f.rule,
message: f.message,
element: f.element.tagName,
}))
);
}Audit a specific container:
const form = document.querySelector('#login-form')!;
const result = auditA11y(form);
for (const finding of result.findings) {
console.warn(`[${finding.severity}] ${finding.rule}: ${finding.message}`);
}In a test suite:
import { describe, expect, it } from 'bun:test';
import { auditA11y } from '@bquery/bquery/a11y';
describe('accessibility', () => {
it('has no errors', () => {
document.body.innerHTML = `
<main>
<h1>Title</h1>
<img src="photo.jpg" alt="A photo" />
<label for="name">Name</label>
<input id="name" type="text" />
</main>
`;
const result = auditA11y();
expect(result.passed).toBe(true);
expect(result.errors).toBe(0);
});
});Full Example
import {
trapFocus,
announceToScreenReader,
rovingTabIndex,
skipLink,
prefersReducedMotion,
auditA11y,
} from '@bquery/bquery/a11y';
import { effect } from '@bquery/bquery/reactive';
// 1. Add skip link
const skip = skipLink('#main', { text: 'Skip to content' });
// 2. Adapt to motion preference
const reduced = prefersReducedMotion();
effect(() => {
document.documentElement.classList.toggle('reduce-motion', reduced.value);
});
// 3. Keyboard navigation in toolbar
const toolbar = document.querySelector('#toolbar')!;
const roving = rovingTabIndex(toolbar, 'button', {
orientation: 'horizontal',
wrap: true,
});
// 4. Modal with focus trap
function openModal() {
const dialog = document.querySelector('#modal')!;
dialog.hidden = false;
const trap = trapFocus(dialog, {
initialFocus: '#close-btn',
escapeDeactivates: true,
onEscape: () => {
dialog.hidden = true;
trap.release();
announceToScreenReader('Dialog closed');
},
});
announceToScreenReader('Dialog opened', 'assertive');
}
// 5. Run audit in development
if (import.meta.env?.DEV) {
const result = auditA11y();
if (!result.passed) {
console.warn('A11y issues found:', result.findings);
}
}Notes
- The audit API is intended for development-time feedback, not as a replacement for manual accessibility testing.
- Focus traps automatically handle Tab, Shift+Tab, and Escape key events.
- Live region announcements use the correct
aria-liveandroleattributes automatically. - All media preference signals must be cleaned up with
destroy()when no longer needed. - Roving tabindex follows the WAI-ARIA Practices guide for composite widgets.