Menu
Anchored menu on Popover. Caller flips `open`; each MenuItem's onClick fires. Arrow keys navigate; Escape / outside-click close.
Preview
Install
npx @joinclass/axiom-ui add menu Copies menu.tsx into ./src/components/. You own the code after that.
Props
| Prop | Description |
|---|---|
| Menu.open | Required boolean. |
| Menu.onOpenChange | (open: boolean) => void — Popover close callback. |
| Menu.anchor | Optional RefObject<HTMLElement> — positions menu below. |
| Menu.* | ComponentPropsWithRef<'div'> pass-through, no `ref` (Menu owns for autofocus). |
| MenuItem.* | Native <button> pass-through; type defaults to 'button'. |
| className | Escape hatch. `!` prefix to override defaults. |
Examples
Dropdown
const t = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
<>
<Button ref={t} onClick={() => setOpen(o => !o)}>Actions</Button>
<Menu open={open} onOpenChange={setOpen} anchor={t}>
<MenuItem onClick={() => { rename(); setOpen(false); }}>Rename</MenuItem>
<MenuItem disabled>Archive</MenuItem>
</Menu>
</> Accessibility
- role="menu" / role="menuitem" wired automatically.
- On open, focus moves to first enabled item. ArrowUp/Down wrap; Home/End jump; Enter/Space activate.
- Escape / outside-click close (Popover auto mode). No submenu (nested) in v0.
States
closedopen
Source
The full source of menu.tsx — exactly what the CLI copies into your repo.
/**
* Anchored menu built on top of Popover. Two primitives: Menu (the
* role="menu" container) and MenuItem (a role="menuitem" button).
* Selection state lives in the caller — flip `open` on click, react
* to each MenuItem's onClick. Arrow keys / Home / End move focus
* inside the menu; Escape closes it (Popover handles both).
*/
import { useEffect, useRef, type ComponentPropsWithRef, type KeyboardEvent, type RefObject } from "react";
import { Popover } from "./popover";
export type MenuProps = Omit<ComponentPropsWithRef<"div">, "ref"> & {
open: boolean;
onOpenChange?: (open: boolean) => void;
anchor?: RefObject<HTMLElement | null>;
};
export type MenuItemProps = ComponentPropsWithRef<"button">;
export function Menu({ open, onOpenChange, anchor, className, onKeyDown, ...props }: MenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const first = ref.current?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])');
first?.focus();
}, [open]);
return (
<Popover
ref={ref}
open={open}
onOpenChange={onOpenChange}
anchor={anchor}
role="menu"
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
const { key } = event;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Home" && key !== "End") return;
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])'),
);
if (items.length === 0) return;
event.preventDefault();
const active = document.activeElement as HTMLElement | null;
const index = active ? items.indexOf(active) : -1;
let next: HTMLElement | undefined;
if (key === "Home") next = items[0];
else if (key === "End") next = items[items.length - 1];
else if (key === "ArrowDown") next = items[(index + 1) % items.length];
else next = items[(index - 1 + items.length) % items.length];
next?.focus();
}}
className={"min-w-40 flex-col gap-0.5 p-1 [&:popover-open]:flex" + (className ? " " + className : "")}
{...props}
/>
);
}
export function MenuItem({ className, type = "button", ...props }: MenuItemProps) {
return (
<button
type={type}
role="menuitem"
tabIndex={-1}
className={
"block w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 disabled:pointer-events-none disabled:opacity-50" +
(className ? " " + className : "")
}
{...props}
/>
);
}
export const manifest = {
intent:
"Anchored menu on Popover. Caller flips `open`; each MenuItem's onClick fires. Arrow keys navigate; Escape / outside-click close.",
props: {
"Menu.open": "Required boolean.",
"Menu.onOpenChange": "(open: boolean) => void — Popover close callback.",
"Menu.anchor": "Optional RefObject<HTMLElement> — positions menu below.",
"Menu.*": "ComponentPropsWithRef<'div'> pass-through, no `ref` (Menu owns for autofocus).",
"MenuItem.*": "Native <button> pass-through; type defaults to 'button'.",
className: "Escape hatch. `!` prefix to override defaults.",
},
states: ["closed", "open"],
a11y: [
'role="menu" / role="menuitem" wired automatically.',
"On open, focus moves to first enabled item. ArrowUp/Down wrap; Home/End jump; Enter/Space activate.",
"Escape / outside-click close (Popover auto mode). No submenu (nested) in v0.",
],
examples: [
{
title: "Dropdown",
code: 'const t = useRef<HTMLButtonElement>(null);\nconst [open, setOpen] = useState(false);\n<>\n <Button ref={t} onClick={() => setOpen(o => !o)}>Actions</Button>\n <Menu open={open} onOpenChange={setOpen} anchor={t}>\n <MenuItem onClick={() => { rename(); setOpen(false); }}>Rename</MenuItem>\n <MenuItem disabled>Archive</MenuItem>\n </Menu>\n</>',
},
],
} as const;