Components

Menu

Commands and options, opened from a control or at what they are about, and closed once a choice is made.

Markdown

A menu presents a list of commands or options, opened from a control — or, as a context menu, at the thing it is about — and closed once a choice is made. It keeps commands out of the way until they are wanted.

Commands and options

Press File and drag to an item, or click and choose. Each command shows its shortcut as this platform writes it. Share opens a submenu; View holds options that stay on or off, and a zoom chosen from three.

1 page · 100% · last command: —

Code
"use client";

import {
  Menu,
  MenuCheckboxItem,
  MenuContent,
  MenuItem,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuSub,
  MenuSubContent,
  MenuSubTrigger,
  MenuTrigger,
} from "@publictheta/ui/components/menu/menu.tsx";
import { useState } from "react";

export default function DocumentMenus() {
  const [ruler, setRuler] = useState(true);
  const [grid, setGrid] = useState(false);
  const [zoom, setZoom] = useState("100");
  const [pages, setPages] = useState(1);
  const [last, setLast] = useState("—");

  const run = (command: string, effect?: () => void) => () => {
    effect?.();
    setLast(command);
  };

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div className="flex gap-1">
        <Menu>
          <MenuTrigger>File</MenuTrigger>
          <MenuContent>
            <MenuItem shortcut="Mod+N" onSelect={run("New Page", () => setPages((n) => n + 1))}>
              New Page
            </MenuItem>
            <MenuItem shortcut="Mod+D" onSelect={run("Duplicate", () => setPages((n) => n + 1))}>
              Duplicate
            </MenuItem>
            <MenuItem
              disabled={pages === 1}
              shortcut="Mod+Backspace"
              onSelect={run("Delete Page", () => setPages((n) => Math.max(1, n - 1)))}
            >
              Delete Page
            </MenuItem>
            <MenuSeparator />
            <MenuSub>
              <MenuSubTrigger>Share</MenuSubTrigger>
              <MenuSubContent>
                <MenuItem onSelect={run("Share via Mail")}>Mail</MenuItem>
                <MenuItem onSelect={run("Share via Messages")}>Messages</MenuItem>
                <MenuItem onSelect={run("Copy Link")}>Copy Link</MenuItem>
              </MenuSubContent>
            </MenuSub>
            <MenuItem shortcut="Mod+Shift+E" onSelect={run("Export…")}>
              Export…
            </MenuItem>
          </MenuContent>
        </Menu>
        <Menu>
          <MenuTrigger>View</MenuTrigger>
          <MenuContent>
            <MenuCheckboxItem
              checked={ruler}
              shortcut="Mod+R"
              onCheckedChange={setRuler}
              onSelect={run(ruler ? "Hide Ruler" : "Show Ruler")}
            >
              Ruler
            </MenuCheckboxItem>
            <MenuCheckboxItem
              checked={grid}
              onCheckedChange={setGrid}
              onSelect={run(grid ? "Hide Grid" : "Show Grid")}
            >
              Grid
            </MenuCheckboxItem>
            <MenuSeparator />
            <MenuRadioGroup label="Zoom" value={zoom} onValueChange={setZoom}>
              <MenuRadioItem value="75" onSelect={run("Zoom 75%")}>
                75%
              </MenuRadioItem>
              <MenuRadioItem value="100" onSelect={run("Zoom 100%")}>
                100%
              </MenuRadioItem>
              <MenuRadioItem value="150" onSelect={run("Zoom 150%")}>
                150%
              </MenuRadioItem>
            </MenuRadioGroup>
          </MenuContent>
        </Menu>
      </div>

      <div className="relative h-40 overflow-hidden rounded-lg border border-border-muted bg-surface">
        {ruler && <div aria-hidden className="h-4 border-b border-border-muted bg-surface-muted" />}
        <div
          aria-hidden
          className="absolute inset-x-0 bottom-0 flex items-center justify-center gap-3"
          style={{
            top: ruler ? 16 : 0,
            backgroundImage: grid
              ? "linear-gradient(var(--ui-border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--ui-border-muted) 1px, transparent 1px)"
              : undefined,
            backgroundSize: "16px 16px",
          }}
        >
          {Array.from({ length: pages }, (_, index) => (
            <div
              key={index}
              className="rounded-sm bg-surface-raised shadow ring-1 ring-border-muted"
              style={{ width: (36 * Number(zoom)) / 100, height: (48 * Number(zoom)) / 100 }}
            />
          ))}
        </div>
      </div>
      <p className="text-sm text-fg-muted" aria-live="polite">
        {pages} {pages === 1 ? "page" : "pages"} · {zoom}% · last command: {last}
      </p>
    </div>
  );
}

A context menu over a list

Secondary-click a file, or touch and hold it: it is selected first, and the menu is about the selection. With a file focused, the Menu key or Shift+F10 opens the menu against it. On a phone, Move to takes the sheet's place, with a way back.

Brief.pdf
Budget 2027.numbers
Cover.png
Interview notes.md
Proposal.docx

Selected: Cover.png · last command: —

Code
"use client";

import { Listbox, ListboxItem } from "@publictheta/ui/components/listbox/listbox.tsx";
import {
  ContextMenu,
  ContextMenuTrigger,
  MenuContent,
  MenuItem,
  MenuSeparator,
  MenuSub,
  MenuSubContent,
  MenuSubTrigger,
} from "@publictheta/ui/components/menu/menu.tsx";
import { useState } from "react";

const FILES = ["Brief.pdf", "Budget 2027.numbers", "Cover.png", "Interview notes.md", "Proposal.docx"];
const FOLDERS = ["Archive", "Clients", "Drafts"];

export default function FileContextMenu() {
  const [files, setFiles] = useState(FILES);
  const [selected, setSelected] = useState<string[]>(["Cover.png"]);
  const [last, setLast] = useState("—");

  // The menu is about the selection, which the press that asked for it has already made.
  const subject = selected.length === 1 ? selected[0]! : `${selected.length} items`;
  const run = (command: string, effect?: () => void) => () => {
    effect?.();
    setLast(`${command} ${subject}`);
  };
  const remove = () => {
    setFiles((current) => current.filter((file) => !selected.includes(file)));
    setSelected([]);
  };

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ContextMenu>
        <ContextMenuTrigger>
          <Listbox
            aria-label="Files"
            selectionMode="multiple"
            value={selected}
            onValueChange={setSelected}
            onAction={(file) => setLast(`Open ${file}`)}
            className="min-h-52"
          >
            {files.map((file) => (
              <ListboxItem key={file} value={file}>
                {file}
              </ListboxItem>
            ))}
          </Listbox>
        </ContextMenuTrigger>
        <MenuContent aria-label="File actions">
          <MenuItem disabled={selected.length !== 1} onSelect={run("Open")} shortcut="Enter">
            Open
          </MenuItem>
          <MenuItem disabled={selected.length === 0} onSelect={run("Duplicate")} shortcut="Mod+D">
            Duplicate
          </MenuItem>
          <MenuSub>
            <MenuSubTrigger disabled={selected.length === 0}>Move to</MenuSubTrigger>
            <MenuSubContent>
              {FOLDERS.map((folder) => (
                <MenuItem key={folder} onSelect={run(`Move to ${folder}:`, remove)}>
                  {folder}
                </MenuItem>
              ))}
            </MenuSubContent>
          </MenuSub>
          <MenuSeparator />
          <MenuItem
            disabled={selected.length === 0}
            onSelect={run("Delete", remove)}
            shortcut="Mod+Backspace"
          >
            Delete
          </MenuItem>
        </MenuContent>
      </ContextMenu>
      <p className="text-sm text-fg-muted" aria-live="polite">
        {selected.length === 0 ? "Nothing selected" : `Selected: ${subject}`} · last command: {last}
      </p>
    </div>
  );
}

A long menu

More cities than fit: the menu stays within the view and scrolls, the arrow keys keep the highlighted city in sight, and Clock, at the end, still opens beside its item. The last item is a link.

Tokyo · 24-hour clock

Code
"use client";

import {
  Menu,
  MenuContent,
  MenuGroup,
  MenuItem,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuSub,
  MenuSubContent,
  MenuSubTrigger,
  MenuTrigger,
} from "@publictheta/ui/components/menu/menu.tsx";
import { useState } from "react";

const REGIONS: ReadonlyArray<readonly [region: string, cities: readonly string[]]> = [
  ["Africa", ["Accra", "Cairo", "Johannesburg", "Lagos", "Nairobi"]],
  ["Americas", ["Bogotá", "Chicago", "Los Angeles", "Mexico City", "New York", "São Paulo", "Toronto"]],
  ["Asia", ["Bangkok", "Dubai", "Hong Kong", "Jakarta", "Kolkata", "Seoul", "Singapore", "Tokyo"]],
  ["Europe", ["Amsterdam", "Berlin", "Istanbul", "Lisbon", "London", "Madrid", "Paris", "Stockholm"]],
  ["Pacific", ["Auckland", "Honolulu", "Sydney"]],
];

export default function LongMenu() {
  const [city, setCity] = useState("Tokyo");
  const [format, setFormat] = useState("24");

  return (
    <div className="flex flex-col items-center gap-3">
      <Menu>
        <MenuTrigger>Time zone</MenuTrigger>
        <MenuContent>
          <MenuRadioGroup value={city} onValueChange={setCity} aria-label="City">
            {REGIONS.map(([region, cities]) => (
              <MenuGroup key={region} label={region}>
                {cities.map((name) => (
                  <MenuRadioItem key={name} value={name}>
                    {name}
                  </MenuRadioItem>
                ))}
              </MenuGroup>
            ))}
          </MenuRadioGroup>
          <MenuSeparator />
          <MenuSub>
            <MenuSubTrigger>Clock</MenuSubTrigger>
            <MenuSubContent>
              <MenuRadioGroup value={format} onValueChange={setFormat} aria-label="Clock">
                <MenuRadioItem value="12">12-hour</MenuRadioItem>
                <MenuRadioItem value="24">24-hour</MenuRadioItem>
              </MenuRadioGroup>
            </MenuSubContent>
          </MenuSub>
          <MenuItem href="/foundations/overlay">How overlays fit the view</MenuItem>
        </MenuContent>
      </Menu>
      <p className="text-sm text-fg-muted" aria-live="polite">
        {city} · {format}-hour clock
      </p>
    </div>
  );
}

When to use

Use a menu for commands that act on the current context, and for options people change occasionally. Commands used constantly deserve their own controls. When people choose among visible options as part of a task, a listbox keeps the choice in view.

Use a context menu for the commands that apply to what is under the pointer: an item of a list, a selection, a region. It is a shortcut and never the only way: everything in it is also reachable from a control that can be seen, since nothing on the screen says a context menu is there.

