feat: workspace select menu
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
79
packages/client/ui-primitives/src/Modal.module.css
Normal file
79
packages/client/ui-primitives/src/Modal.module.css
Normal 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;
|
||||
}
|
||||
62
packages/client/ui-primitives/src/Modal.tsx
Normal file
62
packages/client/ui-primitives/src/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user