Axiom UI
EN / JA

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;