// Action menu: a trigger plus a `role="menu"` list with roving tabindex. // Distinct from Select — a menu runs commands, it does not hold a form value. import type { JSX } from 'solid-js'; import { focusTrap, rovingTabindex, useId } from '../a11y'; import { cx } from '../cx'; import { MENU_ITEM_SELECTOR, tabStopIndex } from '../roving'; import { useUi } from '../theme/context'; import { solid } from '../theme/solid-adapter'; import styles from './Menu.module.scss'; export interface MenuItem { id: string; /** Already-translated item label. */ label: string; onSelect: () => void; disabled?: boolean | undefined; /** Renders with the danger role; use for irreversible actions. */ destructive?: boolean | undefined; icon?: JSX.Element | undefined; } export interface MenuProps { items: readonly MenuItem[]; open: boolean; onOpenChange: (open: boolean) => void; /** * The menu button. `aria-controls` is present only while the list is mounted: an IDREF to an * element not in the document names nothing. */ trigger: (control: { id: string; 'aria-haspopup': 'menu'; 'aria-expanded': boolean; 'aria-controls': string | undefined; }) => JSX.Element; /** Already-translated accessible name for the menu. */ label: string; align?: 'start' | 'end' | undefined; class?: string | undefined; } export function Menu(props: MenuProps): JSX.Element { const ui = useUi(); const rt = solid(); const menuId = useId('menu'); let root: HTMLDivElement | undefined; let list: HTMLDivElement | undefined; // Disabled items are excluded from BOTH answers — the list arrows walk and the one item that // carries the tab stop — because `focus()` on a disabled button silently does nothing. const onKeyDown = rovingTabindex( () => list === undefined ? [] : Array.from(list.querySelectorAll(MENU_ITEM_SELECTOR)), { orientation: 'vertical', dir: ui.dir }, ); const tabStop = (): number => tabStopIndex(props.items); rt.createEffect(() => { if (!props.open || typeof document === 'undefined') return; const onPointerDown = (event: Event): void => { if (root !== undefined && !root.contains(event.target as Node)) props.onOpenChange(false); }; const onEscape = (event: KeyboardEvent): void => { if (event.key === 'Escape') props.onOpenChange(false); }; document.addEventListener('pointerdown', onPointerDown, true); document.addEventListener('keydown', onEscape); // Closing unmounts the panel with focus inside it, which resets focus to and makes the // next Tab restart at the top of the document. The trap moves focus in on open and hands it // back to the trigger on close, so Escape returns the user where they were. const trap = list === undefined ? undefined : focusTrap(list); trap?.activate(); rt.onCleanup(() => { document.removeEventListener('pointerdown', onPointerDown, true); document.removeEventListener('keydown', onEscape); trap?.release(); }); }); return (
{ root = el; }} > {props.trigger({ id: `${menuId}-trigger`, 'aria-haspopup': 'menu', 'aria-expanded': props.open, 'aria-controls': props.open ? menuId : undefined, })} {props.open ? (
{ list = el; }} id={menuId} role="menu" aria-label={props.label} class={cx(styles['menu'], styles[`align-${props.align ?? 'start'}`])} onKeyDown={onKeyDown} > {props.items.map((item, index) => ( ))}
) : null}
); }