Best practices

Name items for what they do. Begin with a verb for a command ("Duplicate", "Move to Trash") and with the noun for an option ("Ruler"). Keep labels short, and begin them with the word that distinguishes them, since people scan the start and typeahead matches it.

Group related items with separators, and give a group a heading when its items are a set of choices, such as a sort order.

Show state with a checkmark. An option that is on or off shows a checkmark when it is on. Options of which exactly one applies form a group, with a mark beside the current one.

Start every label at the same edge. When any item of a menu can be checked, every item of that menu leaves room for the mark, so that the labels form one column. A submenu is its own menu, and is judged by its own items.

Disable rather than hide an item that cannot be used at the moment, so the menu's shape stays familiar and people can learn where things are.

Mark items that need more. An item that opens a submenu shows an arrow; an item that leads to a dialog ends its label with an ellipsis ("Find…").

Show the shortcut, in the platform's own writing. A command that has a keyboard shortcut shows it after its label, where people learn it: ⇧⌘N where the command key is ⌘, Ctrl+Shift+N elsewhere, from one statement of the shortcut. It is told to assistive technology as the item's shortcut, once, and is not part of the item's name. Where there is no keyboard — a phone — it is not shown.

Make an item that leads somewhere a link. An item that opens a page is a real link, so that it can be opened in a new tab, copied or dragged like any other. Choosing it — by click, by key, or by dragging to it — follows the link and closes the menu.

Keep submenus shallow. One level of submenu is easy to use; more than one makes commands hard to find and hard to reach.

Close the menu when a choice is made, and return focus to the control that opened it — or, for a context menu, to what had focus before it opened.

Close it when attention goes elsewhere. A menu does not outlast its window's focus, or a change in the window's size: like a platform's own, it closes.

Forgive the pointer's path. A pointer on its way to an open submenu crosses the items between. While it is heading for the submenu they do not take over; if it stops on one, they do.

Act once. A key held down opens the menu and goes no further; it does not choose the item the menu opened on. An arrow key held down runs to the last item and stops there; only a fresh press comes round to the first. A menu that is fading away takes no presses, so one choice cannot be made twice.

Let a long menu scroll. A menu taller than the view stays within it and scrolls, keeping the item the keys move to in sight. A submenu of a scrolled menu still opens beside its own item.

Let what is under a context menu be selected first. A secondary press on an item of a list that is not in the selection selects it, and then the menu opens: the menu is always about what is seen to be selected. A second request elsewhere moves the menu there, having selected what is there.

A part held to be dragged asks for no menu. A finger put down on a handle or a grip rests there before it moves; that rest is the start of a drag, not a request. Such a part opens no context menu, however long it is held, and the drag that follows goes on. (Here it is marked data-drag-handle.)

Give way to the browser's menu only when yours opens. Where a context menu is not offered — the region is disabled, the request was declined — the browser's own menu appears as usual.

Interaction

IntentPointerKeyboardTouch
OpenPress the controlEnter, Space or Down ArrowTap the control
Open at the last item—Up Arrow—
Open a context menuSecondary click; Control-click on a MacMenu key, or Shift+F10Touch and hold
Move between itemsMove the pointerUp and Down Arrow, Home, End—
Move to an item by name—Type its first letters—
ChooseClick, or release after dragging from the controlEnter or SpaceTap
Open a submenuRest the pointer on its itemRight Arrow, Enter or SpaceTap its item
Close a submenuMove to another itemLeft Arrow or EscapeTap Back
Close without choosingClick outside, press the control again, or drag off and releaseEscape; Tab also moves onTap outside, or swipe the sheet down

Up and Down Arrow wrap from the last item to the first and back, since a menu is short. In right-to-left text, Left and Right Arrow are mirrored. Space chooses an item, except while a name is being typed, when it is part of the name. A press outside closes the menu and does nothing to what it lands on.

Pressing the control and dragging to an item, then releasing, chooses the item in one gesture. A click — press and release on the control — opens the menu and leaves it open. Pressing the control, dragging away and releasing outside the menu closes it: a drag that chose nothing.

A context menu opened with a pointer opens at the pointer with no item highlighted. Asked for from the keyboard, it opens against what has focus, on its first item.

Platform considerations

Pointer and keyboard. The menu opens beneath its control, and submenus open beside their items. A context menu opens from the pointer toward the end of the line and downward, and to the other side where there is no room; in right-to-left text it opens the other way.

Touch. Items are at least 44 CSS pixels tall. On a phone the menu rises from the bottom edge as a sheet, across the full width. A submenu does not rise as a second sheet: it takes the place of its menu in the same sheet, under the one scrim, beneath a heading that says which submenu it is, with a way back as its first item. Nothing is highlighted for a finger. Touching and holding opens a context menu; where a browser reports no such gesture, the hold is timed, with a little tolerance for a finger that is not quite still, and the platform's own callout is kept from appearing over the menu.

Assistive technology. The control states that it opens a menu and whether it is open; the menu is named by the control, and a context menu, having none, in words; items are menu items, checkbox items or radio items with their checked state; an item that opens a submenu says so; an item with a shortcut states it. An item answers being activated by assistive technology, which presses nothing. A context menu answers the request a screen reader makes for one.

See also Overlays, Keyboard navigation and Typeahead.

Behaviour

Semantics

The control states that it opens a menu, and whether it is open
const screen = await render(<Edit />);
await expect.element(menuTrigger(screen)).toHaveAttribute("aria-haspopup", "menu");
await expect.element(menuTrigger(screen)).toHaveAttribute("aria-expanded", "false");
await userEvent.click(menuTrigger(screen));
await expect.element(menuTrigger(screen)).toHaveAttribute("aria-expanded", "true");
The menu is named by the control that opens it
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
await expect.element(menu(screen)).toBeVisible();
Checkbox and radio items state whether they are checked
const screen = await render(
  <Menu>
    <MenuTrigger>View</MenuTrigger>
    <MenuContent>
      <MenuCheckboxItem defaultChecked>Show Ruler</MenuCheckboxItem>
      <MenuRadioGroup label="Sort By" defaultValue="name">
        <MenuRadioItem value="name">Name</MenuRadioItem>
        <MenuRadioItem value="date">Date</MenuRadioItem>
      </MenuRadioGroup>
    </MenuContent>
  </Menu>,
);
await userEvent.click(screen.getByRole("button", { name: "View" }));
await expect
  .element(screen.getByRole("menuitemcheckbox", { name: "Show Ruler" }))
  .toHaveAttribute("aria-checked", "true");
await expect
  .element(screen.getByRole("menuitemradio", { name: "Name" }))
  .toHaveAttribute("aria-checked", "true");
await expect
  .element(screen.getByRole("menuitemradio", { name: "Date" }))
  .toHaveAttribute("aria-checked", "false");
await expect.element(screen.getByRole("group", { name: "Sort By" })).toBeVisible();
An item that opens a submenu says so, and whether it is open
const screen = await render(<Edit />);
await openWithKeyboard(screen);
await expect.element(item(screen, "Find")).toHaveAttribute("aria-haspopup", "menu");
await expect.element(item(screen, "Find")).toHaveAttribute("aria-expanded", "false");
Has no accessibility violations when open
const screen = await render(
  <Menu>
    <MenuTrigger>View</MenuTrigger>
    <MenuContent>
      <MenuItem>Zoom In</MenuItem>
      <MenuItem disabled>Zoom Out</MenuItem>
      <MenuSeparator />
      <MenuCheckboxItem defaultChecked>Show Ruler</MenuCheckboxItem>
      <MenuGroup label="Panels">
        <MenuItem>Layers</MenuItem>
      </MenuGroup>
    </MenuContent>
  </Menu>,
);
await userEvent.click(screen.getByRole("button", { name: "View" }));
await expectAccessible(screen.container);

Opening

A click opens the menu, with no item highlighted
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
await expect.element(menu(screen)).toHaveFocus();
Pressing the control and releasing it leaves the menu open
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(await isOpen(screen)).toBe(true);
Enter or Space opens the menu on its first item
const screen = await render(<Edit />);
await openWithKeyboard(screen);
await press("{Escape}");
await press(" ");
await expect.element(item(screen, "Undo")).toHaveFocus();
ArrowDown opens on the first item, ArrowUp on the last
const screen = await render(<Edit />);
await focusTrigger(screen);
await press("{ArrowDown}");
await expect.element(item(screen, "Undo")).toHaveFocus();
await press("{Escape}");
await press("{ArrowUp}");
await expect.element(item(screen, "Paste")).toHaveFocus();
Pressing the control, dragging to an item and releasing chooses it
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await commands.pressDragRelease(menuTrigger(screen).selector, item(screen, "Copy").selector);
expect(onSelect).toHaveBeenCalledWith("Copy");
await expect.poll(() => isOpen(screen)).toBe(false);
Pressing the control, dragging away and releasing outside the menu closes itChromium, Firefox, WebKit

Not yet written.

Keyboard navigation

An arrow key held down stops at the end: only a fresh press comes round
const screen = await render(<Edit />);
await openWithKeyboard(screen);
// More repeats than there are items.
await press("{ArrowDown>12/}");
await expect.element(item(screen, "Paste")).toHaveFocus();
await press("{ArrowDown}");
await expect.element(item(screen, "Undo")).toHaveFocus();
Arrow keys move through the items, wrapping at either end
const screen = await render(<Edit />);
await openWithKeyboard(screen);
await press("{ArrowUp}");
await expect.element(item(screen, "Paste")).toHaveFocus();
await press("{ArrowDown}");
await expect.element(item(screen, "Undo")).toHaveFocus();
Disabled items are passed over
const screen = await render(<Edit />);
await openWithKeyboard(screen);
await press("{ArrowDown}");
await expect.element(item(screen, "Cut")).toHaveFocus();
Home and End move to the first and last item
const screen = await render(<Edit />);
await openWithKeyboard(screen);
await press("{End}");
await expect.element(item(screen, "Paste")).toHaveFocus();
await press("{Home}");
await expect.element(item(screen, "Undo")).toHaveFocus();
Typing moves to the item that starts with what was typed
const screen = await render(<Edit />);
await openWithKeyboard(screen);
await press("p");
await expect.element(item(screen, "Paste")).toHaveFocus();
await press("{Home}c");
await expect.element(item(screen, "Cut")).toHaveFocus();
await press("c");
await expect.element(item(screen, "Copy")).toHaveFocus();
A space is part of a word being typed, and chooses nothing
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await openWithKeyboard(screen);
await press("{End}{ArrowUp}{ArrowRight}");
await expect.element(item(screen, "Find…")).toHaveFocus();
await press("find n");
await expect.element(item(screen, "Find Next")).toHaveFocus();
expect(onSelect).not.toHaveBeenCalled();
expect(await isOpen(screen)).toBe(true);
The pointer moves the highlight with it
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
await userEvent.hover(item(screen, "Copy"));
await expect.element(item(screen, "Copy")).toHaveFocus();

