feat(web): session list one-list, hover card, row menus, rename, manual ordering
Sidebar session list grows the figma 239-10458 feature set and the
workspace/session browsing region moves wholesale into ui-workspace:
- Group-by menu (WorkSpace / In one list): flat mode lists every session
top-level, strictly newest-first; the choice persists across reloads.
- Session rows get a 500ms hover detail card (title / relative time /
status line) and a ... menu (Rename / Fork session / Delete session,
visual-only for now); workspace headers get ... with Rename (wired) and
Delete workspace (visual-only).
- workspace.rename RPC: trims, rejects duplicate titles on the create
chain (workspace-name-conflict), no-op on same title; modal dialog with
client-side duplicate pre-check.
- workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted
anchor appends): HTML5 drag reorder of root sessions inside a workspace
group; order truth stays host-side, the view refreshes from the
response/changed frame.
- Activity pinning removed: the session/event touchSession chain is gone;
workspace accounts are manually owned (new sessions prepend, explicit
reordering only). Contracts and tests updated, api catalog regenerated.
- ui-sidebar reduced to the column shell (brand, fold state machine, New
Session, Settings) exposing one sidebar.workspaces hole with a two-fact
owner share {wide, expandSidebar}; ui-workspace owns the whole region
(header, search, grouped/flat lists, dialogs, drag) plus the picker via
a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and
its deferral indirection are gone.
- ui-primitives: Menu gains label entries, danger rows, and
closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled
guard). Hover card and row menu never coexist.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// 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.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
@@ -19,6 +20,8 @@ export interface MenuItem {
|
||||
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[]
|
||||
}
|
||||
@@ -29,13 +32,24 @@ export interface MenuSeparator {
|
||||
id: string
|
||||
}
|
||||
|
||||
/** One primary-menu entry: a row or a separator. */
|
||||
export type MenuEntry = MenuItem | MenuSeparator
|
||||
/** 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'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an anchored dropdown menu.
|
||||
* @param props.open - whether the list is showing (owner-controlled).
|
||||
@@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
|
||||
* 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
|
||||
@@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
|
||||
* 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', portal = false, getAnchorRect, className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuEntry[]
|
||||
@@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
align?: 'start' | 'end'
|
||||
side?: 'bottom' | 'top'
|
||||
portal?: boolean
|
||||
closeOnPointerLeave?: boolean
|
||||
getAnchorRect?: () => DOMRect | null
|
||||
className?: string
|
||||
}) {
|
||||
@@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={fixedPos ?? 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() }}
|
||||
>
|
||||
{items.map(entry => {
|
||||
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 (
|
||||
@@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected)}
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user