// Menu: minimal controlled dropdown (group-by pickers, project selectors). // Default: pure CSS positioning relative to the anchor wrapper — no popper. // Opt-in `portal` renders the list into document.body, fixed-positioned from // the anchor rect, for anchors inside overflow-clipping containers (sidebar). // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. // Entries also cover non-interactive `label` headings and `danger` rows. // Lists keep 12px clearance to the viewport's top/bottom edges and scroll // internally past that; submenu-bearing menus are exempt (see .scrollable). import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCheckOutline16 } from './icons/index.tsx' import css from './Menu.module.css' /** Selectable row (optionally with a nested submenu). */ export interface MenuItem { id: string label: ReactNode disabled?: boolean /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode /** Destructive row: error-colored text/icon and danger hover fill. */ danger?: boolean /** Nested card opened to the right on hover/focus. */ submenu?: readonly MenuItem[] } /** Hairline between item groups (not selectable). */ export interface MenuSeparator { type: 'separator' id: string } /** Non-interactive heading row above a group of items. */ export interface MenuLabel { type: 'label' id: string text: string } /** One primary-menu entry: a row, a separator, or a heading label. */ export type MenuEntry = MenuItem | MenuSeparator | MenuLabel function isSeparator(entry: MenuEntry): entry is MenuSeparator { return 'type' in entry && entry.type === 'separator' } function isLabel(entry: MenuEntry): entry is MenuLabel { return 'type' in entry && entry.type === 'label' } /** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). * @param props.side - open below (`bottom`, default) or above (`top`) the anchor. * @param props.portal - render the list into document.body, fixed-positioned * from the anchor rect (repositions on scroll/resize while open). Use when an * ancestor's overflow clipping would crop the in-place list; default false * keeps the pure-CSS in-place behavior. * @param props.closeOnPointerLeave - close the list when the pointer leaves * it (default false keeps it open until outside click/Escape/selection). * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the * Menu's own wrapper span. Required when the wrapper isn't itself laid out at * the trigger (render-prop anchors, effect-positioned proxies — measuring the * wrapper there races the host's layout effects). Called on open and on every * scroll/resize; return null to skip placement for that frame. * @param props.footer - rows pinned below the scrolling items area, separated * by a hairline; they stay visible while the items above scroll. * @returns anchor wrapper with the conditional list. */ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] selectedId?: string | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean getAnchorRect?: () => DOMRect | null className?: string }) { const rootRef = useRef(null) const listRef = useRef(null) const [openSubmenuId, setOpenSubmenuId] = useState(null) const [fixedPos, setFixedPos] = useState(null) // Portal mode: fixed-position the list from the anchor rect before paint; // track the anchor while open (capture-phase scroll catches nested panes). // getAnchorRect trumps measuring the wrapper span: a child layout effect // runs before the parent's, so a wrapper the host positions in its own // effect measures stale here — the host callback owns the truth instead. useLayoutEffect(() => { if (!open || !portal) { setFixedPos(null); return } const place = () => { let r: DOMRect | null if (getAnchorRect !== undefined) { r = getAnchorRect() } else { /* v8 ignore next 2 -- the ref is attached before the layout effect runs and the listeners die with it. */ r = rootRef.current?.getBoundingClientRect() ?? null } if (r === null) return const MARGIN = 12 const vw = window.innerWidth const vh = window.innerHeight const listEl = listRef.current const lw = listEl?.offsetWidth ?? 0 const lh = listEl?.offsetHeight ?? 0 let x: number let y: number if (side === 'right') { x = r.right + 4 y = r.top } else if (align === 'start') { x = r.left y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 } else { x = r.right - lw y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN) setFixedPos({ left: x, top: y }) } // First run measures the hidden pre-render (same commit as `open`), so // end/top alignment and clamping use real dimensions before anything // paints — no visible jump from a zero-size first guess. place() window.addEventListener('scroll', place, true) window.addEventListener('resize', place) return () => { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } }, [open, portal, align, side, getAnchorRect]) useEffect(() => { if (!open) { setOpenSubmenuId(null) return } const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Node)) return // The portaled list is outside the anchor subtree; check both. if (rootRef.current?.contains(e.target) === true) return if (listRef.current?.contains(e.target) === true) return onClose() } const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) return () => { document.removeEventListener('pointerdown', onPointerDown) document.removeEventListener('keydown', onKeyDown) } }, [open, onClose]) // The submenu card is absolutely positioned outside the list box; the // scroll clip would crop it, so only submenu-free menus get the height cap. const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) const renderEntry = (entry: MenuEntry) => { if (isSeparator(entry)) { return
} if (isLabel(entry)) { return
{entry.text}
} const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id return (
{ setOpenSubmenuId(hasSub ? entry.id : null) }} onMouseLeave={() => { setOpenSubmenuId(null) }} > {subOpen && entry.submenu !== undefined && (
{entry.submenu.map(sub => ( ))}
)}
) } // Portal lists render hidden until placed: the placement effect measures // this pre-render in the same commit, so the first painted frame is // already at the final position (with getAnchorRect returning null the // list simply stays hidden). const list = open && (
{ onClose() } : undefined} // React portals bubble synthetic events through the REACT tree: without // this stop, an item click re-fires the anchor row's own onClick // (open/toggle) after onSelect. onClick={(e) => { e.stopPropagation() }} >
{items.map(renderEntry)}
{footer !== undefined && footer.length > 0 && (
{footer.map(renderEntry)}
)}
) return ( {anchor} {portal ? (list !== false && createPortal(list, document.body)) : list} ) }