@spunto/design-system 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,21 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
66
66
  (a confirmation variant, non-dismissible), `Sheet`, `Tooltip`. Their portals render
67
67
  into the provider's overlay container; `Tooltip`'s shared delay group is mounted by
68
68
  the provider too.
69
+ - _ActionMenu_ — `ActionMenu` (+`ActionMenuItem`, `ActionMenuEntry`), the `⋯` menu
70
+ **described instead of assembled**: `items` is a list of `{ label, icon?, onClick }`
71
+ (or `href`, for a real `<a>` via Base UI's `Menu.LinkItem`) and the component builds
72
+ the trigger, the portal, the popup, the items and their states — `loading` swaps the
73
+ icon for a spinner and disarms the entry, `destructive` turns it red, `shortcut`
74
+ right-aligns a `Kbd`. A menu is the one place an app brings nothing but data, and
75
+ every app was re-typing the same twenty lines of Base UI markup around it. Two things
76
+ the data form gets right that hand-written menus don't: entries may be
77
+ `false`/`null`/`undefined`, so a conditional menu stays one flat array; and
78
+ `"separator"` is an entry like any other, **normalized** — a rule left leading,
79
+ trailing or doubled by a missing neighbour disappears instead of floating at the top
80
+ of the popup. Empty after that pass → nothing renders, not even the `⋯`. Still
81
+ presentational: it never knows what an entry *does*. For what a list can't say (a
82
+ submenu, a checkbox item), compose Base UI directly — that's what `WorkerCard`'s
83
+ `actions` slot is for.
69
84
  - _Sheet_ — `Sheet` (+`SheetTrigger`/`SheetClose`/`SheetContent`/`SheetHeader`/
70
85
  `SheetFooter`/`SheetTitle`/`SheetDescription`), the same Base UI dialog entering from
71
86
  an edge. `side` (4) decides the edge, `size` (`sm`…`full`) how far it comes in — two
@@ -167,10 +182,18 @@ opinions a dashboard doesn't want, so it gets an entry too.
167
182
  - **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
168
183
  progress, git branches, resources, banners, actions. **Purely presentational** —
169
184
  hand it a snapshot plus slots and it draws. The rule that keeps it that way:
170
- *everything that CALLS the API is a slot or a callback; everything that DRAWS is
171
- in the component.* So the `⋯` menu (`actions`), the footer (`footer`), the link
172
- (`href` + `render.link`, never `next/link`), the rebuild (`onRebuild`) and the
173
- tag persistence (`onAddTag`/`onRemoveTag`) all stay in the app.
185
+ *everything that CALLS the API is data, a slot or a callback; everything that
186
+ DRAWS is in the component.* So what the `⋯` entries **do** (`menu`), the footer
187
+ (`footer`), the link (`href` + `render.link`, never `next/link`), the rebuild
188
+ (`onRebuild`) and the tag persistence (`onAddTag`/`onRemoveTag`) all stay in the
189
+ app.
190
+ - **The `⋯` menu is a prop, not markup.** `menu` takes the `ActionMenuEntry[]` of
191
+ the `ActionMenu` primitive — `{ label, icon?, onClick }`, falsy entries allowed,
192
+ `"separator"` normalized — and the card draws the whole menu. Before, every app
193
+ handed the card a fully-built menu through the `actions` slot and re-typed the
194
+ same Base UI markup to do it. `actions` stays as the escape hatch (a submenu, a
195
+ checkbox item) and wins over `menu` when both are given; `render.menuLink` is
196
+ the link slot for an entry with an `href`.
174
197
  - **Its bricks, exported individually** because the table view assembles them
