Skip to content

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.

ts
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.

ts
function trapFocus(container: HTMLElement, options?: TrapFocusOptions): FocusTrapHandle;
ParameterTypeDescription
containerHTMLElementThe element to trap focus within
optionsTrapFocusOptionsOptional configuration

TrapFocusOptions

ts
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

ts
interface FocusTrapHandle {
  /** Release the focus trap and restore focus. */
  release: () => void;
  /** Whether the trap is currently active. */
  active: boolean;
}

Examples

Basic modal trap:

ts
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); // false

With return focus and escape handler:

ts
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.

ts
function releaseFocus(handle: FocusTrapHandle): void;
ts
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.

ts
function getFocusableElements(container: Element): HTMLElement[];
ts
const dialog = document.querySelector('#dialog')!;
const focusable = getFocusableElements(dialog);
console.log(focusable.length); // Number of focusable elements inside

Live-Region Announcements

announceToScreenReader()

Announces a message to screen readers via an ARIA live region. The module manages the live region elements automatically.

ts
function announceToScreenReader(message: string, priority?: AnnouncePriority): void;
ParameterTypeDefaultDescription
messagestringThe text to announce
priorityAnnouncePriority'polite'Urgency level: 'polite' waits for idle, 'assertive' interrupts
ts
type AnnouncePriority = 'polite' | 'assertive';

Examples

ts
// 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.

ts
function clearAnnouncements(): void;
ts
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".

ts
function rovingTabIndex(
  container: HTMLElement,
  itemSelector: string,
  options?: RovingTabIndexOptions
): RovingTabIndexHandle;
ParameterTypeDescription
containerHTMLElementThe composite widget container
itemSelectorstringCSS selector for navigable items
optionsRovingTabIndexOptionsOptional configuration

RovingTabIndexOptions

ts
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 item
  • End — jump to last item

RovingTabIndexHandle

ts
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:

ts
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:

ts
const menu = document.querySelector('[role="menu"]')!;
const roving = rovingTabIndex(menu, '[role="menuitem"]', {
  orientation: 'vertical',
  wrap: false,
});

roving.destroy();

Tab list:

ts
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;
      });
    }
  },
});

Creates a visually-hidden "Skip to content" link that becomes visible on focus. Follows WCAG 2.4.1 (Bypass Blocks).

ts
function skipLink(targetSelector: string, options?: SkipLinkOptions): SkipLinkHandle;
ParameterTypeDescription
targetSelectorstringCSS selector for the main content target (e.g., '#main')
optionsSkipLinkOptionsOptional configuration

SkipLinkOptions

ts
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

ts
interface SkipLinkHandle {
  /** Remove the skip link from the DOM. */
  destroy: () => void;
  /** The anchor element, or `null` if destroyed. */
  element: HTMLAnchorElement | null;
}

Examples

ts
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:

ts
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.

ts
function prefersReducedMotion(): MediaPreferenceSignal<boolean>;
ts
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.

ts
function prefersColorScheme(): MediaPreferenceSignal<ColorScheme>;
ts
type ColorScheme = 'light' | 'dark';
ts
const scheme = prefersColorScheme();

effect(() => {
  document.documentElement.dataset.theme = scheme.value;
});

scheme.destroy();

prefersContrast()

Tracks the (prefers-contrast) media query.

ts
function prefersContrast(): MediaPreferenceSignal<ContrastPreference>;
ts
type ContrastPreference = 'no-preference' | 'more' | 'less' | 'custom';
ts
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:

ts
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.

ts
function auditA11y(container?: Element): AuditResult;
ParameterTypeDefaultDescription
containerElementdocument.bodyThe subtree to audit

AuditResult

ts
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

ts
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;
}
ts
type AuditSeverity = 'error' | 'warning' | 'info';

Audit Rules

The audit checks for:

RuleSeverityWhat it checks
Missing alt texterror<img> without alt attribute
Missing form labelserror<input>, <textarea>, <select> without associated <label> or aria-label
Empty buttonswarning<button> with no text content or aria-label
Empty linkswarning<a> with no text content or aria-label
Heading hierarchywarningSkipped heading levels (e.g., <h1> followed by <h3>)
Missing ARIA referenceswarningaria-describedby, aria-labelledby, etc. pointing to non-existent IDs
Missing landmarksinfoNo <main>, <nav>, or ARIA landmark roles found

Examples

Audit the entire page:

ts
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:

ts
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:

ts
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

ts
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-live and role attributes 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.