feat: workspace select menu

This commit is contained in:
07akioni
2026-07-24 16:09:58 +08:00
parent 06143b1a86
commit 6f5321cb37
26 changed files with 890 additions and 187 deletions

View File

@@ -56,6 +56,20 @@
background: var(--dsw-alias-interactive-bg-active);
}
/* Dialog Cancel (figma 451:18655): bordered capsule on transparent fill. */
.outline {
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
}
.outline:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.outline:disabled {
border-color: var(--dsw-alias-border-l1);
}
.toolbar {
background: var(--dsw-alias-button-tool-bar-fill);
}

View File

@@ -6,7 +6,7 @@ import clsx from 'clsx'
import css from './Button.module.css'
/** Visual variant, each backed by its --dsw-alias-button-* token family. */
export type ButtonVariant = 'primary' | 'ghost' | 'toolbar'
export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar'
/**
* Render a button.

View File

@@ -3,21 +3,32 @@
display: inline-flex;
}
/* Dropdown card (figma MenuDropdown 122:10096): white card, r12, no border,
* layered drop shadows via the shadow token, 4px inset padding. */
/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface,
* r12, inverted hairline border, shadow-lv3, 4px inset padding. */
.list,
.submenu {
padding: 4px;
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
}
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 100;
min-width: 130px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 0;
border-radius: 12px;
background: var(--dsw-alias-bg-layer-1);
box-shadow: var(--dsw-shadow-lv2);
}
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
.sideTop {
top: auto;
bottom: calc(100% + 4px);
}
.alignEnd {
@@ -25,12 +36,18 @@
right: 0;
}
/* Menu cell (figma .Menu_cell 27:5169): r10, pad 10/8, 14/22 primary text,
.itemWrap {
position: relative;
}
/* Menu cell (figma .Menu_cell): min-h 40, r10, pad 10/8, 14/22 primary,
* gap 8 between leading icon / label / trailing check. */
.item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: none;
border-radius: 10px;
@@ -51,9 +68,22 @@
cursor: not-allowed;
}
.itemIcon {
display: inline-flex;
flex: none;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
color: var(--dsw-alias-label-tertiary);
}
.itemLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check {
@@ -66,3 +96,34 @@
.selected {
background: transparent;
}
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
.separator {
height: 1px;
margin: 4px 2px;
background: var(--dsw-alias-border-l1);
}
/* Nested card to the right of the parent row (figma 419:16920).
* Bottom-aligned with the parent menu card (grows upward): itemWrap sits in
* .list's 4px pad, so bottom: -4px matches the list's outer bottom edge.
* Horizontal: list pad (4px) + 6px card gap = 10px past itemWrap — plain
* `100% + 6px` collapses to ~2px between outer card edges.
* ::before bridges the full gap so the pointer can cross without mouseLeave. */
.submenu {
position: absolute;
top: auto;
bottom: -4px;
left: calc(100% + 10px);
z-index: 101;
min-width: 160px;
}
.submenu::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -10px;
width: 10px;
}

View File

@@ -1,46 +1,69 @@
// Menu: minimal controlled dropdown (group-by pickers, project selectors).
// Pure CSS positioning relative to the anchor wrapper — no portal, no popper.
// The owner controls `open`; outside-click closing uses one document listener
// active only while open.
// active only while open. Submenus open on hover/focus inside the same root.
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16 } from './icons/index.tsx'
import css from './Menu.module.css'
/** One selectable menu row. */
/** 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
/** 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
}
/** One primary-menu entry: a row or a separator. */
export type MenuEntry = MenuItem | MenuSeparator
function isSeparator(entry: MenuEntry): entry is MenuSeparator {
return 'type' in entry && entry.type === 'separator'
}
/**
* 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.
* @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).
* @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.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: {
open: boolean
anchor: ReactNode
items: readonly MenuItem[]
items: readonly MenuEntry[]
selectedId?: string
onSelect: (id: string) => void
onClose: () => void
align?: 'start' | 'end'
side?: 'bottom' | 'top'
className?: string
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
useEffect(() => {
if (!open) return
if (!open) {
setOpenSubmenuId(null)
return
}
const onPointerDown = (e: PointerEvent) => {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose()
}
@@ -59,21 +82,61 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
<span ref={rootRef} className={clsx(css.root, className)}>
{anchor}
{open && (
<div className={clsx(css.list, align === 'end' && css.alignEnd)} role="menu">
{items.map(item => (
<button
key={item.id}
type="button"
role="menuitem"
className={clsx(css.item, item.id === selectedId && css.selected)}
disabled={item.disabled}
onClick={() => onSelect(item.id)}
>
<span className={css.itemLabel}>{item.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{item.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
))}
<div className={clsx(css.list, side === 'top' && css.sideTop, align === 'end' && css.alignEnd)} role="menu">
{items.map(entry => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
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)}
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>
)
})}
</div>
)}
</span>

View File

@@ -0,0 +1,79 @@
/* Full-viewport layer (figma Mask + Dialog 451:18655): mask + centered card. */
.root {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
/* User/spec mask: rgba(0,0,0,0.24) + blur(2px) via --dsw-alias-bg-mask-1 /
--dsw-mask-blur (light); dark theme raises mask opacity. */
.mask {
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
/* Dialog card: r24, shadow-lv3, layer-2 fill, inverted border, pb 24. */
.dialog {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 20px;
width: min(380px, 100%);
padding: 0 0 24px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 24px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
}
.content {
display: flex;
flex-direction: column;
width: 100%;
}
/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */
.header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 22px 14px 12px 24px;
}
.title {
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.description {
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
}
.body {
display: flex;
flex-direction: column;
min-width: 0;
padding: 0 24px;
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 24px;
}

View File

@@ -0,0 +1,62 @@
// Modal: controlled full-viewport dialog (create-workspace and similar).
// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
// stays free of a react-dom dependency; mask tokens match figma 451:18655.
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import css from './Modal.module.css'
/**
* Render a centered modal over a blurred page mask.
* @param props.open - whether the dialog is showing.
* @param props.onClose - Escape or mask click.
* @param props.title - dialog heading.
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({ open, onClose, title, description, children, footer, className }: {
open: boolean
onClose: () => void
title: string
description?: string
children?: ReactNode
footer?: ReactNode
className?: string
}) {
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('keydown', onKeyDown) }
}, [open, onClose])
if (!open) return null
return (
<div className={css.root} role="presentation">
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div
className={clsx(css.dialog, className)}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
{description !== undefined && description !== '' && (
<p className={css.description}>{description}</p>
)}
</div>
{children !== undefined && <div className={css.body}>{children}</div>}
</div>
{footer !== undefined && <div className={css.footer}>{footer}</div>}
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
/**
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Input,
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input,
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
* token vars only. Contract: api-contracts v3 section 8.
*/
@@ -11,7 +11,8 @@ export type { ButtonVariant } from './Button.tsx'
export { Pill } from './Pill.tsx'
export { Input } from './Input.tsx'
export { Menu } from './Menu.tsx'
export type { MenuItem } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx'
export { Modal } from './Modal.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'