Choosing

Enter chooses the item, closes the menu and returns focus to its control
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await openWithKeyboard(screen);
await press("{ArrowDown}{Enter}");
expect(onSelect).toHaveBeenCalledWith("Cut");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(menuTrigger(screen)).toHaveFocus();
A click chooses the item and closes the menu
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await userEvent.click(menuTrigger(screen));
await userEvent.click(item(screen, "Paste"));
expect(onSelect).toHaveBeenCalledWith("Paste");
await expect.poll(() => isOpen(screen)).toBe(false);
Space chooses the item too
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await openWithKeyboard(screen);
await press(" ");
expect(onSelect).toHaveBeenCalledWith("Undo");
await expect.poll(() => isOpen(screen)).toBe(false);
A key held down chooses nothing: the menu it opened stays open
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await openWithKeyboard(screen);
// The key repeats first on the control, if the menu has yet to take focus.
const held = new KeyboardEvent("keydown", {
  key: "Enter",
  repeat: true,
  bubbles: true,
  cancelable: true,
});
menuTrigger(screen).element().dispatchEvent(held);
expect(held.defaultPrevented).toBe(true);
for (const key of ["Enter", " "]) {
  document.activeElement!.dispatchEvent(
    new KeyboardEvent("keydown", { key, repeat: true, bubbles: true, cancelable: true }),
  );
}
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onSelect).not.toHaveBeenCalled();
expect(await isOpen(screen)).toBe(true);
A click that no press came before, as assistive technology makes, chooses the item
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await userEvent.click(menuTrigger(screen));
(item(screen, "Cut").element() as HTMLElement).click();
expect(onSelect).toHaveBeenCalledWith("Cut");
await expect.poll(() => isOpen(screen)).toBe(false);
A disabled item cannot be chosen
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await userEvent.click(menuTrigger(screen));
await userEvent.click(item(screen, "Redo"), { force: true });
expect(onSelect).not.toHaveBeenCalled();
expect(await isOpen(screen)).toBe(true);
Choosing a checkbox item turns it on or off
const screen = await render(
  <Menu>
    <MenuTrigger>View</MenuTrigger>
    <MenuContent>
      <MenuCheckboxItem>Show Ruler</MenuCheckboxItem>
    </MenuContent>
  </Menu>,
);
const view = screen.getByRole("button", { name: "View" });
const ruler = screen.getByRole("menuitemcheckbox", { name: "Show Ruler", includeHidden: true });
await userEvent.click(view);
await userEvent.click(ruler);
await expect.element(ruler).toHaveAttribute("aria-checked", "true");
await userEvent.click(view);
await userEvent.click(ruler);
await expect.element(ruler).toHaveAttribute("aria-checked", "false");
Choosing a radio item selects it and deselects the others in its group
const screen = await render(
  <Menu>
    <MenuTrigger>View</MenuTrigger>
    <MenuContent>
      <MenuRadioGroup label="Sort By" defaultValue="name">
        <MenuRadioItem value="name">Name</MenuRadioItem>
        <MenuRadioItem value="date">Date</MenuRadioItem>
      </MenuRadioGroup>
    </MenuContent>
  </Menu>,
);
await userEvent.click(screen.getByRole("button", { name: "View" }));
await userEvent.click(screen.getByRole("menuitemradio", { name: "Date" }));
const name = screen.getByRole("menuitemradio", { name: "Name", includeHidden: true });
const date = screen.getByRole("menuitemradio", { name: "Date", includeHidden: true });
await expect.element(date).toHaveAttribute("aria-checked", "true");
await expect.element(name).toHaveAttribute("aria-checked", "false");

Dismissal

Escape closes the menu and returns focus to its control
const screen = await render(<Edit />);
await openWithKeyboard(screen);
await press("{Escape}");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(menuTrigger(screen)).toHaveFocus();
Tab closes the menu and moves on from its control
const screen = await render(<Edit />);
await openWithKeyboard(screen);
await press("{Tab}");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(screen.getByRole("textbox", { name: "After" })).toHaveFocus();
A press outside closes the menu, and does nothing to what it lands on
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
await userEvent.click(screen.getByRole("textbox", { name: "After" }), throughScrim);
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(screen.getByRole("textbox", { name: "After" })).not.toHaveFocus();
Pressing the control again closes the menu
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
await userEvent.click(menuTrigger(screen), throughScrim);
await expect.poll(() => isOpen(screen)).toBe(false);
The menu closes when its window loses the person's attention
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
await expect.poll(() => isOpen(screen)).toBe(true);
window.dispatchEvent(new Event("blur"));
await expect.poll(() => isOpen(screen)).toBe(false);
The menu closes when its window changes sizeChromium, Firefox, WebKit

Not yet written.

A menu that is fading away takes no presses: an item cannot be chosen twice
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
const element = menu(screen).element() as HTMLElement;
// Let it finish appearing, so that what follows is its fading and nothing else.
await Promise.all(element.getAnimations().map((animation) => animation.finished));
(item(screen, "Cut").element() as HTMLElement).click();
// Once React has closed it, and before anything has had time to fade.
await new Promise<void>(queueMicrotask);
expect(element.matches(":popover-open")).toBe(false);
// Gone at once, where an engine does not fade it; else still to be seen, and out of the pointer's reach.
const style = getComputedStyle(element);
expect(style.display === "none" || style.pointerEvents === "none").toBe(true);

Submenus

The arrow toward the submenu opens it on its first item
const screen = await render(<Edit />);
await focusFind(screen);
await press("{ArrowRight}");
await expect.element(item(screen, "Find…")).toHaveFocus();
await expect.element(item(screen, "Find")).toHaveAttribute("aria-expanded", "true");
Enter and Space open the submenu too
const screen = await render(<Edit />);
await focusFind(screen);
await press("{Enter}");
await expect.element(item(screen, "Find…")).toHaveFocus();
The arrow away from it closes the submenu and returns to its item
const screen = await render(<Edit />);
await focusFind(screen);
await press("{ArrowRight}{ArrowLeft}");
await expect.poll(() => isOpen(screen, "Find")).toBe(false);
await expect.element(item(screen, "Find")).toHaveFocus();
expect(await isOpen(screen)).toBe(true);
In right-to-left text, the arrows are mirrored
const screen = await render(<Edit dir="rtl" />);
await focusFind(screen);
await press("{ArrowLeft}");
await expect.element(item(screen, "Find…")).toHaveFocus();
await press("{ArrowRight}");
await expect.element(item(screen, "Find")).toHaveFocus();
Escape closes only the submenu
const screen = await render(<Edit />);
await focusFind(screen);
await press("{ArrowRight}{Escape}");
await expect.poll(() => isOpen(screen, "Find")).toBe(false);
expect(await isOpen(screen)).toBe(true);
await expect.element(item(screen, "Find")).toHaveFocus();
Choosing in a submenu closes every menu and returns focus to the first control
const onSelect = vi.fn();
const screen = await render(<Edit onSelect={onSelect} />);
await focusFind(screen);
await press("{ArrowRight}{ArrowDown}{Enter}");
expect(onSelect).toHaveBeenCalledWith("Find Next");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(menuTrigger(screen)).toHaveFocus();
Resting the pointer on the item opens its submenu
const screen = await render(<Edit />);
await userEvent.click(menuTrigger(screen));
await userEvent.hover(item(screen, "Find"));
await expect.poll(() => isOpen(screen, "Find")).toBe(true);
Moving the pointer to another item closes the submenuChromium, Firefox, WebKit

Not yet written.

A submenu the pointer opened is entered by the arrow toward itChromium, Firefox, WebKit

Not yet written.

Moving the pointer diagonally toward an open submenu, across other items, keeps it openChromium, Firefox, WebKit

Not yet written.

Touch

A tap opens the menu, and a tap on an item chooses itChromium · touch, WebKit · touch

Not yet written.

On a phone, the menu rises from the bottom edge across the full widthChromium · touch, WebKit · touch

Not yet written.

Every item is at least 44 pixels tallChromium · touch, WebKit · touch

Not yet written.

On a phone, a submenu takes the place of its menu: one sheet under one scrim, with nothing highlightedChromium · touch, WebKit · touch

Not yet written.

There it has a heading that says which submenu it is, and a way back that returns to the menuChromium · touch, WebKit · touch

Not yet written.

A tap on the scrim closes the submenu and its menu togetherChromium · touch, WebKit · touch

Not yet written.

Opened by a key there, a submenu opens on its first item, past the way backChromium · touch, WebKit · touch

Not yet written.

With a pointer a submenu has no way back in it: its menu is beside itChromium, Firefox, WebKit

Not yet written.

Items

A key written as a symbol is written as text, never as an emoji
const screen = await render(
  <Menu>
    <MenuTrigger>File</MenuTrigger>
    <MenuContent>
      <MenuItem shortcut="Enter">Open</MenuItem>
    </MenuContent>
  </Menu>,
);
await userEvent.click(screen.getByRole("button", { name: "File" }));
const hint = screen
  .getByRole("menuitem", { name: "Open", exact: true })
  .element()
  .querySelector("[aria-hidden]")!;
expect(hint.textContent).toBe(isApplePlatform() ? "↩\uFE0E" : "Enter");
An item's shortcut is written after its label as its platform writes it, and told to assistive technology once
const screen = await render(
  <Menu>
    <MenuTrigger>File</MenuTrigger>
    <MenuContent>
      <MenuItem shortcut="Mod+Shift+N">New Page</MenuItem>
    </MenuContent>
  </Menu>,
);
await userEvent.click(screen.getByRole("button", { name: "File" }));
// Its name is its label alone: the shortcut is not read out as part of it.
const newPage = screen.getByRole("menuitem", { name: "New Page", exact: true });
const apple = isApplePlatform();
await expect
  .element(newPage)
  .toHaveAttribute("aria-keyshortcuts", apple ? "Meta+Shift+N" : "Control+Shift+N");
const hint = newPage.element().querySelector("[aria-hidden]")!;
expect(hint.textContent).toBe(apple ? "⇧⌘N" : "Ctrl+Shift+N");
const label = newPage.element().firstChild as Text;
const range = document.createRange();
range.selectNodeContents(label);
// Unseen where there is no keyboard to press it on.
if (!hasTouch)
  expect(hint.getBoundingClientRect().left).toBeGreaterThan(range.getBoundingClientRect().right);
