import { Fragment, useState, type ReactNode } from "react"; import { ChevronRight, MoreHorizontal } from "lucide-react"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks"; import { IconButton } from "./IconButton"; import { MenuCheck } from "./MenuCheck"; import { Pressable } from "./Pressable"; import { SURFACE } from "./surfaces"; // A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions. // Built on the AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher // and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are // fire-and-close (`onSelect`) — all but a `check: { kind: "many" }` row, which toggles in place; a // `disabled` item (e.g. a mutation already in flight) is inert. // // The ITEM LIST is split out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click // context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point // of the split: a context menu that drifts from the overflow menu beside it is two menus, and the // user has to learn both. /** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and * `ContextMenu`. One constant so a context menu can never look like a different component. */ export const MENU_SURFACE = `${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`; // No `disabled:opacity-50` on the row itself: a disabled row may carry a `note`, and opacity on the // row would dim the one line that explains it. The icon and the label dim instead (`dim`). const ROW = "flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition"; const dim = (disabled: boolean | undefined) => (disabled ? " opacity-50" : ""); /** The label, and under it the row's note: why it is off, or the state of what it opens. */ function RowLabel({ item }: { item: MenuItemBase }) { return ( {item.label} {item.note && {item.note}} ); } interface MenuItemBase { id: string; label: string; icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending) disabled?: boolean; /** Optional native tooltip (carries a longer hint the label alone can't) — for an ENABLED row. */ title?: string; /** Why a DISABLED row is off, shown as a line under its label. Not a `title`: a disabled button * fires no pointer events (no tooltip) and roving focus skips it, so a reason there reached * nobody. An `aria-disabled` row was tried and reverted (floating-ui skips * it identically, and the mouse got a highlight that then no-opped). Visible text is the one * place a disabled row's reason can be: seen, and read in a screen reader's browse mode. * On an ENABLED row it is the state of what the row opens ("Downloaded" under "Download…"), * for the same reason: a `title` shows to a resting mouse only. */ note?: string; /** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */ separatorBefore?: boolean; } interface ActionItem extends MenuItemBase { onSelect: () => void; /** Destructive action: painted in the danger colour, like the outline-danger buttons. */ danger?: boolean; /** Run INSIDE the click, then close — for a clipboard write, which Safari allows only within the * user's gesture (a task later, as `closeThenAct` runs every other row, it is refused). Only for * an action that opens nothing and moves no focus: that is what the deferral exists for. */ inGesture?: boolean; /** A checkable row, its state shown in the icon slot. `many`: one of a set that can all be on (a * channel's tags) — a menuitemcheckbox that toggles IN PLACE: the menu stays open, so the next * tag is one click away and the check just set is seen. `one`: one choice of several (a priority * step) — a menuitemradio that picks and closes, like any other row. */ check?: { kind: "many" | "one"; checked: boolean }; items?: never; } interface SubmenuItem extends MenuItemBase { /** Non-empty → this row opens a submenu instead of firing. */ items: readonly ActionMenuItem[]; onSelect?: never; } // A discriminated union rather than "onSelect and items are both optional": the invariant is // "a row either fires or it opens a submenu, never both and never neither", and this codebase's // house rule is that such a rule lives in the type, not in a comment above it. export type ActionMenuItem = ActionItem | SubmenuItem; const isSubmenu = (it: ActionMenuItem): it is SubmenuItem => Array.isArray(it.items); /** The rows of a menu: leaf actions, submenus and separators, from one item array. Rendered inside a * `PopoverSurface` (which supplies the list registry) — see `ActionMenu` and `ContextMenu`. * `onClose` closes the ROOT menu, so selecting deep inside a submenu dismisses the whole stack. */ export function MenuItemList({ items, onClose, }: { items: readonly ActionMenuItem[]; onClose: () => void; }) { return ( <> {items.map((it, i) => ( {it.separatorBefore && i > 0 && (