[]previous and next page

Keyboard

Keyboard in the Accessibility guide.

For agentsUpdated

Handlers are ordinary DOM events. Read event.key (not keyCode), call preventDefault() for keys you handle, and handle all of the keys the pattern in the ARIA Authoring Practices defines. A roving tabindex puts exactly one item of a composite widget in the tab order and moves it with the arrow keys:

src/toolbar.tsxtsx
import { For } from "@shadow-js/web";
import { useStore } from "@shadow-js/core";

export function Toolbar(props: { actions: readonly string[] }) {
  const [active, setActive] = useStore(0);
  const buttons: HTMLButtonElement[] = [];
  const move = (to: number): void => {
    const next = (to + props.actions.length) % props.actions.length;
    setActive(next);
    buttons[next]?.focus();
  };
  return (
    <div
      role="toolbar"
      aria-label="Formatting"
      onKeyDown={(event) => {
        if (event.key === "ArrowRight") {
          event.preventDefault();
          move(active() + 1);
        } else if (event.key === "ArrowLeft") {
          event.preventDefault();
          move(active() - 1);
        } else if (event.key === "Home") {
          event.preventDefault();
          move(0);
        } else if (event.key === "End") {
          event.preventDefault();
          move(props.actions.length - 1);
        }
      }}
    >
      <For each={props.actions}>
        {(action, index) => (
          <button
            type="button"
            tabIndex={index() === active() ? 0 : -1}
            ref={(el: HTMLButtonElement) => {
              buttons[index()] = el;
            }}
            onFocus={() => setActive(index())}
          >
            {action}
          </button>
        )}
      </For>
    </div>
  );
}

onKeyDown on the container works because keydown bubbles and ShadowJS delegates it; the handler sees the event from any button inside. A userEvent.tab() moves focus by the tabindex order, so you can test that a composite widget has one tab stop.