An item that leads somewhere is a link, and choosing it by key, click or drag follows it
const onSelect = vi.fn();
const screen = await render(
  <>
    <Menu>
      <MenuTrigger>Help</MenuTrigger>
      <MenuContent>
        <MenuItem>About</MenuItem>
        <MenuItem href="#guide" onSelect={onSelect}>
          Guide
        </MenuItem>
      </MenuContent>
    </Menu>
    <input aria-label="After" />
  </>,
);
const help = screen.getByRole("button", { name: "Help" });
const guide = screen.getByRole("menuitem", { name: "Guide" });
const followed = async (times: number) => {
  await expect.poll(() => location.hash).toBe("#guide");
  expect(onSelect).toHaveBeenCalledTimes(times);
  await expect.poll(() => isOpen(screen, "Help")).toBe(false);
  history.replaceState(null, "", location.pathname + location.search);
};
try {
  await userEvent.click(help);
  const link = guide.element() as HTMLAnchorElement;
  expect(link.tagName).toBe("A");
  expect(link.getAttribute("href")).toBe("#guide");
  await userEvent.click(guide);
  await followed(1);

  await userEvent.click(screen.getByRole("textbox", { name: "After" }));
  (help.element() as HTMLElement).focus();
  await press("{Enter}{ArrowDown}{Enter}");
  await followed(2);

  if (!hasTouch) {
    await commands.pressDragRelease(help.selector, guide.selector);
    await followed(3);
  }
} finally {
  history.replaceState(null, "", location.pathname + location.search);
}
When any item of a menu can be checked, every label in it begins at the same edge
const screen = await render(
  <>
    <Menu>
      <MenuTrigger>View</MenuTrigger>
      <MenuContent>
        <MenuItem>Zoom In</MenuItem>
        <MenuCheckboxItem>Show Ruler</MenuCheckboxItem>
      </MenuContent>
    </Menu>
    <Menu>
      <MenuTrigger>Go</MenuTrigger>
      <MenuContent>
        <MenuItem>Back</MenuItem>
      </MenuContent>
    </Menu>
  </>,
);
const labelStart = (element: Element) => {
  const range = document.createRange();
  range.selectNodeContents(
    Array.from(element.childNodes).find((node) => node.nodeType === Node.TEXT_NODE)!,
  );
  return range.getBoundingClientRect().left - element.getBoundingClientRect().left;
};
await userEvent.click(screen.getByRole("button", { name: "View" }));
const plain = labelStart(screen.getByRole("menuitem", { name: "Zoom In" }).element());
const checkable = labelStart(screen.getByRole("menuitemcheckbox", { name: "Show Ruler" }).element());
expect(Math.abs(plain - checkable)).toBeLessThan(0.5);
await press("{Escape}");
// With nothing to check, no room is left for a mark.
await userEvent.click(screen.getByRole("button", { name: "Go" }));
expect(labelStart(screen.getByRole("menuitem", { name: "Back" }).element())).toBeLessThan(plain - 8);

Long menus

A menu longer than the view stays within it and scrolls, keeping the item the keys move to in sight
const screen = await render(<Long />);
const fonts = await openLong(screen);
await settled();
await expect.poll(() => fonts.getBoundingClientRect().bottom).toBeLessThanOrEqual(window.innerHeight);
expect(fonts.getBoundingClientRect().top).toBeGreaterThanOrEqual(0);
expect(fonts.scrollHeight).toBeGreaterThan(fonts.clientHeight);
await press("{End}");
const last = screen.getByRole("menuitem", { name: "More" });
await expect.element(last).toHaveFocus();
const box = fonts.getBoundingClientRect();
const more = last.element().getBoundingClientRect();
expect(more.top).toBeGreaterThanOrEqual(box.top);
expect(more.bottom).toBeLessThanOrEqual(box.bottom);
await press("{Home}");
const first = screen.getByRole("menuitem", { name: "Font 1", exact: true }).element();
expect(first.getBoundingClientRect().top).toBeGreaterThanOrEqual(fonts.getBoundingClientRect().top);
A submenu of a menu that has scrolled still opens beside its itemChromium, Firefox, WebKit

Not yet written.

Context menu

A secondary click opens the menu at the pointer, with no item highlightedChromium, Firefox, WebKit

Not yet written.

A second secondary click elsewhere moves the menu thereChromium, Firefox, WebKit

Not yet written.

Asked for by the keyboard, it opens against what has focus, on its first item
const screen = await render(<Files />);
// A pointer was used here last, and then the keyboard.
await userEvent.click(screen.getByText("Report"));
const notes = document.querySelector<HTMLElement>("#Notes")!;
notes.focus();
await askByKeyboard(screen, notes);
await expect.element(screen.getByRole("menuitem", { name: "Open" })).toHaveFocus();
if (hasTouch) return;
const rect = context(screen).element().getBoundingClientRect();
near(rect.left, notes.getBoundingClientRect().left);
near(rect.top, notes.getBoundingClientRect().bottom);
Choosing an item closes it, and so does Escape, and focus returns to what had it before
const onSelect = vi.fn();
const screen = await render(<Files onSelect={onSelect} />);
await userEvent.click(screen.getByRole("textbox", { name: "After" }));
const notes = document.querySelector<HTMLElement>("#Notes")!;
notes.focus();
await askByKeyboard(screen, notes);
await expect.element(screen.getByRole("menuitem", { name: "Open" })).toHaveFocus();
await press("{Escape}");
await expect.poll(() => contextOpen(screen)).toBe(false);
expect(document.activeElement).toBe(notes);

await askByKeyboard(screen, notes);
await expect.element(screen.getByRole("menuitem", { name: "Open" })).toHaveFocus();
await press("{ArrowDown}{Enter}");
expect(onSelect).toHaveBeenCalledWith("Rename");
await expect.poll(() => contextOpen(screen)).toBe(false);
expect(document.activeElement).toBe(notes);
A press outside closes it, and does nothing to what it lands onChromium, Firefox, WebKit

Not yet written.

The browser's own menu gives way only when this one opens
const ask = () =>
  region().dispatchEvent(
    new MouseEvent("contextmenu", {
      bubbles: true,
      cancelable: true,
      button: 2,
      clientX: 30,
      clientY: 30,
    }),
  );
const screen = await render(<Files disabled />);
// `dispatchEvent` answers false when the default was prevented.
expect(ask()).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(contextOpen(screen)).toBe(false);
await screen.rerender(<Files />);
expect(ask()).toBe(false);
await expect.poll(() => contextOpen(screen)).toBe(true);
A secondary press is first the list's, which selects what it is on, and then the menu opensChromium, Firefox, WebKit

Not yet written.

A finger that rests opens it, without waiting for a browser that brings no event for thatChromium · touch, WebKit · touch

Not yet written.

A finger that rests on a part held to be dragged opens nothing: it has not moved yetChromium · touch, WebKit · touch

Not yet written.

A finger that moves on before it has rested opens nothingChromium · touch, WebKit · touch

Not yet written.

Has no accessibility violations when open
const screen = await render(<Files />);
region().dispatchEvent(
  new MouseEvent("contextmenu", {
    bubbles: true,
    cancelable: true,
    button: 2,
    clientX: 30,
    clientY: 30,
  }),
);
await expect.poll(() => contextOpen(screen)).toBe(true);
await expectAccessible(screen.container);

API

onSelect can keep the menu open with preventDefault()
const screen = await render(
  <Menu>
    <MenuTrigger>View</MenuTrigger>
    <MenuContent>
      <MenuItem onSelect={(event) => event.preventDefault()}>Zoom In</MenuItem>
    </MenuContent>
  </Menu>,
);
await userEvent.click(screen.getByRole("button", { name: "View" }));
await userEvent.click(screen.getByRole("menuitem", { name: "Zoom In" }));
expect(await isOpen(screen, "View")).toBe(true);
A controlled open changes only when its owner changes it
function Controlled() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Menu open={open} onOpenChange={setOpen}>
        <MenuTrigger>View</MenuTrigger>
        <MenuContent>
          <MenuItem>Zoom In</MenuItem>
        </MenuContent>
      </Menu>
      <output>{open ? "open" : "closed"}</output>
    </>
  );
}
const screen = await render(<Controlled />);
await userEvent.click(screen.getByRole("button", { name: "View" }));
await expect.element(screen.getByRole("status")).toHaveTextContent("open");
await press("{Escape}");
await expect.element(screen.getByRole("status")).toHaveTextContent("closed");
An id given to MenuTrigger is kept, and the menu is still named by its control
const screen = await render(
  <Menu>
    <MenuTrigger id="view-menu">View</MenuTrigger>
    <MenuContent>
      <MenuItem>Zoom In</MenuItem>
    </MenuContent>
  </Menu>,
);
const view = screen.getByRole("button", { name: "View" });
await expect.element(view).toHaveAttribute("id", "view-menu");
await userEvent.click(view);
await expect.element(screen.getByRole("menu", { name: "View" })).toBeVisible();
messages names a context menu and the way back in another language
const screen = await render(
  <ContextMenu messages={{ contextMenu: "コンテキストメニュー", back: "戻る" }}>
    <ContextMenuTrigger id="area" className="h-20 border" />
    <MenuContent>
      <MenuSub>
        <MenuSubTrigger>共有</MenuSubTrigger>
        <MenuSubContent>
          <MenuItem>リンクをコピー</MenuItem>
        </MenuSubContent>
      </MenuSub>
    </MenuContent>
  </ContextMenu>,
);
document
  .querySelector("#area")!
  .dispatchEvent(
    new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }),
  );
await expect.element(screen.getByRole("menu", { name: "コンテキストメニュー" })).toBeVisible();
if (!hasTouch) return;
await commands.tap(screen.getByRole("menuitem", { name: "共有" }).selector);
await expect.element(screen.getByRole("menuitem", { name: "戻る" })).toBeVisible();
An aria-label given to a context menu's MenuContent names it instead
const screen = await render(
  <ContextMenu>
    <ContextMenuTrigger id="area" className="h-20 border" />
    <MenuContent aria-label="File actions">
      <MenuItem>Open</MenuItem>
    </MenuContent>
  </ContextMenu>,
);
document
  .querySelector("#area")!
  .dispatchEvent(
    new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }),
  );
await expect.element(screen.getByRole("menu", { name: "File actions" })).toBeVisible();

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/menu.json
Source

src/components/menu/menu.tsx

"use client";

