import { Fragment, useState, type ReactNode } from "react"; import { ChevronRight, MoreHorizontal } from "lucide-react"; import { PopoverItem, PopoverSurface, closeThenAct, useAnchoredPopover } from "./AnchoredPopover"; // 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]"; const ROW = "flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"; 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). * * KNOWN GAP, deliberately still open: on a `disabled` row this reaches nobody — a disabled button * fires no pointer events, so no tooltip, and floating-ui's roving focus skips it. E10 review * round 1 tried an `aria-disabled` "inert" row to fix that; round 2 found `useListNavigation` * skips `aria-disabled` identically (`isListIndexDisabled`), which AnchoredPopover's own comment * already said — so the swap fixed nothing and made the mouse worse (the row took the active * highlight, then no-opped on click). Reverted. A real fix has to put the reason somewhere a * disabled row is not: a visible hint line in the menu, or an `aria-describedby` note. */ title?: 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 && (