Accessibility

Built-in focus management, ARIA helpers, and screen reader support.

Focus Management

What provides four tools for controlling focus: useFocus() for tracking/moving focus, useFocusRestore() for parent-controlled restore, useFocusTrap() for container-level trapping, and FocusTrap for declarative dialog boundaries.

useFocus()

Track the currently focused element and move focus programmatically:

import { useFocus } from 'what-framework';

const focus = useFocus();

// Read the currently focused element (reactive)
focus.current();  // HTMLElement or null

// Programmatically focus an element (pass a real DOM node)
focus.focus(document.querySelector('#search'));

// Blur the active element
focus.blur();

Inside a component, capture the node with a callback ref and focus it from onMount, once the element exists:

import { useFocus, onMount } from 'what-framework';

function SearchBox() {
  let input;
  const focus = useFocus();

  onMount(() => focus.focus(input));

  return <input ref={(node) => { input = node; }} />;
}

useFocusRestore()

Capture trigger focus before opening overlays, then restore it after close:

import { signal, useFocusRestore } from 'what-framework';

const isOpen = signal(false);
const focusRestore = useFocusRestore();

function openDialog(e) {
  focusRestore.capture(e.currentTarget);
  isOpen.set(true);
}

function closeDialog() {
  isOpen.set(false);
  focusRestore.restore();
}

useFocusTrap(containerRef)

Trap focus within a container element. When active, pressing Tab at the last focusable element wraps to the first, and Shift+Tab at the first wraps to the last. Essential for modals, dialogs, and dropdown menus.

import { useFocusTrap, onMount, onCleanup } from 'what-framework';

function Modal({ onClose, onConfirm }) {
  const ref = { current: null };
  const trap = useFocusTrap(ref);
  let release;

  // Activate the trap (focuses first focusable element)
  onMount(() => { release = trap.activate(); });

  // Tear it down here, because onMount ignores anything you return from it
  onCleanup(() => {
    release?.();
    trap.deactivate();
  });

  return (
    <div ref={ref} role="dialog" aria-modal="true">
      <h2>Confirm Action</h2>
      <p>Are you sure you want to proceed?</p>
      <button onClick={onClose}>Cancel</button>
      <button onClick={onConfirm}>Confirm</button>
    </div>
  );
}

onMount does not accept a cleanup return

Unlike React's useEffect, onMount discards a function you return from it. Returning the trap's teardown leaves its keydown listener attached to a removed container and never restores focus to the trigger. Always register teardown with onCleanup.

FocusTrap Component

Wrap the dialog subtree and conditionally mount it while open:

import { signal, FocusTrap } from 'what-framework';

function AccountMenu({ onLogout }) {
  const isOpen = signal(false);

  return (
    <div>
      <button onClick={() => isOpen.set(o => !o)}>Account</button>
      {() => isOpen() ? (
        <FocusTrap>
          <div class="dropdown-menu" role="dialog" aria-modal={true}>
            <a href="/profile">Profile</a>
            <a href="/settings">Settings</a>
            <button onClick={onLogout}>Log out</button>
          </div>
        </FocusTrap>
      ) : null}
    </div>
  );
}

Focus Restoration

For predictable UX, capture the trigger with useFocusRestore().capture(...) in parent logic and call restore() when closing the overlay.

Screen Reader Announcements

Announce dynamic content changes to screen readers using an ARIA live region managed by the framework.

import { announce, announceAssertive } from 'what-framework';

// Polite announcement (waits for screen reader to finish current speech)
announce('3 new items loaded', {
  priority: 'polite',    // 'polite' or 'assertive' (default: 'polite')
  timeout: 1000,         // auto-clear after ms (default: 1000)
});

// Assertive shortcut, interrupts current speech for urgent alerts
announceAssertive('Form submission failed. Please check your input.');

LiveRegion Component

For content that updates continuously, wrap it in a LiveRegion:

import { LiveRegion } from 'what-framework';

<LiveRegion priority="polite" atomic={true}>
  {() => `${itemCount()} items in cart`}
</LiveRegion>

ARIA Helpers

Hooks that hold ARIA state in signals, so the state a screen reader is told matches the state your UI is in. Each one bundles its attributes and handlers into a props object you spread onto the element.

The values in those objects are accessors rather than snapshots, which is what keeps a spread live. {...hook.buttonProps()} is evaluated once, but every function-valued prop is wrapped in its own effect by the renderer, so the attribute follows the signal for the life of the element. Enumerated ARIA attributes are emitted as the strings "true" / "false" on all three paths (h(), compiled JSX, and server rendering), never as aria-expanded="" or a missing attribute.

On 0.13.4 these props are snapshots, not accessors

