Keyboard
Keyboard in the Accessibility guide.
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.