import { Fragment, useState, type ReactNode } from "react"; import { ChevronRight, MoreHorizontal } from "lucide-react"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks"; // A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions. // Built on the R8 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`); a `disabled` item (e.g. a mutation already in flight) is inert. // // E10 S2 split the ITEM LIST 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 = "glass-menu min-w-52 rounded-xl p-1.5 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 — on a disabled row only — the reason it is off. */ function RowLabel({ item }: { item: MenuItemBase }) { return ( {item.label} {item.disabled && 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 (E10 review rounds 1–2: 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. */ 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; 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 && (