The accessor behaviour above lands in the next release. On 0.13.4, the current version on npm, each helper reads its own signal ('aria-expanded': expanded()), so the single evaluation a spread performs bakes in the state at mount: an accordion built from buttonProps() announces aria-expanded="false" forever, no matter how many times it opens, and panelProps().hidden is stuck with it. Components run once in What, so nothing ever re-calls the helper to refresh it. Until then, bind the attribute yourself the way useAriaExpanded below describes: aria-expanded={() => disclosure.expanded()} is reactive, and correctly serialized, on both versions.

useAriaExpanded

Manage expandable UI like accordions, menus, and collapsible panels. The hook owns the open/closed signal and hands you the button and panel props:

import { useAriaExpanded } from 'what-framework';

function Accordion({ title, children }) {
  const disclosure = useAriaExpanded(false);

  return (
    <div>
      <button {...disclosure.buttonProps()}>{title}</button>
      <div {...disclosure.panelProps()}>{children}</div>
    </div>
  );
}

buttonProps() carries aria-expanded plus an onClick that toggles. panelProps() carries hidden, which stays a real boolean: hidden is a genuine HTML boolean attribute, where present-or-absent is the correct serialization.

The hook exposes the state directly:

disclosure.expanded();  // reactive boolean
disclosure.toggle();    // flip open/closed
disclosure.open();      // force open
disclosure.close();     // force closed

Bind an attribute yourself when you want something the props object does not do, for example a button that opens but never closes: aria-expanded={() => disclosure.expanded()}. Both forms are reactive, so mix them freely.

useAriaSelected

Track selection state for tab lists, listboxes, and similar patterns. itemProps(value) carries that item's aria-selected and an onClick that selects it, and it writes no role, so the one you set stays yours:

import { useAriaSelected } from 'what-framework';

const tabs = useAriaSelected('home');

<div role="tablist">
  <button role="tab" {...tabs.itemProps('home')}>Home</button>
  <button role="tab" {...tabs.itemProps('about')}>About</button>
</div>

// Reactive state
tabs.selected();             // 'home'
tabs.isSelected('about');    // false
tabs.select('about');        // switch selection

useAriaChecked

Build accessible custom checkboxes with proper keyboard support (Space and Enter toggle). checkboxProps() carries the role, the tab stop, aria-checked, and both handlers:

import { useAriaChecked } from 'what-framework';

const checkbox = useAriaChecked(false);

<div {...checkbox.checkboxProps()}>
  {() => checkbox.checked() ? '[x]' : '[ ]'} Accept terms
</div>

// checkbox.checked() is a reactive boolean; checkbox.set(v) writes it directly.
// checkboxProps() = { role: 'checkbox', tabIndex: 0, aria-checked, onClick, onKeyDown }

This is the one helper that writes a role, and it is always "checkbox". Whether writing role beside the spread can override it depends on how the file is built, which is the reason not to try. Compiled, it cannot: the compiler folds static attributes into the element's template and applies the spread over the top, so <div role="switch" {...checkbox.checkboxProps()}> and <div {...checkbox.checkboxProps()} role="switch"> emit identical code and the helper wins both. Uncompiled, h() and the server renderer receive an ordinary object literal, so the last key wins and h('div', { ...checkbox.checkboxProps(), role: 'switch' }) really does render role="switch". A widget whose role changes when you turn the compiler on is not a widget worth shipping. For a switch, a menu item checkbox, or anything else that is checkable but not a checkbox, bind the pieces yourself:

import { useAriaChecked, Keys } from 'what-framework';

const sw = useAriaChecked(false);

<div
  role="switch"
  tabIndex={0}
  aria-checked={() => sw.checked()}
  onClick={sw.toggle}
  onKeyDown={(e) => {
    if (e.key === Keys.Space || e.key === Keys.Enter) {
      e.preventDefault();
      sw.toggle();
    }
  }}
>
  Notifications
</div>

Keyboard Navigation

The useRovingTabIndex hook implements the roving tabindex pattern for a composite widget like a toolbar, menu, tablist, or listbox: exactly one item is tabbable at a time, and the arrow keys move both that tab stop and real DOM focus. Spread getItemProps(i) onto each item and you are done.

import { useRovingTabIndex } from 'what-framework';

function Toolbar() {
  const items = ['Bold', 'Italic', 'Underline', 'Link'];
  const roving = useRovingTabIndex(items.length);

  return (
    <div role="toolbar" aria-label="Formatting" {...roving.containerProps()}>
      {items.map((label, i) => (
        <button key={label} {...roving.getItemProps(i)}>{label}</button>
      ))}
    </div>
  );
}

getItemProps(i) returns the item's reactive tabIndex, an onKeyDown, an onFocus, and a ref. The ref is how the hook holds the node it has to focus, so do not spread a ref of your own after it. Pass one through instead and the hook chains both: getItemProps(i, { ref }).

The key handler moves the active item, and focus with it:

  • ArrowDown / ArrowRight: move to next item (wraps around)
  • ArrowUp / ArrowLeft: move to previous item (wraps around)
  • Home: jump to first item
  • End: jump to last item