import {
  createContext,
  use,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
} from "react";
import type {
  ComponentProps,
  KeyboardEvent,
  MouseEvent,
  PointerEvent,
  ReactElement,
  ReactNode,
  Ref,
  RefObject,
} from "react";
import { pointInPolygon } from "../../algorithms/geometry.ts";
import type { Point } from "../../algorithms/geometry.ts";
import { composeEventHandlers } from "../../lib/compose-event-handlers.ts";
import { buttonClassName } from "../../lib/button-class.ts";
import { mergeProps } from "../../lib/merge-props.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { overlaySurfaceClassName } from "../../lib/overlay-class.ts";
import { isApplePlatform } from "../../lib/platform.ts";
import { pointerMoved, watchPointer } from "../../lib/pointer.ts";
import { cn } from "../../lib/utils.ts";
import { collectionItemProps, focusItem, getCollection } from "../../primitives/collection.ts";
import { moveForKey, step } from "../../primitives/navigation.ts";
import { useOverlay, useSheet } from "../../primitives/overlay.ts";
import type { InitialFocus, Overlay } from "../../primitives/overlay.ts";
import { matchTypeahead, useTypeahead } from "../../primitives/typeahead.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";

/** Long enough that passing over an item on the way elsewhere does not open its submenu. */
const SUBMENU_DELAY = 120;
/** How long a pointer heading for a submenu may dwell on the items between before they take over. */
const AIM_GRACE = 300;
/** How long a finger rests before it is asking for a context menu, where the browser does not say. */
const LONG_PRESS = 500;
/** How far a resting finger may wander and still be resting. */
const LONG_PRESS_TOLERANCE = 10;

/** The ground a pointer may cross on its way from a submenu's item to the submenu. */
interface Aim {
  readonly area: readonly Point[];
  /** The side of the item the submenu is on, as the pointer must be moving. */
  readonly toward: 1 | -1;
}

interface Count {
  readonly subscribe: (onChange: () => void) => () => void;
  readonly any: () => boolean;
  /** Count one more, until what is returned is called. */
  readonly add: () => () => void;
}

function createCount(): Count {
  let count = 0;
  const listeners = new Set<() => void>();
  const change = (by: number) => {
    const before = count > 0;
    count += by;
    if (count > 0 !== before) for (const listener of listeners) listener();
  };
  return {
    subscribe(onChange) {
      listeners.add(onChange);
      return () => listeners.delete(onChange);
    },
    any: () => count > 0,
    add() {
      change(1);
      return () => change(-1);
    },
  };
}

interface MenuLevel {
  /** The collection scope of this level's items. */
  readonly scope: string;
  readonly contentRef: RefObject<HTMLElement | null>;
  /** Close the whole menu: a choice was made. */
  readonly closeAll: () => void;
  /** Close this level's open submenus, except the one whose trigger is `except`. */
  readonly closeSubmenus: (except?: string) => void;
  readonly registerSubmenu: (key: string, close: () => void) => () => void;
  /** Set as the pointer leaves the item of an open submenu; cleared after a moment. */
  readonly aim: RefObject<Aim | null>;
  /** Which way the pointer last moved across this level: 1 rightward, -1 leftward. */
  readonly heading: RefObject<1 | -1>;
  /** Where the pointer last was over this level. */
  readonly position: RefObject<Point | null>;
  /** How many of this level's items can be checked: with any, every label makes room for the mark. */
  readonly checkable: Count;
}

/** What a menu says in words; pass `messages` to say it in another language. */
export interface MenuMessages {
  /** The way back from a submenu, where a submenu takes the place of its menu: on a phone. */
  readonly back: string;
  /** The name of a context menu that is given none of its own. */
  readonly contextMenu: string;
}

const MESSAGES: MenuMessages = { back: "Back", contextMenu: "Context menu" };

interface MenuRoot {
  readonly overlay: Overlay;
  readonly level: MenuLevel;
  /** The id of the control that names the menu. */
  readonly triggerId: string;
  /** Told of an id the control was given, so that the menu is still named by it. */
  readonly setTriggerId: (id: string | undefined) => void;
  /** Whether it opens at a point, with no control of its own. */
  readonly context: boolean;
  readonly messages: MenuMessages;
}

interface MenuSubRoot {
  readonly overlay: Overlay;
  readonly level: MenuLevel;
  readonly triggerKey: string;
  /** What its item says: its heading, where it takes its menu's place. */
  readonly title: string;
}

const RootContext = createContext<MenuRoot | null>(null);
const LevelContext = createContext<MenuLevel | null>(null);
const SubContext = createContext<MenuSubRoot | null>(null);

function required<T>(value: T | null, name: string): T {
  if (value === null) throw new Error(`${name} must be rendered inside a Menu.`);
  return value;
}

/**
 * The enabled items of one menu, not of the submenus inside it: where focus may
 * land as it opens. Not the way back, which is passed on the way in.
 */
function menuItems(content: HTMLElement): HTMLElement[] {
  return Array.from(
    content.querySelectorAll<HTMLElement>("[data-collection]:not([data-disabled], [data-back])"),
  ).filter((item) => item.closest('[role="menu"]') === content);
}

function levelCollection(level: MenuLevel) {
  return getCollection(level.contentRef.current, level.scope);
}

function enabledItems(level: MenuLevel): HTMLElement[] {
  return getCollection(level.contentRef.current, level.scope)
    .filter((item) => !item.disabled)
    .map((item) => item.element);
}

function useLevel(closeAll: () => void): MenuLevel {
  const scope = useId();
  const contentRef = useRef<HTMLElement>(null);
  const submenus = useRef(new Map<string, () => void>());
  const aim = useRef<Aim | null>(null);
  const heading = useRef<1 | -1>(1);
  const position = useRef<Point | null>(null);
  // Watched from before the menu opens, so that the first move over it can be judged.
  useEffect(watchPointer, []);
  const [checkable] = useState(createCount);
  return useMemo(
    () => ({
      scope,
      contentRef,
      aim,
      heading,
      position,
      checkable,
      closeAll,
      closeSubmenus(except) {
        for (const [key, close] of submenus.current) if (key !== except) close();
      },
      registerSubmenu(key, close) {
        submenus.current.set(key, close);
        return () => submenus.current.delete(key);
      },
    }),
    [scope, checkable, closeAll],
  );
}

/** What a menu opened from a control and one opened at a point share. */
function useMenuRoot(overlay: Overlay, context: boolean, messages?: Partial<MenuMessages>): MenuRoot {
  const ownId = useId();
  const [givenId, setTriggerId] = useState<string>();
  const { open, setOpen } = overlay;
  const closeAll = useCallback(() => setOpen(false), [setOpen]);
  const level = useLevel(closeAll);

  // A menu does not outlive its window's attention, or its window's shape: a platform's own closes too.
  useEffect(() => {
    if (!open) return undefined;
    const view = level.contentRef.current?.ownerDocument.defaultView ?? window;
    const close = () => setOpen(false);
    view.addEventListener("blur", close);
    view.addEventListener("resize", close);
    return () => {
      view.removeEventListener("blur", close);
      view.removeEventListener("resize", close);
    };
  }, [open, setOpen, level]);

  return useMemo(
    () => ({
      overlay,
      level,
      triggerId: givenId ?? ownId,
      setTriggerId,
      context,
      messages: { ...MESSAGES, ...messages },
    }),
    [overlay, level, givenId, ownId, context, messages],
  );
}

export type MenuProps = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  messages?: Partial<MenuMessages>;
  children?: ReactNode;
};

export function Menu({ open, defaultOpen, onOpenChange, messages, children }: MenuProps): ReactElement {
  const overlay = useOverlay({
    open,
    defaultOpen,
    onOpenChange,
    placement: "block-end",
    focusable: menuItems,
    // A press outside a menu dismisses it and does nothing more.
    modal: true,
  });
  const root = useMenuRoot(overlay, false, messages);
  return <RootContext value={root}>{children}</RootContext>;
}

export function MenuTrigger({
  id,
  className,
  ref,
  style,
  onPointerDown,
  onClick,
  onKeyDown,
  ...props
}: ComponentProps<"button">): ReactElement {
  const { overlay, level, triggerId, setTriggerId } = required(use(RootContext), "MenuTrigger");
  const pointerType = useRef("");
  useLayoutEffect(() => {
    setTriggerId(id);
    return () => setTriggerId(undefined);
  }, [id, setTriggerId]);

  function toggle(focus: InitialFocus): void {
    overlay.setOpen(!overlay.open, focus);
  }

  // The latest way to close, for a release heard long after the press that began it.
  const setOpen = useRef(overlay.setOpen);
  useEffect(() => {
    setOpen.current = overlay.setOpen;
  });
  const release = useRef<AbortController>(undefined);
  useEffect(() => () => release.current?.abort(), []);

  /** A press that opened the menu and is let go away from it closes it again: a drag that chose nothing. */
  function closeOnReleaseElsewhere(trigger: HTMLElement): void {
    release.current?.abort();
    const controller = new AbortController();
    release.current = controller;
    const options = { capture: true, signal: controller.signal };
    trigger.ownerDocument.addEventListener(
      "pointerup",
      (event) => {
        controller.abort();
        // By where it was let go rather than on what: behind a sheet the control is inert, and is not what is hit.
        const box = trigger.getBoundingClientRect();
        const onTrigger =
          event.clientX >= box.left &&
          event.clientX <= box.right &&
          event.clientY >= box.top &&
          event.clientY <= box.bottom;
        const content = level.contentRef.current;
        if (onTrigger || (content !== null && event.composedPath().includes(content))) return;
        setOpen.current(false);
      },
      options,
    );
    trigger.ownerDocument.addEventListener("pointercancel", () => controller.abort(), options);
  }

  return (
    <button
      type="button"
      id={id ?? triggerId}
      aria-haspopup="menu"
      {...overlay.triggerProps}
      ref={mergeRefs(overlay.triggerProps.ref, ref)}
      style={{ ...overlay.triggerProps.style, ...style }}
      // A mouse opens on press, so the pointer can be dragged to an item and released.
      onPointerDown={(event) => {
        onPointerDown?.(event);
        if (event.defaultPrevented) return;
        pointerType.current = event.pointerType;
        if (event.pointerType !== "mouse" || event.button !== 0) return;
        if (!overlay.open) closeOnReleaseElsewhere(event.currentTarget);
        toggle("content");
      }}
      onClick={(event) => {
        onClick?.(event);
        if (event.defaultPrevented) return;
        const pressed = pointerType.current === "mouse" && event.detail !== 0;
        pointerType.current = "";
        if (!pressed) toggle(event.detail === 0 ? "first" : "content");
      }}
      // Keep the press from moving focus back to the trigger after the menu took it.
      onMouseDown={(event) => {
        if (event.button === 0) event.preventDefault();
      }}
      onKeyDown={(event) => {
        onKeyDown?.(event);
        if (event.defaultPrevented) return;
        if (event.key === "ArrowDown" || event.key === "ArrowUp") {
          event.preventDefault();
          overlay.setOpen(true, event.key === "ArrowDown" ? "first" : "last");
        } else if (event.repeat && (event.key === "Enter" || event.key === " ")) {
          // A key held down opens the menu once; it does not go on to choose from it.
          event.preventDefault();
        }
      }}
      className={cn(buttonClassName, className)}
      {...props}
    />
  );
}

