Files
deepseek-harness/packages/client/ui-primitives/src/Menu.tsx
T
Yif 1f79b78045 fix(web-ui): menu placement and scrolling, tool-row and settings polish
Menus: keep 12px viewport clearance with internal scroll, pin workspace
create actions in a footer, and pre-render portal lists hidden so the
first painted frame is already at its final position (no open jump).
Tool rows: 14px icons, secondary titles, no hover fill, and a hover
chevron preview on in-place expandable rows. Settings: 800x600 layer-2
panel over a blurred mask, hover states, and wrapping selector cubes;
ModelSelect surface tokens now match the Menu primitive.
2026-07-28 04:25:18 +08:00

276 lines
11 KiB
TypeScript

// 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<HTMLSpanElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
const [fixedPos, setFixedPos] = useState<CSSProperties | null>(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 <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect(entry.id)
}}
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
onClick={() => { onSelect(sub.id) }}
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
</button>
))}
</div>
)}
</div>
)
}
// 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 && (
<div
ref={listRef}
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { 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() }}
>
<div className={css.viewport} role="presentation">
{items.map(renderEntry)}
</div>
{footer !== undefined && footer.length > 0 && (
<div className={css.footer} role="presentation">
{footer.map(renderEntry)}
</div>
)}
</div>
)
return (
<span ref={rootRef} className={clsx(css.root, className)}>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
</span>
)
}