containerProps() writes no role of its own, because roving tabindex is the shared keyboard mechanic of toolbars, menus, trees, grids, tablists, radiogroups, and listboxes. Label the container yourself, as above, or ask the hook for a role explicitly:

// Per hook
const roving = useRovingTabIndex(items.length, { role: 'menu' });

// Or per call site, which wins over the hook option
<div {...roving.containerProps({ role: 'menu', 'aria-label': 'Actions' })}>

Move focus yourself with focusItem(index), which is what a menu wants when it opens. It returns the element it focused, or null when the index is out of range or the item is not in the DOM. setFocusIndex(index) moves the tab stop and follows it with focus only when focus is already inside the group, so syncing the index from application state cannot yank focus off an unrelated widget. Both refuse an index that does not exist rather than leaving the widget with no tabbable item.

You can also pass a signal or getter for dynamic item counts. If the list shrinks past the active index, the tab stop clamps to the last item so the widget never drops out of the tab order:

const itemCount = signal(5);
const roving = useRovingTabIndex(() => itemCount());

0.13.4 ships a different hook

All of the above is the next release. On 0.13.4, the current version on npm, useRovingTabIndex takes no options object, and getItemProps() returns no ref and a plain number for tabIndex, so nothing holds the item nodes: run the Toolbar example above on it and ArrowRight moves the hook's internal index while the tab stop and real DOM focus both stay on the first button. containerProps() also hard-codes role: "listbox", which overwrites the role="toolbar" written beside it. focusItem() does not exist. The published TypeScript declarations describe that older hook, so an editor will reject the options object, focusItem, and the overrides arguments until the next release lands.

Help keyboard users bypass navigation and provide screen-reader-only content.

Renders a link that is hidden until focused. When activated, it moves focus to the target element. A childless <SkipLink /> falls back to the label Skip to content, and it does so identically on the client and in server-rendered HTML, so the link ships with its accessible name rather than gaining it at hydration.

import { SkipLink } from 'what-framework';

// Default target is #main
<SkipLink>Skip to content</SkipLink>

// Custom target and label
<SkipLink href="#content">Skip to main content</SkipLink>

Give the target a tabindex="-1" as well. SkipLink calls focus() on whatever the href matches, and a plain <main id="main"> is not focusable, so without it the page scrolls but keyboard focus stays on the link:

<main id="main" tabindex="-1">...</main>

VisuallyHidden

Hides content visually while keeping it accessible to screen readers. Uses the standard clip/overflow technique:

import { VisuallyHidden } from 'what-framework';

<button>
  <IconTrash />
  <VisuallyHidden>Delete item</VisuallyHidden>
</button>

// Renders as a span by default, use `as` for other elements
<VisuallyHidden as="div">Loading complete</VisuallyHidden>

ID Generation

Generate unique, stable IDs for connecting ARIA attributes across elements. One counter is shared by every prefix and by both helpers, so the number in an id reflects its position in the render's allocation order, not a per-prefix sequence. On the server the counter is scoped to the render and hydrate() restarts the sequence, so the ids the client produces reproduce the server's and for / aria-labelledby links survive hydration.

import { useId, useIds, useDescribedBy, useLabelledBy } from 'what-framework';

// Single unique ID, the first allocation in this render
const id = useId('dialog');  // () => 'dialog-1'

// Multiple IDs at once, continuing the same shared counter
const [titleId, bodyId] = useIds(2, 'section');
// ['section-2', 'section-3']

useDescribedBy

Connect a description to an element via aria-describedby:

const desc = useDescribedBy('Password must be 8+ characters');

<input type="password" {...desc.describedByProps()} />
<desc.Description />
// Renders a hidden div with the description text, linked by ID

useLabelledBy

Connect a visible label to an element via aria-labelledby:

const label = useLabelledBy('Email Address');

<h3 {...label.labelProps()}>Email Address</h3>
<input type="email" {...label.labelledByProps()} />

Keyboard Helpers

Utility functions and constants for keyboard event handling.

import { signal, Keys, onKey, onKeys } from 'what-framework';

const isOpen = signal(true);

// Keys constant, avoids string typos
Keys.Enter      // 'Enter'
Keys.Space      // ' '
Keys.Escape     // 'Escape'
Keys.ArrowUp    // 'ArrowUp'
Keys.ArrowDown  // 'ArrowDown'
Keys.Tab        // 'Tab'
Keys.Home       // 'Home'
Keys.End        // 'End'

// Handle a single key
<input onKeyDown={onKey(Keys.Escape, () => isOpen.set(false))} />

// Handle multiple keys
<div onKeyDown={onKeys(
  [Keys.Enter, Keys.Space],
  (e) => { e.preventDefault(); isOpen.set(o => !o); }
)} />

Live Demo

An accessible accordion built with aria-expanded attributes. Try using your keyboard, each button correctly announces its expanded state to screen readers.

Live Demo: Accessible Accordion