175
198
  differently: `WorkerStatusDot`/`WorkerStatusPill` (+`resolveWorkerStatus`,
176
199
  `workerStatusConfig`), `SetupProgress` (+`setupProgress`, `phaseLabel`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,225 @@
1
+ "use client"
2
+
3
+ import type { ComponentType, ReactElement, ReactNode } from "react"
4
+ import { Menu as MenuPrimitive } from "@base-ui/react/menu"
5
+ import { ExternalLinkIcon, MoreVerticalIcon } from "lucide-react"
6
+
7
+ import { cn } from "../utils"
8
+ import { Spinner } from "./spinner"
9
+ import { useOverlayContainer } from "./spunto-provider"
10
+
11
+ /** One entry of an `ActionMenu`, described rather than rendered. */
12
+ export interface ActionMenuItem {
13
+ /** What the entry says. */
14
+ label: ReactNode
15
+ /**
16
+ * React key. Defaults to the label when it's a string — give one explicitly if
17
+ * the label is a node, or if two entries share a label.
18
+ */
19
+ key?: string
20
+ /** Leading icon — any component taking a `className` (lucide-react, a local SVG…). */
21
+ icon?: ComponentType<{ className?: string }>
22
+ /** What the entry does. Omitted (and no `href`) → the entry is inert. */
23
+ onClick?: () => void
24
+ /** Navigates instead of acting: the entry renders as a real `<a>` (middle-click, ⌘-click…). */
25
+ href?: string
26
+ /** `"_blank"` implies the trailing ↗ unless `external` says otherwise. */
27
+ target?: string
28
+ /** Trailing ↗. Defaults to `target === "_blank"`. */
29
+ external?: boolean
30
+ disabled?: boolean
31
+ /**
32
+ * Action in flight: a spinner takes the icon's place and the entry is
33
+ * disabled. Kept apart from `disabled` so the caller doesn't have to swap the
34
+ * icon itself — the pending flag of a mutation goes straight in here.
35
+ */
36
+ loading?: boolean
37
+ /** Red, and pushed below a separator by convention (the caller still places it). */
38
+ destructive?: boolean
39
+ /** Trailing hint, right-aligned — a `Kbd`, a shortcut, a count. */
40
+ shortcut?: ReactNode
41
+ /** Keeps the popup open after a click (a toggle, typically). */
42
+ closeOnClick?: boolean
43
+ }
44
+
45
+ /**
46
+ * An entry, a rule between groups, or nothing.
47
+ *
48
+ * The falsy members are the point: a menu is almost always conditional
49
+ * (`isRunning && { label: "Stop", … }`), and allowing `false`/`null`/`undefined`
50
+ * keeps that written as one flat array instead of a `filter(Boolean)` dance.
51
+ * Separators are normalized afterwards — see `normalizeEntries`.
52
+ */
53
+ export type ActionMenuEntry = ActionMenuItem | "separator" | false | null | undefined
54
+
55
+ /** Renders an `href` entry with the app's link component (`next/link`, a router link…). */
56
+ export type ActionMenuLinkRender = (props: {
57
+ href: string
58
+ target?: string
59
+ children?: ReactNode
60
+ }) => ReactElement
61
+
62
+ export interface ActionMenuProps {
63
+ /** The menu, as data. Falsy entries are dropped; empty → nothing renders at all. */
64
+ items: ActionMenuEntry[]
65
+ /** Accessible name of the default `⋯` trigger. */
66
+ label?: string
67
+ /** Replaces the `⋯` button. Base UI merges its trigger props into this element. */
68
+ trigger?: ReactElement
69
+ triggerClassName?: string
70
+ /** Classes for the popup (its width, typically). */
71
+ className?: string
72
+ align?: "start" | "center" | "end"
73
+ side?: "top" | "bottom" | "left" | "right"
74
+ sideOffset?: number
75
+ /** Escape hatch for `href` entries; without it they render as a plain `<a>`. */
76
+ render?: { link?: ActionMenuLinkRender }
77
+ }
78
+
79
+ /**
80
+ * The `⋯` menu, described instead of assembled: give it a list of
81
+ * `{ label, icon, onClick }` and it builds the trigger, the popup, the items,
82
+ * the separators and their states.
83
+ *
84
+ * Every app was rebuilding the same twenty lines of Base UI markup around the
85
+ * same five shapes of entry (an action, a link, a destructive one, one with a
86
+ * mutation in flight, a rule between groups) — and drifting: different paddings,
87
+ * different red, a spinner here and not there. The data form is what the callers
88
+ * actually have anyway; the markup is ours.
89
+ *
90
+ * Two things it does that hand-written menus keep getting wrong:
91
+ *
92
+ * - **Conditional entries.** `false`/`null`/`undefined` are legal entries, so a
93
+ * menu stays one flat array (`isRunning && { label: "Stop", … }`).
94
+ * - **Separators are normalized.** Leading, trailing and consecutive rules are
95
+ * dropped, so a `"separator"` next to an entry that just disappeared doesn't
96
+ * leave a stray line floating at the top of the popup. That's the bug you get
97
+ * every time a conditional item sits next to a conditional separator.
98
+ *
99
+ * It stays presentational: it never knows what an entry *does*. `onClick` and
100
+ * `href` come from the caller, like everywhere else in this package.
101
+ */
102
+ export function ActionMenu({
103
+ items,
104
+ label = "Actions",
105
+ trigger,
106
+ triggerClassName,
107
+ className,
108
+ align = "end",
109
+ side = "bottom",
110
+ sideOffset = 4,
111
+ render,
112
+ }: ActionMenuProps) {
113
+ const container = useOverlayContainer()
114
+ const entries = normalizeEntries(items)
115
+
116
+ // No action → no affordance. A `⋯` that opens an empty popup is worse than no
117
+ // `⋯` at all, and callers build these lists conditionally: an empty one is the
118
+ // normal outcome for, say, a worker on an offline node.
119
+ if (entries.length === 0) return null
120
+
121
+ return (
122
+ <MenuPrimitive.Root>
123
+ {trigger ? (
124
+ <MenuPrimitive.Trigger render={trigger} />
125
+ ) : (
126
+ <MenuPrimitive.Trigger aria-label={label} className={cn(triggerButton, triggerClassName)}>
127
+ <MoreVerticalIcon className="h-4 w-4" />
128
+ </MenuPrimitive.Trigger>
129
+ )}
130
+
131
+ <MenuPrimitive.Portal container={container ?? undefined}>
132
+ <MenuPrimitive.Positioner className="isolate z-50 outline-none" align={align} side={side} sideOffset={sideOffset}>
133
+ <MenuPrimitive.Popup
134
+ data-slot="action-menu"
135
+ className={cn(
136
+ // `max-h-(--available-height)` + scroll: a menu of twenty actions
137
+ // opened from a card at the bottom of the page must scroll inside
138
+ // the viewport, never spill past its edge.
139
+ "max-h-(--available-height) min-w-52 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/[0.06] outline-none",
140
+ "transition-[transform,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
141
+ className
142
+ )}
143
+ >
144
+ {entries.map((entry, i) =>
145
+ entry === "separator" ? (
146
+ <div key={`sep-${i}`} role="separator" className="-mx-1 my-1 h-px bg-border" />
147
+ ) : (
148
+ <Item key={entry.key ?? (typeof entry.label === "string" ? entry.label : `item-${i}`)} item={entry} render={render} />
149
+ )
150
+ )}
151
+ </MenuPrimitive.Popup>
152
+ </MenuPrimitive.Positioner>
153
+ </MenuPrimitive.Portal>
154
+ </MenuPrimitive.Root>
155
+ )
156
+ }
157
+
158
+ const triggerButton =
159
+ "flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/50 transition-colors outline-none hover:bg-accent hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-foreground"
160
+
161
+ const itemClass =
162
+ "flex cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-sm no-underline outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0"
163
+
164
+ const destructiveClass =
165
+ "text-destructive data-highlighted:bg-destructive/10 data-highlighted:text-destructive dark:data-highlighted:bg-destructive/20"
166
+
167
+ function Item({ item, render }: { item: ActionMenuItem; render?: { link?: ActionMenuLinkRender } }) {
168
+ const { label, icon: Icon, href, target, external, disabled, loading, destructive, shortcut, closeOnClick, onClick } = item
169
+ const className = cn(itemClass, destructive && destructiveClass)
170
+ const showExternal = external ?? target === "_blank"
171
+
172
+ const content = (
173
+ <>
174
+ {loading ? <Spinner className="size-3.5" /> : Icon ? <Icon /> : null}
175
+ <span className="min-w-0 flex-1 truncate">{label}</span>
176
+ {shortcut != null && <span className="text-xs text-muted-foreground">{shortcut}</span>}
177
+ {showExternal && <ExternalLinkIcon className="opacity-50" />}
178
+ </>
179
+ )
180
+
181
+ // A navigation is an `<a>` — Base UI's `LinkItem` renders one, so ⌘-click and
182
+ // "open in new tab" work like anywhere else. `render.link` swaps in the app's
183
+ // router link; without it, a plain anchor (the package never imports `next/link`).
184
+ if (href) {
185
+ return (
186
+ <MenuPrimitive.LinkItem
187
+ className={className}
188
+ closeOnClick
189
+ render={render?.link?.({ href, target }) ?? <a href={href} target={target} rel={target === "_blank" ? "noopener noreferrer" : undefined} />}
190
+ >
191
+ {content}
192
+ </MenuPrimitive.LinkItem>
193
+ )
194
+ }
195
+
196
+ return (
197
+ <MenuPrimitive.Item className={className} disabled={disabled || loading} closeOnClick={closeOnClick ?? true} onClick={onClick}>
198
+ {content}
199
+ </MenuPrimitive.Item>
200
+ )
201
+ }
202
+
203
+ /**
204
+ * Drops falsy entries, then every separator that would end up alone: leading,
205
+ * trailing, or next to another one.
206
+ *
207
+ * This is the whole reason separators are data here. Written by hand, a rule
208
+ * between two conditional groups needs its own condition — the union of the
209
+ * conditions below it — and that condition is what goes stale when an entry is
210
+ * added. Here the rule simply disappears when nothing follows it.
211
+ */
212
+ export function normalizeEntries(entries: ActionMenuEntry[]): (ActionMenuItem | "separator")[] {
213
+ const out: (ActionMenuItem | "separator")[] = []
214
+ for (const entry of entries) {
215
+ if (!entry) continue
216
+ if (entry === "separator") {
217
+ if (out.length === 0 || out[out.length - 1] === "separator") continue
218
+ out.push("separator")
219
+ continue
220
+ }
221
+ out.push(entry)
222
+ }
223
+ while (out.length > 0 && out[out.length - 1] === "separator") out.pop()
224
+ return out
225
+ }
@@ -9,6 +9,12 @@
9
9
  export { WorkerCard } from "./worker-card"
10
10
  export type { WorkerCardProps, LinkRender } from "./worker-card"
11
11
 
12
+ // The card's `menu` prop is a list of these. Re-exported here (they also ship
13
+ // from the root entry) so an app can type its worker menu without importing the
14
+ // primitives entry just for that.
15
+ export { ActionMenu } from "../action-menu"
16
+ export type { ActionMenuProps, ActionMenuItem, ActionMenuEntry, ActionMenuLinkRender } from "../action-menu"
17
+
12
18
  export { WorkerCockpit } from "./worker-cockpit"
13
19
  export type { WorkerCockpitProps } from "./worker-cockpit"
14
20
 
@@ -12,6 +12,7 @@ import {
12
12
  } from "lucide-react"
13
13
 
14
14
  import { cn, formatDuration, formatRelativeTime } from "../../utils"
15
+ import { ActionMenu, type ActionMenuEntry, type ActionMenuLinkRender } from "../action-menu"
15
16
  import { buttonVariants } from "../button-variants"
16
17
  import { CreatorAvatar } from "./creator-avatar"
17
18
  import { GitBranchChips, type GitRepoStatus } from "./git-branch-chips"
@@ -33,8 +34,12 @@ export interface WorkerCardProps {
33
34
  * Escape hatches for app-specific rendering. `link` receives `{ href, className,
34
35
  * children }` and must return an anchor-ish node (`next/link`, a router link…);
35
36
  * without it the card falls back to a plain `<a>`.
37
+ *
38
+ * `menuLink` does the same for a `menu` entry carrying an `href`, but with the
39
+ * other contract: it returns an *element* that Base UI merges its own props
40
+ * and children into, so it takes no `children` of its own.
36
41
  */
37
- render?: { link?: LinkRender }
42
+ render?: { link?: LinkRender; menuLink?: ActionMenuLinkRender }
38
43
 
39
44
  creator?: WorkerCreator
40
45
  nodeName?: string
@@ -71,7 +76,33 @@ export interface WorkerCardProps {
71
76
  error?: string | null
72
77
  onDismissError?: () => void
73
78
 
74
- /** The `⋯` menu, top-right. A slot: every entry in it calls the API. */
79
+ /**
80
+ * The `⋯` menu, top-right, **as data**: one entry is a `label`, optionally an
81
+ * `icon`, and an `onClick` (or an `href`) — the card builds the trigger, the
82
+ * popup and the items. Entries may be `false`/`null` (a menu is always
83
+ * conditional) and `"separator"` is an entry like any other; rules left alone
84
+ * by a missing neighbour are dropped. Empty or all-falsy → no `⋯` at all.
85
+ *
86
+ * What an entry *does* still belongs to the app: `onClick` is where the API
87
+ * call lives. What it *looks like* is ours — that's the difference with the
88
+ * `actions` slot below, which each app used to fill with its own twenty lines
89
+ * of menu markup.
90
+ *
91
+ * ```tsx
92
+ * menu={[
93
+ * codeUrl && { label: "Open in VS Code", icon: Code2Icon, href: codeUrl, target: "_blank" },
94
+ * "separator",
95
+ * isRunning && { label: "Stop", icon: SquareIcon, onClick: () => stop.mutate(), loading: stop.isPending },
96
+ * "separator",
97
+ * { label: "Delete", icon: Trash2Icon, destructive: true, onClick: () => del.mutate() },
98
+ * ]}
99
+ * ```
100
+ */
101
+ menu?: ActionMenuEntry[]
102
+ /**
103
+ * Escape hatch for a menu the data form can't express (a submenu, a checkbox
104
+ * item, a dialog opened from an entry). Takes precedence over `menu`.
105
+ */
75
106
  actions?: ReactNode
76
107
  /** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
77
108
  footer?: ReactNode
@@ -86,10 +117,10 @@ export interface WorkerCardProps {
86
117
  * on, and what you can do with it.
87
118
  *
88
119
  * **Purely presentational.** It receives a snapshot and draws it — no query, no
89
- * mutation, no router. Everything that would talk to an API is a slot
90
- * (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule is
91
- * what makes the same card renderable by the dashboard, by Spunto Lite, and by
92
- * the showcase with hand-written fixtures. The card is the assembly; each brick
120
+ * mutation, no router. Everything that would talk to an API is data (`menu`), a
121
+ * slot (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule
122
+ * is what makes the same card renderable by the dashboard, by Spunto Lite, and
123
+ * by the showcase with hand-written fixtures. The card is the assembly; each brick
93
124
  * (`WorkerStatusDot`, `SetupProgress`, `ResourceBars`, `GitBranchChips`,
94
125
  * `TagChips`) is exported on its own because the table view needs them without
95
126
  * the card.
@@ -116,6 +147,7 @@ export function WorkerCard({
116
147
  rebuilding = false,
117
148
  error,
118
149
  onDismissError,
150
+ menu,
119
151
  actions,
120
152
  footer,
121
153
  viewLabel = "View",
@@ -215,7 +247,7 @@ export function WorkerCard({
215
247
  </div>
216
248
  </div>
217
249
 
218
- {actions}
250
+ {actions ?? (menu && <ActionMenu items={menu} render={{ link: render?.menuLink }} />)}
219
251
  </div>
220
252
 
221
253
  {status.settingUp && (
package/src/index.ts CHANGED
@@ -208,6 +208,13 @@ export type {
208
208
  OAuthProvider,
209
209
  } from "./components/auth"
210
210
 
211
+ // ActionMenu — the `⋯` menu described as data (`{ label, icon, onClick }[]`)
212
+ // rather than assembled from parts: the caller owns what an entry does, the
213
+ // package owns the markup, the states, and the separators (which are normalized,
214
+ // so a conditional entry can't leave a stray rule behind).
215
+ export { ActionMenu } from "./components/action-menu"
216
+ export type { ActionMenuProps, ActionMenuItem, ActionMenuEntry, ActionMenuLinkRender } from "./components/action-menu"
217
+
211
218
  // Kbd — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K). Used by the
212
219
  // palette, exported on its own for anywhere else a shortcut needs drawing.
213
220
  export { Kbd } from "./components/kbd"