/**
 * The menu surface. On a phone it becomes a sheet along the bottom edge. Every
 * label of a level starts at the same edge: further in, once any item of the
 * level can be checked, to leave room for the mark.
 */
const SURFACE = cn(
  overlaySurfaceClassName,
  // Clear of the edge of the view when held against it, which leaves room for a
  // submenu raised or lowered to bring its first item level with its own.
  "mt-1 mb-2 min-w-52 flex-col p-1 open:flex pointer-coarse:max-sm:mb-0",
  "[--menu-inset:--spacing(2)] data-inset:[--menu-inset:--spacing(7)]",
  "pointer-coarse:max-sm:pb-[max(0.25rem,env(safe-area-inset-bottom))]",
);

function trackHeading(level: MenuLevel, event: PointerEvent<HTMLElement>): void {
  const before = level.position.current;
  if (before !== null && event.clientX !== before.x)
    level.heading.current = event.clientX > before.x ? 1 : -1;
  level.position.current = { x: event.clientX, y: event.clientY };
}

/** Whether the pointer is on its way to an open submenu, across the items between. */
function isAiming(level: MenuLevel, event: PointerEvent<HTMLElement>): boolean {
  const aim = level.aim.current;
  return (
    aim !== null &&
    level.heading.current === aim.toward &&
    pointInPolygon({ x: event.clientX, y: event.clientY }, aim.area)
  );
}

function useLevelKeyDown(level: MenuLevel, onCloseKey?: () => void) {
  const typeahead = useTypeahead(
    useCallback(
      (query: string) => {
        const items = levelCollection(level).filter((item) => !item.disabled);
        const current = items.find((item) => item.element === document.activeElement)?.key ?? null;
        const match = matchTypeahead(
          items.map((item) => ({ key: item.key, text: item.textValue })),
          query,
          current,
        );
        const element = items.find((item) => item.key === match)?.element;
        if (element !== undefined) focusItem(element);
      },
      [level],
    ),
  );

  return (event: KeyboardEvent<HTMLElement>) => {
    // A submenu sits inside its parent; each level answers only its own keys.
    if ((event.target as Element).closest('[role="menu"]') !== event.currentTarget) return;
    if (typeahead(event)) {
      event.preventDefault();
      return;
    }
    // Space chooses only when it is not part of a word being typed.
    if (event.key === " ") {
      event.preventDefault();
      const item = event.currentTarget.ownerDocument.activeElement as HTMLElement | null;
      if (!event.repeat && item?.dataset.collection === level.scope) item.click();
      return;
    }
    const direction = getComputedStyle(event.currentTarget).direction === "rtl" ? "rtl" : "ltr";
    if (event.key === (direction === "rtl" ? "ArrowRight" : "ArrowLeft") && onCloseKey) {
      event.preventDefault();
      onCloseKey();
      return;
    }
    if (event.key === "Tab") {
      // Let Tab move on from where the menu was, then close.
      level.closeAll();
      return;
    }
    const move = moveForKey(event.key, "vertical", direction);
    if (move === null) return;
    event.preventDefault();
    const items = enabledItems(level);
    const current = items.find((item) => item === document.activeElement) ?? null;
    // A key held down runs to the end and stops there: only a fresh press comes round.
    const target = step(items, current, move, { loop: !event.repeat });
    if (target !== null) focusItem(target);
  };
}

/** What the surface of a level has, whichever menu it is a level of. */
function useLevelSurface(level: MenuLevel) {
  const inset = useSyncExternalStore(level.checkable.subscribe, level.checkable.any, () => false);
  return {
    "data-inset": inset ? "" : undefined,
    onPointerMoveCapture: (event: PointerEvent<HTMLElement>) => trackHeading(level, event),
    // A menu has no context menu of its own — but a link in one keeps the browser's, which opens it elsewhere.
    onContextMenu(event: MouseEvent<HTMLElement>) {
      if ((event.target as Element).closest("a[href]") === null) event.preventDefault();
    },
  } as const;
}

export type MenuContentProps = ComponentProps<"div">;

export function MenuContent({
  className,
  ref,
  style,
  onKeyDown,
  children,
  ...props
}: MenuContentProps): ReactElement {
  const { overlay, level, triggerId, context, messages } = required(use(RootContext), "MenuContent");
  const handleKeyDown = useLevelKeyDown(level);
  const surface = useLevelSurface(level);
  const named = props["aria-label"] !== undefined || props["aria-labelledby"] !== undefined;
  return (
    <LevelContext value={level}>
      {context && <span {...overlay.anchorProps} />}
      <div
        // Named by its control; with none, as a context menu has none, in words.
        aria-labelledby={named || context ? undefined : triggerId}
        aria-label={named || !context ? undefined : messages.contextMenu}
        {...overlay.contentProps}
        role="menu"
        tabIndex={-1}
        {...mergeProps(surface, props)}
        ref={mergeRefs(overlay.contentProps.ref, level.contentRef as RefObject<HTMLDivElement | null>, ref)}
        style={{ ...overlay.contentProps.style, ...style }}
        onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
        className={cn(SURFACE, className)}
      >
        {children}
      </div>
    </LevelContext>
  );
}

export type ContextMenuProps = {
  onOpenChange?: ((open: boolean) => void) | undefined;
  messages?: Partial<MenuMessages> | undefined;
  children?: ReactNode;
};

/**
 * A menu for what is under the pointer: the same menu, opened at a point rather
 * than from a control. `ContextMenuTrigger` is the region it serves, and
 * `MenuContent`, with the same items as any menu, is the menu.
 */
export function ContextMenu({ onOpenChange, messages, children }: ContextMenuProps): ReactElement {
  const overlay = useOverlay({ onOpenChange, placement: "block-end", focusable: menuItems, modal: true });
  const root = useMenuRoot(overlay, true, messages);
  return <RootContext value={root}>{children}</RootContext>;
}

export type ContextMenuTriggerProps = ComponentProps<"div"> & {
  /** Leave the browser's own context menu in place. */
  disabled?: boolean | undefined;
};

/**
 * Whether a press began on something that is held in order to be dragged — a
 * handle, a grip: marked `data-drag-handle`. A finger resting there has not
 * asked for a menu; it has not moved yet.
 */
function heldToDrag(target: EventTarget): boolean {
  return target instanceof Element && target.closest("[data-drag-handle]") !== null;
}

interface Rest {
  readonly pointerId: number;
  readonly x: number;
  readonly y: number;
  readonly timer: ReturnType<typeof setTimeout>;
}

export function ContextMenuTrigger({
  disabled = false,
  className,
  onContextMenu,
  onTouchEnd,
  ...props
}: ContextMenuTriggerProps): ReactElement {
  const { overlay } = required(use(RootContext), "ContextMenuTrigger");
  // Whether the last thing done here was done with a pointer: a context menu
  // asked for after a key was asked for by the keyboard.
  const byPointer = useRef(false);
  const rest = useRef<Rest>(undefined);
  /** Whether this touch has already opened the menu by resting. */
  const rested = useRef(false);

  const openAt = useRef(overlay.openAt);
  useEffect(() => {
    openAt.current = overlay.openAt;
  });

  function forget(): void {
    clearTimeout(rest.current?.timer);
    rest.current = undefined;
  }
  useEffect(() => () => clearTimeout(rest.current?.timer), []);

  return (
    <div
      data-open={overlay.open ? "" : undefined}
      {...props}
      onPointerDownCapture={(event) => {
        props.onPointerDownCapture?.(event);
        byPointer.current = true;
        rested.current = false;
        forget();
        // A finger or a pen: a mouse has a button for this.
        if (disabled || event.pointerType === "mouse" || !event.isPrimary || heldToDrag(event.target)) return;
        // Where a long press brings no `contextmenu` event — iOS — the rest is timed here.
        const { clientX: x, clientY: y, pointerId } = event;
        const timer = setTimeout(() => {
          rest.current = undefined;
          rested.current = true;
          openAt.current({ x, y }, "content");
        }, LONG_PRESS);
        rest.current = { pointerId, x, y, timer };
      }}
      onPointerMoveCapture={(event) => {
        props.onPointerMoveCapture?.(event);
        const resting = rest.current;
        if (resting === undefined || resting.pointerId !== event.pointerId) return;
        if (Math.hypot(event.clientX - resting.x, event.clientY - resting.y) > LONG_PRESS_TOLERANCE) forget();
      }}
      onPointerUpCapture={(event) => {
        props.onPointerUpCapture?.(event);
        forget();
      }}
      onPointerCancelCapture={(event) => {
        props.onPointerCancelCapture?.(event);
        forget();
      }}
      onKeyDownCapture={(event) => {
        props.onKeyDownCapture?.(event);
        byPointer.current = false;
      }}
      // The finger that rested until the menu opened does not also tap what it rested on.
      onTouchEnd={(event) => {
        onTouchEnd?.(event);
        if (rested.current && event.cancelable) event.preventDefault();
      }}
      // A secondary click, Control-click on a Mac, the Menu key or Shift+F10, a
      // long press on Android: each arrives as this one event.
      onContextMenu={(event) => {
        onContextMenu?.(event);
        if (event.defaultPrevented || disabled) return;
        // The browser's own menu gives way only when this one opens.
        event.preventDefault();
        forget();
        if (rested.current || (byPointer.current && heldToDrag(event.target))) return;
        if (byPointer.current) {
          overlay.openAt({ x: event.clientX, y: event.clientY }, "content");
          return;
        }
        // Asked for by the keyboard, it belongs to what has focus, and opens against it.
        const focused = event.currentTarget.ownerDocument.activeElement;
        if (focused !== null && focused !== event.currentTarget && event.currentTarget.contains(focused)) {
          const box = focused.getBoundingClientRect();
          overlay.openAt({ x: box.left, y: box.top, width: box.width, height: box.height }, "first");
        } else {
          overlay.openAt({ x: event.clientX, y: event.clientY }, "first");
        }
      }}
      // The callout iOS shows for a long press would cover the menu.
      className={cn("[-webkit-touch-callout:none]", className)}
    />
  );
}

