feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions

View File

@@ -1,10 +1,13 @@
// Menu: minimal controlled dropdown (group-by pickers, project selectors).
// Pure CSS positioning relative to the anchor wrapper — no portal, no popper.
// 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.
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
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'
@@ -43,9 +46,19 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
* @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.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.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -54,10 +67,44 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
onClose: () => void
align?: 'start' | 'end'
side?: 'bottom' | 'top'
portal?: 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
setFixedPos({
...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }),
...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }),
})
}
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) {
@@ -65,7 +112,11 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
return
}
const onPointerDown = (e: PointerEvent) => {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose()
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()
@@ -78,11 +129,13 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
}
}, [open, onClose])
return (
<span ref={rootRef} className={clsx(css.root, className)}>
{anchor}
{open && (
<div className={clsx(css.list, side === 'top' && css.sideTop, align === 'end' && css.alignEnd)} role="menu">
const list = open && (!portal || fixedPos !== null) && (
<div
ref={listRef}
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined}
role="menu"
>
{items.map(entry => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
@@ -137,8 +190,13 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
</div>
)
})}
</div>
)}
</div>
)
return (
<span ref={rootRef} className={clsx(css.root, className)}>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
</span>
)
}