import { createContext, useContext, type ComponentPropsWithRef, type ReactElement, type ReactNode, type RefObject } from 'react' import { IconChevronRight } from '@tabler/icons-react' import { Menu as BaseMenu } from '@base-ui/react/menu' import { cn } from './cn' import { Divider, DividerInPanel } from './Divider' import { triggerDisabled } from './triggerDisabled' import { ScrollArea } from './ScrollArea' import { Kbd } from './Kbd' import { SectionLabel } from './SectionLabel' import { FLOATING_SURFACE_CLASSES } from './looks' /** * THE menu shell (2026-09-01) — extracted once, for every menu in every app. * On Base UI's `Menu` since stage 4 of the migration (2026-09-07). * * Peek has no single Menu file; its topic menu, conversation menus, files * menu and the top bar's account menu each hand-roll the same thing, and * they disagree: some close on Escape, most do not, and row heights and * minimum widths drift copy by copy. Ship extracted the shape so it would * not become the next copy; the package exists so nobody becomes the one * after that. * * What every menu shares, kept exactly: an elevated container with a * hairline border, 8px radius, 8px padding and the large shadow; items that * are 8px-radius rows, `px-2 py-1.5` and never shorter than 36px, hover * fill, 14px text, destructive ones in the error colour; section headings * as a SectionLabel in a 32px row; and the two exits every menu has — Escape and a click outside. * * **What stage 4 changed, and it is the first thing a keyboard user notices: * the arrow keys walk the rows.** ↑ and ↓ move between items and wrap, Home * and End jump to the ends, typing a row's first letters jumps to it, and * focus moves into the menu when it opens and back out when it closes. None * of that existed; a menu was a portalled box of buttons you tabbed through. * * Deleted with the port: the `createPortal`, the `mousedown` listener on * `document`, the Escape listener beside it, the resize and scroll listeners, * the provisional hidden render this component did in order to measure * itself, and `fit.ts` — the shared geometry the shell and Select both used. * Floating UI places, flips and clamps all of it. * * The three anchorings are unchanged, and all three now portal (the third * did not, which is exactly how the identity menu got covered by a z-indexed * panel header in the first place): * - `anchor` (an element, or a rect a click handler measured): the menu hangs * under it, flips above when the room below is worse, and stays on screen. * `align="right"` hangs the menu's right edge from the anchor's. * - `position` (viewport coordinates the caller computed): the corner the * caller chose, as a virtual anchor, so it still cannot land off screen. * - neither: under the element the menu sits in, right-aligned — what * `absolute right-0 top-full mt-1` drew, but portalled, so no ancestor's * stacking context or overflow can clip it. */ /** * Whether a row is inside a `Menu`. * * It is load-bearing: `MenuItem` and `MenuSection` are also used * inside a bare `MenuPanel`, with no `Menu` around them — Peek's `@`, `/` and * `[` pickers and its compose menu all do that, because a popup inside a text * editor cannot have a menu's keyboard: the editor's suggestion plugin already * owns it. A Base UI `Menu.Item` outside a `Menu.Root` has no context to read, * so those rows stay exactly the buttons they have always been, and only rows * inside a real menu become the part. */ const MenuContext = createContext<{ openOnHover: boolean } | null>(null) /** * The menu's surface, with none of its behaviour — an elevated box with a * hairline border, 8px radius, 8px padding and the large shadow. * * Split out of `Menu` on 2026-09-05. `Menu` owns Escape, outside-click, * placement and now the keyboard, and that is right for a menu opened from a * trigger — but a type-ahead popup inside a text editor cannot have them: the * editor's suggestion plugin already owns the keyboard and positions the * popup, and a second Escape handler fights it. So Peek's `@`, `/` and `[` * menus each drew this box by hand, and the three had already drifted apart. * * `Menu` renders this, so there is still exactly one definition of the * surface — change it here and every menu in every app follows. * * Width, height and internal rhythm belong to the caller: a picker that lists * people is not the width of one that lists verbs. */ export interface MenuPanelProps extends Omit, 'children'> { /** Optional only because Base UI fills it in when this is a `render` * target: `Menu.Popup` supplies the children. Every other caller passes * them. */ children?: ReactNode } /** The menu's surface without the menu: the elevated box, the rows' padding, * and a divider that runs the full width of what it separates. `Menu` renders * its popup as one. On its own it is for the places that draw a menu's look * but not its behaviour — a docs page, Peek's `@`, `/` and `[` pickers. */ export function MenuPanel({ children, className, ...props }: MenuPanelProps) { return (
[role=separator]]:mx-0', className, )} {...props} > {children}
) } export interface MenuProps { /** * The control that opens the menu. Any element that forwards its ref and * spreads its props — this package's `Button`, `IconButton` and * `PersonTrigger` all do. * * The menu owns it, and that is the point: Base UI can only do the toggle, * the placement, the focus return, the first-item highlight and the whole * hover choreography if it knows which element opened it. */ trigger: ReactElement /** Which of the menu's edges hangs from the trigger's. Default left. */ align?: 'left' | 'right' /** * Open on hover, and close shortly after the pointer leaves — for a control * that only appears while the pointer is on a card. * * Base UI owns the whole choreography, including the diagonal from a row * out to a submenu panel. The shell used to hand-write this and got it * wrong in two directions. */ openOnHover?: boolean /** Controlled, for a caller that must know or must force it. Leave both * off and the menu keeps its own state. */ open?: boolean onOpenChange?: (open: boolean) => void /** Base UI's imperative handle. `actions.current?.close()` shuts the menu — * for a row that must close it before opening what it opens. A `MenuItem` * closes the menu by itself, so this is only for content that is not one. */ actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null> children: ReactNode /** On the menu's surface — its width. **Not its padding**: see * `contentClassName`. */ className?: string /** * The padding around the rows, as a class. Default `p-2`, 8px. * * It is on the scrolling content, not on the panel, so the scrollbar hugs the * panel's edge (D63) — and so a padding class on `className` adds to it * rather than replacing it. Peek's Later menu asked for `p-1` there and got * 12px from `0.12.6` on (PLAN Finding 60). Set it here. */ contentClassName?: string } /** * Read from `globalThis` rather than as a bare `process`: the declaration * build carries no ambient types on purpose, so that a consumer of this * package does not inherit Node's. Every bundler still replaces the value. */ const isProduction = () => (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV === 'production' /** The 4px between a menu and its trigger, and the 8px it keeps clear of * every screen edge — the two numbers `fit.ts` used. */ const GAP = 4 const VIEWPORT_PAD = 8 /** A hover menu opens at once and closes 150ms after the pointer leaves — * the two numbers the hand-written version used. */ const HOVER_OPEN_DELAY = 0 const HOVER_CLOSE_DELAY = 150 export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpenChange, actionsRef, children, className, contentClassName }: MenuProps) { return ( onOpenChange(next) : undefined} actionsRef={actionsRef} /* Non-modal, as every menu here has always been: the page behind keeps its scrollbar, so opening a menu never shifts the layout. */ modal={false} > } > {/* The height cap sits on the box that scrolls — on the panel it let the box grow to its content and nothing scrolled (measured, 2026-09-08). * * And **the padding is on the content, not on the panel** (D63, * 2026-09-13). With it on the panel the scrolling box was inset by * it, so the bar floated 9px in from the panel's edge where every * other scrolling surface in the suite draws it at 1px — * `DialogShell` had it right and said so in its own comment, and * this is the same arrangement. The rows do not move: the padding * that used to be the panel's is now the content's, at the same * 8px. The cap loses its `- 1rem` for the same reason — the * padding is inside the scrolling box now, so the panel is exactly * as tall as Floating UI allowed. * * A caller's `className` still lands on the panel, so a caller * asking for different padding needs `contentClassName`. That * sentence was written at 0.12.6 and the prop was not: a `p-1` on * `className` added 4px to these 8px instead of replacing them * (PLAN Finding 60). The prop exists now. */} {children} ) } /** * A row that opens another menu beside it — the submenu two Peek menus * hand-rolled before this, one of which dropped the ref its edge-flip * measured and shipped a submenu cut off by the screen (2026-09-03). * * Hover-timed as those were, and as ours was: it opens at once and closes * 150ms after the pointer leaves, so the diagonal from row to panel survives. * Those two numbers are all that is left of the old implementation — the * placement, the flip at a screen edge, and now the arrow keys (→ opens it, * ← closes it) are Base UI's. */ export interface MenuSubProps { /** The trigger row's label. */ label: string leading?: ReactNode /** Mark the trigger row as holding a current value. */ selected?: boolean /** The submenu's rows. */ children: ReactNode /** On the submenu panel. */ className?: string } /** A row that opens another menu beside it; its rows are this row's children. * Never inside a menu opened on hover — leaving the submenu strands both open, * measured below. */ export function MenuSub({ label, leading, selected, children, className }: MenuSubProps) { /* * A submenu inside a hover-opened menu strands it. * * Measured 2026-09-08: enter the submenu's panel, then leave in any * direction that does not cross back over the parent, and neither the * submenu nor the menu ever closes again — at 200ms, 500ms, 1s and 2s. * Both triggers hard-code Floating UI's `safePolygon({ blockPointerEvents: * true })`, which blocks pointer events while the path from row to panel is * live; leaving that way never resolves the polygon. There is no prop. * * So the rule is: no `openOnHover` on a menu that has one of these. This * says so out loud rather than leaving it to the page, because the failure * is a menu that will not go away and the cause is two files apart. */ const menu = useContext(MenuContext) if (!isProduction() && menu?.openOnHover) { console.error( '[@estiva-app/ui] Menu: `openOnHover` and `MenuSub` cannot be used together — ' + 'leaving the submenu panel strands the menu open. Open this menu on a press instead. ' + 'See Menu.mdx, "Hover-opened menus".', ) } return ( } className={menuItemClassName({ size: 'default', selected })} > }> {children} ) } export interface MenuItemProps extends Omit, 'children'> { label?: string /** Replaces the label/description block — for rows whose middle is richer * than text (the launcher's form rows). Leading/trailing still apply. */ children?: ReactNode /** `tall` is the picker row — content height with a 40px floor, px-3, * gap-3, room for a 32px face or icon tile and the description line. * `default` is the command row. */ size?: 'default' | 'tall' /** A second line under the label — a role, an address — 12px, secondary, truncating. */ description?: string /** * After the label, on its line — 12px, muted, never cut: where a file sits * (an issue's ref and project) when its second line is its summary (SUM-7). * The label truncates before it does. */ aside?: string /** Before the label: a 16px icon (stroke 1.5, secondary), or an Avatar, for rows led by a face. */ leading?: ReactNode /** At the right edge: a hint, a value — anything. Wins over `shortcut` and `submenu`. */ trailing?: ReactNode /** * Shown at the right edge **only while this row is the one you are pointing * at or have arrowed onto** — an `EnterHint`, typically. * * Pass it unconditionally. Do not do `trailing={active ? : undefined}`: * that mounts the hint, and a mount is instant while the row's own fill is a * 150ms fade, so the hint lands ahead of the highlight on the way in and * vanishes ahead of it on the way out (measured 2026-09-05 — ~7 frames of a * chip sitting on an unhighlighted row, and two rows lit at once when * sweeping). This slot is always in the DOM and revealed by the *same* * `:hover` / `selected` the fill uses, on the same duration and curve, so * the two cannot come apart — and the row does not reflow when it appears. * * It cross-fades with `trailing`/`shortcut`/`submenu` rather than displacing * them, and reserves the wider of the two, so nothing moves either way. */ hint?: ReactNode /** A keyboard hint, drawn as the kbd chip. */ shortcut?: string /** The row opens another menu: draws the chevron at the right edge. */ submenu?: boolean destructive?: boolean /** The row the menu currently points at (a submenu's chosen value). */ selected?: boolean } /** * The row's class list, written once because `MenuSub`'s trigger row is the * same row. * * `data-highlighted` joins `:hover` here, and that is the whole visible * consequence of the port: the fill that followed the pointer now also * follows the arrow keys, because Base UI sets one attribute for both. */ /** The row's look, shared inside the package: `MenuItem` draws it, and * `Select` puts it on Base UI's `Select.Item`, whose parts (`ItemText`, * `ItemIndicator`) have to stay the element's own. One row, two parts. Not * exported from the package's index. */ export function menuItemClassName({ size, selected, className }: { size: 'default' | 'tall'; selected?: boolean; className?: string }) { return cn( // shrink-0: a menu is a flex column that scrolls at its max height, // and a flex child shrinks before its container does — so every row // in an overflowing menu was squashed to its `min-h`, and a row given // an explicit height silently lost it (Peek's `[` menu: h-12 rows // measured 40px). The same fix NavItem took on 2026-09-02. // `group`: the `hint` slot reveals itself from this row's own :hover, // so the hint and the fill are one CSS state change, not two engines. // // No transition on the fill (Katerina, 2026-09-05). It faded over // 150ms, and anything appearing with it had to fade too or arrive // ahead of it — which, sweeping a pointer down a list, read as the // hint flickering in and out. Both are instant now: they still change // on exactly the same :hover, so they cannot come apart, and a row // lights and unlights crisply as the pointer crosses it. 'group flex w-full shrink-0 cursor-pointer items-center rounded-lg text-left outline-none hover:bg-bg-hover data-[highlighted]:bg-bg-hover', // Both sizes are as tall as their content and never shorter than a // floor: 36px for a default row (Katerina, 2026-09-11), 40px for a // tall one (Katerina, 2026-09-01). A single-line action row sits at // 36, a picker row at 40, a row with a 32px face and a role line // comes out at its natural 48. One rule, not a hand-picked height // per file — Peek's Later menu had been forcing `h-9` for exactly // this 36px and was the only menu in either app out of step. size === 'tall' ? 'min-h-10 gap-3 px-3 py-1.5' : 'min-h-9 gap-2 px-2 py-1.5', selected && 'bg-bg-hover', className, ) } /** Everything inside the row — written once, for the same reason. Shared * inside the package like `menuItemClassName`: `CommandPalette` draws it * inside Base UI's `Autocomplete.Item`. Not exported from the index. */ export function MenuItemBody({ label, children, size = 'default', description, aside, leading, trailing, hint, shortcut, submenu, destructive, selected }: Pick) { const edge = trailing ?? (shortcut ? ( {shortcut} ) : submenu ? ( ) : null) return ( <> {leading && {leading}} {/* Sizes are arbitrary values (the body-2 and caption tokens): these lists merge with a colour, and tw-merge drops a token size beside a colour. Ship's copy said `text-sm`, which was never the ramp. */} {children ?? ( {aside ? ( {label} {aside} ) : ( {label} )} {description && {description}} )} {(edge || hint) && ( // One grid cell holding both, right-aligned: the slot is as wide as // the wider of the two and never changes, so revealing the hint moves // nothing. No transition here either — the hint switches on the same // :hover / `selected` as the fill, in the same frame. {edge && ( {edge} )} {hint && ( {hint} )} )} ) } export function MenuItem({ label, children, size = 'default', description, aside, leading, trailing, hint, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) { const body = ( {children} ) const rowClassName = menuItemClassName({ size, selected, className }) /* * Inside a `Menu` the row is Base UI's `Menu.Item`: it joins the roving * focus, answers the typeahead, and closes the menu when it is chosen. * `nativeButton` keeps the ` ) } return ( } className={rowClassName} /* `Menu.Item` types its handlers for the `
` it would draw by default. `render` makes it the `