interface ItemOptions {
  readonly disabled: boolean;
  readonly textValue: string | undefined;
  /** What the item does when chosen, and whether a finger chose it. Returns whether the menu should close. */
  readonly onActivate: (touch: boolean) => boolean;
  /** Whether it is a link, which is followed by the click it is given and by nothing else. */
  readonly link?: boolean;
  /** Whether it can be checked, and so has a mark before its label. */
  readonly checkable?: boolean;
  /** Keys this item answers besides Enter and Space. */
  readonly onKeyDown?: ((event: KeyboardEvent<HTMLElement>) => boolean) | undefined;
  readonly onPointerEnter?: (() => void) | undefined;
  readonly onPointerLeave?: ((event: PointerEvent<HTMLElement>) => void) | undefined;
}

/** What every kind of item shares: membership of its level, focus following the pointer, activation. */
function useItem(
  key: string,
  {
    disabled,
    textValue,
    onActivate,
    link = false,
    checkable = false,
    onKeyDown,
    onPointerEnter,
    onPointerLeave,
  }: ItemOptions,
) {
  const level = required(use(LevelContext), "A menu item");
  const pointerType = useRef("");
  useLayoutEffect(() => (checkable ? level.checkable.add() : undefined), [checkable, level]);

  function activate(touch = false): void {
    if (disabled) return;
    if (onActivate(touch)) level.closeAll();
  }

  return {
    ...collectionItemProps(level.scope, key, { disabled, textValue }),
    tabIndex: -1,
    "aria-disabled": disabled || undefined,
    onPointerMove(event: PointerEvent<HTMLElement>) {
      // A finger has no hover: only a pointer that can rest over an item moves the highlight.
      // Nor does a menu that scrolls under a resting pointer: that move is not the pointer's.
      if (event.pointerType === "touch" || !pointerMoved() || isAiming(level, event)) return;
      level.closeSubmenus(key);
      onPointerEnter?.();
      if (!disabled && document.activeElement !== event.currentTarget) {
        event.currentTarget.focus({ preventScroll: true });
      }
    },
    onPointerLeave(event: PointerEvent<HTMLElement>) {
      onPointerLeave?.(event);
      // A press that left came to nothing here. A finger leaves as it lifts, before its click.
      if (event.pointerType === "mouse") pointerType.current = "";
      // The highlight follows the pointer, and leaves with it.
      if (event.pointerType !== "touch" && document.activeElement === event.currentTarget) {
        level.contentRef.current?.focus({ preventScroll: true });
      }
    },
    onPointerDown(event: PointerEvent<HTMLElement>) {
      pointerType.current = event.pointerType;
    },
    // A mouse chooses on release — including a release at the end of a drag that
    // began on the trigger. Touch chooses on click, so a scroll does not choose.
    onPointerUp(event: PointerEvent<HTMLElement>) {
      if (event.pointerType !== "mouse" || event.button !== 0) return;
      if (!link) activate();
      // A press that began elsewhere brings a link no click of its own.
      else if (pointerType.current !== "mouse" && !disabled) event.currentTarget.click();
    },
    onClick(event: MouseEvent<HTMLElement>) {
      const pressed = pointerType.current;
      pointerType.current = "";
      if (link) {
        // The click is what follows the link, so it is also what chooses it.
        if (disabled) event.preventDefault();
        else activate(pressed === "touch");
        return;
      }
      // A click that no press came before is a key's, or assistive technology's.
      if (event.detail === 0 || (pressed !== "mouse" && pressed !== "")) activate(pressed === "touch");
    },
    onKeyDown(event: KeyboardEvent<HTMLElement>) {
      if (onKeyDown?.(event)) {
        event.preventDefault();
        return;
      }
      // Space is the level's to judge: it may be part of a word being typed.
      if (event.key === "Enter") {
        event.preventDefault();
        if (event.repeat) return;
        if (link) event.currentTarget.click();
        else activate();
      }
    },
  };
}

const ITEM = cn(
  "group/item relative flex min-h-8 cursor-default items-center gap-2 rounded-md py-1 ps-(--menu-inset) pe-2 outline-none select-none",
  "[-webkit-tap-highlight-color:transparent]",
  "focus:bg-selected focus:text-on-selected",
  // Where a system forces its own colours, the highlight is the system's.
  "forced-colors:forced-color-adjust-none forced-colors:focus:bg-[Highlight] forced-colors:focus:text-[HighlightText]",
  "data-disabled:text-fg-disabled forced-colors:data-disabled:text-[GrayText]",
  "pointer-coarse:min-h-11 pointer-coarse:text-base",
);

const MODIFIERS = ["Ctrl", "Alt", "Shift", "Mod"] as const;
type Modifier = (typeof MODIFIERS)[number];
const SPELLED: readonly Modifier[] = ["Mod", "Ctrl", "Alt", "Shift"];

const ALIASES: Readonly<Record<string, Modifier>> = {
  ctrl: "Ctrl",
  control: "Ctrl",
  alt: "Alt",
  option: "Alt",
  shift: "Shift",
  mod: "Mod",
};

const APPLE_MODIFIERS: Readonly<Record<Modifier, string>> = { Ctrl: "⌃", Alt: "⌥", Shift: "⇧", Mod: "⌘" };

const KEY_GLYPHS: Readonly<Record<string, string>> = {
  ArrowUp: "↑",
  ArrowDown: "↓",
  ArrowLeft: "←",
  ArrowRight: "→",
};

const APPLE_KEYS: Readonly<Record<string, string>> = {
  ...KEY_GLYPHS,
  // U+FE0E asks for the text form: alone, the arrow is drawn as a coloured emoji.
  Enter: "↩\uFE0E",
  Backspace: "⌫",
  Delete: "⌦",
  Escape: "⎋",
  Tab: "⇥",
};

const OTHER_KEYS: Readonly<Record<string, string>> = { ...KEY_GLYPHS, Escape: "Esc", Delete: "Del" };

/**
 * One notation for a shortcut — `Mod+Shift+N`, where `Mod` is the platform's
 * command key — read into what each platform shows and what is told to
 * assistive technology.
 */
function readShortcut(keys: string, apple: boolean): { shown: string; spoken: string } {
  const parts = keys.split("+");
  // A shortcut on the plus key ends in two.
  const key = parts.pop() || "+";
  const held = new Set(parts.map((part) => ALIASES[part.trim().toLowerCase()]));
  const name = key.length === 1 ? key.toUpperCase() : key;
  // The command key leads where it is spelled out; Apple writes it last, against the key.
  const spelled = [
    ...new Set(
      SPELLED.filter((modifier) => held.has(modifier)).map((modifier) =>
        modifier === "Mod" && !apple ? "Ctrl" : modifier,
      ),
    ),
  ];
  const spoken = [
    ...spelled.map((modifier) => (modifier === "Mod" ? "Meta" : modifier === "Ctrl" ? "Control" : modifier)),
    name === " " ? "Space" : name,
  ].join("+");
  const shown = apple
    ? MODIFIERS.filter((modifier) => held.has(modifier))
        .map((modifier) => APPLE_MODIFIERS[modifier])
        .join("") + (APPLE_KEYS[name] ?? name)
    : [...spelled, OTHER_KEYS[name] ?? name].join("+");
  return { shown, spoken };
}

function subscribeToNothing(): () => void {
  return () => {};
}

/** A server cannot know the platform, so it writes the other's, and the reader's own takes over. */
function useApplePlatform(): boolean {
  return useSyncExternalStore(subscribeToNothing, isApplePlatform, () => false);
}

function useShortcut(keys: string | undefined) {
  const apple = useApplePlatform();
  return keys === undefined ? undefined : readShortcut(keys, apple);
}

export type MenuShortcutProps = Omit<ComponentProps<"span">, "children"> & {
  /** The shortcut in one notation for every platform: `Mod+Shift+N`. */
  keys: string;
};

/**
 * A shortcut as its platform writes it — ⇧⌘N, Ctrl+Shift+N — after an item's
 * label. Seen and not spoken: the item tells assistive technology its shortcut
 * itself. An item given `shortcut` shows one.
 */
export function MenuShortcut({ keys, className, ...props }: MenuShortcutProps): ReactElement {
  const shortcut = useShortcut(keys);
  return (
    <span
      aria-hidden
      className={cn(
        "ms-auto ps-6 text-xs tracking-wide text-fg-muted group-focus/item:text-current group-data-disabled/item:text-current",
        // A keyboard shortcut means nothing to a finger.
        "pointer-coarse:hidden",
        className,
      )}
      {...props}
    >
      {shortcut?.shown}
    </span>
  );
}

/** A choice: an event whose default is to close the menu. */
export type MenuSelectEvent = Event;

export type MenuItemProps = Omit<ComponentProps<"div">, "onSelect"> & {
  disabled?: boolean;
  textValue?: string;
  /** Called when the item is chosen; `preventDefault()` keeps the menu open. */
  onSelect?: (event: MenuSelectEvent) => void;
  /** The key that does the same from anywhere, as `Mod+Shift+N`: shown after the label and told to assistive technology. */
  shortcut?: string;
  /** Where the item leads: with one it is a link, which the browser can also open elsewhere. */
  href?: string;
  target?: string;
  rel?: string;
};

function select(onSelect: ((event: MenuSelectEvent) => void) | undefined): boolean {
  const event = new Event("menuselect", { cancelable: true });
  onSelect?.(event);
  return !event.defaultPrevented;
}

export function MenuItem({
  disabled = false,
  textValue,
  onSelect,
  shortcut,
  href,
  target,
  rel,
  className,
  children,
  ...props
}: MenuItemProps): ReactElement {
  const key = useId();
  const link = href !== undefined;
  const item = useItem(key, { disabled, textValue, link, onActivate: () => select(onSelect) });
  const keys = useShortcut(shortcut);
  const content = (
    <>
      {children}
      {shortcut !== undefined && <MenuShortcut keys={shortcut} />}
    </>
  );
  const merged = {
    ...mergeProps(item, props),
    "aria-keyshortcuts": keys?.spoken,
    className: cn(ITEM, className),
  };
  if (!link) {
    return (
      <div role="menuitem" {...merged}>
        {content}
      </div>
    );
  }
  // An anchor, so that it is the browser's own link: opened in a new tab, copied, dragged.
  const { ref, ...anchor } = merged as typeof merged & { ref?: Ref<HTMLElement> };
  return (
    <a
      role="menuitem"
      // A link that cannot be used leads nowhere.
      href={disabled ? undefined : href}
      target={target}
      rel={rel}
      ref={ref as Ref<HTMLAnchorElement>}
      {...(anchor as ComponentProps<"a">)}
    >
      {content}
    </a>
  );
}

function Indicator({ type, checked }: { type: "checkbox" | "radio"; checked: boolean }) {
  return (
    <span aria-hidden className="absolute start-2 flex size-4 items-center justify-center">
      {checked &&
        (type === "checkbox" ? (
          <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M3.5 8.5 6.5 11.5 12.5 4.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        ) : (
          <span className="size-1.5 rounded-full bg-current" />
        ))}
    </span>
  );
}

