feat(web): add workspace-aware session flow
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
* r12, inverted hairline border, shadow-lv3, 4px inset padding. */
|
||||
.list,
|
||||
.submenu {
|
||||
/* min-widths below are the design's outer card widths — include the pad. */
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -17,12 +19,22 @@
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
/* Primary card is 218 wide in the design across both hosts. */
|
||||
.list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
min-width: 130px;
|
||||
min-width: 218px;
|
||||
}
|
||||
|
||||
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
|
||||
* anchor rect (side/align resolved in JS, the in-place offset rules above
|
||||
* don't apply). */
|
||||
.portal {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
|
||||
@@ -116,7 +128,7 @@
|
||||
bottom: -4px;
|
||||
left: calc(100% + 10px);
|
||||
z-index: 101;
|
||||
min-width: 160px;
|
||||
min-width: 163px;
|
||||
}
|
||||
|
||||
.submenu::before {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */
|
||||
/* Header row (figma Title row): pad l24/t22/r14/b12, SPACE_BETWEEN —
|
||||
* title left, close button right. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 22px 14px 12px 24px;
|
||||
}
|
||||
@@ -52,21 +54,43 @@
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.close {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Description and body share the 332px content column (24px side pads). */
|
||||
.description {
|
||||
margin: 0;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
margin-top: 20px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16 } from './icons/index.tsx'
|
||||
import css from './Modal.module.css'
|
||||
|
||||
/**
|
||||
@@ -49,10 +50,13 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
||||
<div className={css.content}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
{description !== undefined && description !== '' && (
|
||||
<p className={css.description}>{description}</p>
|
||||
)}
|
||||
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{description !== undefined && description !== '' && (
|
||||
<p className={css.description}>{description}</p>
|
||||
)}
|
||||
{children !== undefined && <div className={css.body}>{children}</div>}
|
||||
</div>
|
||||
{footer !== undefined && <div className={css.footer}>{footer}</div>}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// it escapes ancestor overflow clipping (the sidebar rail clips its column)
|
||||
// without a portal.
|
||||
|
||||
import { cloneElement, useEffect, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, ReactElement, Ref } from 'react'
|
||||
import { cloneElement, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
|
||||
import css from './Tooltip.module.css'
|
||||
|
||||
/** Bubble placement relative to the anchor. */
|
||||
@@ -28,11 +28,19 @@ interface AnchorProps {
|
||||
* @param props.label - bubble text.
|
||||
* @param props.side - placement relative to the anchor (default 'right').
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.children - a single anchor element. Tooltip owns its ref (no current consumer passes one).
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||
// anchor in Tooltip never silently severs the owner's ref.
|
||||
const childRef = (children as ReactElement<AnchorProps> & { ref?: Ref<HTMLElement> }).ref
|
||||
const mergedRef = useCallback((el: HTMLElement | null) => {
|
||||
anchor.current = el
|
||||
if (typeof childRef === 'function') childRef(el)
|
||||
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
|
||||
}, [childRef])
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
@@ -61,7 +69,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
|
||||
return (
|
||||
<>
|
||||
{cloneElement(children, {
|
||||
ref: anchor,
|
||||
ref: mergedRef,
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
|
||||
|
||||
Reference in New Issue
Block a user