type CheckableItemProps = Omit<MenuItemProps, "children" | "href" | "target" | "rel">;

export type MenuCheckboxItemProps = CheckableItemProps & {
  checked?: boolean;
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  children?: ReactNode;
};

export function MenuCheckboxItem({
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  disabled = false,
  textValue,
  onSelect,
  shortcut,
  className,
  children,
  ...props
}: MenuCheckboxItemProps): ReactElement {
  const key = useId();
  const [checked, setChecked] = useControllableState(checkedProp, defaultChecked, onCheckedChange);
  const item = useItem(key, {
    disabled,
    textValue,
    checkable: true,
    onActivate() {
      setChecked(!checked);
      return select(onSelect);
    },
  });
  const keys = useShortcut(shortcut);
  return (
    <div
      role="menuitemcheckbox"
      aria-checked={checked}
      aria-keyshortcuts={keys?.spoken}
      {...mergeProps(item, props)}
      className={cn(ITEM, className)}
    >
      <Indicator type="checkbox" checked={checked} />
      {children}
      {shortcut !== undefined && <MenuShortcut keys={shortcut} />}
    </div>
  );
}

const RadioContext = createContext<{ value: string | null; setValue: (value: string) => void } | null>(null);

export type MenuRadioGroupProps = Omit<ComponentProps<"div">, "defaultValue"> & {
  value?: string | null;
  defaultValue?: string | null;
  onValueChange?: (value: string) => void;
  /** A heading for the group, which also names it. */
  label?: ReactNode;
};

export function MenuRadioGroup({
  value,
  defaultValue = null,
  onValueChange,
  label,
  children,
  ...props
}: MenuRadioGroupProps): ReactElement {
  const [current, setCurrent] = useControllableState<string | null>(
    value,
    defaultValue,
    onValueChange as ((value: string | null) => void) | undefined,
  );
  const radio = useMemo(() => ({ value: current, setValue: setCurrent }), [current, setCurrent]);
  return (
    <RadioContext value={radio}>
      <MenuGroup label={label} {...props}>
        {children}
      </MenuGroup>
    </RadioContext>
  );
}

export type MenuRadioItemProps = CheckableItemProps & { value: string; children?: ReactNode };

export function MenuRadioItem({
  value,
  disabled = false,
  textValue,
  onSelect,
  shortcut,
  className,
  children,
  ...props
}: MenuRadioItemProps): ReactElement {
  const key = useId();
  const radio = required(use(RadioContext), "MenuRadioItem");
  const checked = radio.value === value;
  const item = useItem(key, {
    disabled,
    textValue,
    checkable: true,
    onActivate() {
      radio.setValue(value);
      return select(onSelect);
    },
  });
  const keys = useShortcut(shortcut);
  return (
    <div
      role="menuitemradio"
      aria-checked={checked}
      aria-keyshortcuts={keys?.spoken}
      {...mergeProps(item, props)}
      className={cn(ITEM, className)}
    >
      <Indicator type="radio" checked={checked} />
      {children}
      {shortcut !== undefined && <MenuShortcut keys={shortcut} />}
    </div>
  );
}

export type MenuGroupProps = Omit<ComponentProps<"div">, "children"> & {
  label?: ReactNode;
  children?: ReactNode;
};

export function MenuGroup({ label, className, children, ...props }: MenuGroupProps): ReactElement {
  const labelId = useId();
  return (
    <div
      role="group"
      aria-labelledby={label === undefined ? undefined : labelId}
      className={cn("flex flex-col", className)}
      {...props}
    >
      {label !== undefined && (
        <div id={labelId} role="presentation" className="px-2 pt-2 pb-1 text-xs font-medium text-fg-muted">
          {label}
        </div>
      )}
      {children}
    </div>
  );
}

export function MenuSeparator({ className, ...props }: ComponentProps<"div">): ReactElement {
  return (
    <div
      role="separator"
      className={cn("mx-2 my-1 h-px bg-border-muted forced-colors:bg-[GrayText]", className)}
      {...props}
    />
  );
}

export type MenuSubProps = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children?: ReactNode;
};

export function MenuSub({ open, defaultOpen, onOpenChange, children }: MenuSubProps): ReactElement {
  const parent = required(use(LevelContext), "MenuSub");
  const triggerKey = useId();
  const overlay = useOverlay({
    open,
    defaultOpen,
    onOpenChange,
    placement: "inline-end",
    focusable: menuItems,
    // On a phone a submenu is the same sheet turned to another level, not a second sheet.
    replaces: true,
  });
  const level = useLevel(parent.closeAll);
  const { setOpen } = overlay;
  useEffect(
    () => parent.registerSubmenu(triggerKey, () => setOpen(false, "none")),
    [parent, triggerKey, setOpen],
  );
  const [title, setTitle] = useState("");
  useLayoutEffect(() => {
    if (!overlay.open) return;
    const item = document.getElementById(triggerKey);
    // oxlint-disable-next-line react/set-state-in-effect -- read from the item once the submenu is to be shown
    setTitle(item?.dataset.textValue ?? item?.textContent ?? "");
  }, [overlay.open, triggerKey]);
  const sub = useMemo(() => ({ overlay, level, triggerKey, title }), [overlay, level, triggerKey, title]);
  return <SubContext value={sub}>{children}</SubContext>;
}

export type MenuSubTriggerProps = Omit<ComponentProps<"div">, "onSelect"> & {
  disabled?: boolean;
  textValue?: string;
};

export function MenuSubTrigger({
  disabled = false,
  textValue,
  className,
  ref,
  style,
  children,
  ...props
}: MenuSubTriggerProps): ReactElement {
  const { overlay, triggerKey } = required(use(SubContext), "MenuSubTrigger");
  const parent = required(use(LevelContext), "MenuSubTrigger");
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  const grace = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(
    () => () => {
      clearTimeout(timer.current);
      clearTimeout(grace.current);
    },
    [],
  );

  const item = useItem(triggerKey, {
    disabled,
    textValue,
    onActivate(touch) {
      // A finger highlights nothing: the level it opened has no item it is on.
      overlay.setOpen(true, touch ? "content" : "first");
      return false;
    },
    onKeyDown(event) {
      const direction = getComputedStyle(event.currentTarget).direction === "rtl" ? "rtl" : "ltr";
      if (event.key !== (direction === "rtl" ? "ArrowLeft" : "ArrowRight") || disabled) return false;
      overlay.setOpen(true, "first");
      return true;
    },
    onPointerEnter() {
      if (disabled || overlay.open || timer.current !== undefined) return;
      timer.current = setTimeout(() => {
        timer.current = undefined;
        // Not into a menu that has closed in the meantime.
        if (parent.contentRef.current?.matches(":popover-open")) overlay.setOpen(true, "none");
      }, SUBMENU_DELAY);
    },
    onPointerLeave(event) {
      clearTimeout(timer.current);
      timer.current = undefined;
      const content = document.getElementById(overlay.contentId);
      if (!overlay.open || content === null || event.pointerType === "touch") return;
      // From where the pointer was on this item — leaving is heard before the
      // move that left — fanning out to the near edge of the submenu: across
      // this, a pointer heading that way is on its way in.
      const from = parent.position.current ?? { x: event.clientX, y: event.clientY };
      const box = content.getBoundingClientRect();
      const toward = box.left + box.width / 2 > from.x ? 1 : -1;
      const near = toward === 1 ? box.left : box.right;
      parent.aim.current = {
        toward,
        area: [
          { x: from.x - toward * 4, y: from.y },
          { x: near, y: box.top - 8 },
          { x: near, y: box.bottom + 8 },
        ],
      };
      clearTimeout(grace.current);
      grace.current = setTimeout(() => {
        parent.aim.current = null;
      }, AIM_GRACE);
    },
  });

  return (
    <div
      role="menuitem"
      id={triggerKey}
      aria-haspopup="menu"
      {...mergeProps(item, props)}
      {...overlay.triggerProps}
      ref={mergeRefs(overlay.triggerProps.ref, ref)}
      style={{ ...overlay.triggerProps.style, ...style }}
      className={cn(ITEM, "aria-expanded:not-focus:bg-surface-strong", className)}
    >
      {children}
      <Chevron className="ms-auto" />
    </div>
  );
}

function Chevron({ className }: { className?: string }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 16 16"
      className={cn("size-3.5 shrink-0 rtl:-scale-x-100", className)}
      fill="none"
      stroke="currentColor"
      strokeWidth="1.75"
    >
      <path d="m6 3.5 4.5 4.5L6 12.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/** The way back to the menu a submenu has taken the place of: an item, so every way of choosing one works on it. */
function MenuBack({ onBack, children }: { onBack: () => void; children: ReactNode }) {
  const key = useId();
  const item = useItem(key, {
    disabled: false,
    textValue: undefined,
    onActivate() {
      onBack();
      return false;
    },
  });
  return (
    <div role="menuitem" data-back="" {...item} className={cn(ITEM, "justify-self-start gap-1 ps-1 pe-3")}>
      <Chevron className="-scale-x-100 rtl:scale-x-100" />
      {children}
    </div>
  );
}

export function MenuSubContent({
  className,
  ref,
  style,
  onKeyDown,
  children,
  ...props
}: MenuContentProps): ReactElement {
  const { overlay, level, triggerKey, title } = required(use(SubContext), "MenuSubContent");
  const { messages } = required(use(RootContext), "MenuSubContent");
  const handleKeyDown = useLevelKeyDown(level, () => overlay.setOpen(false));
  const surface = useLevelSurface(level);
  // Where a submenu takes its menu's place there is no menu beside it to go back to by pointing.
  const sheet = useSheet();
  return (
    <LevelContext value={level}>
      <div
        aria-labelledby={triggerKey}
        {...overlay.contentProps}
        role="menu"
        tabIndex={-1}
        {...mergeProps(surface, props)}
        ref={mergeRefs(overlay.contentProps.ref, level.contentRef as RefObject<HTMLDivElement | null>, ref)}
        style={{ ...overlay.contentProps.style, ...style }}
        onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
        className={cn(SURFACE, "-mt-1 ms-0.5 pointer-coarse:max-sm:ms-0", className)}
      >
        {sheet && (
          <div className="mb-1 grid grid-cols-[1fr_auto_1fr] items-center border-b border-border-muted pb-1">
            <MenuBack onBack={() => overlay.setOpen(false)}>{messages.back}</MenuBack>
            {/* Seen only: the submenu is already named by its item. */}
            <span aria-hidden className="truncate px-2 text-base font-medium text-fg-strong">
              {title}
            </span>
          </div>
        )}
        {children}
      </div>
    </LevelContext>
  );
}