@preset.nz/ux-kit 0.0.0-stage → 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -2
  3. package/package.json +51 -3
  4. package/src/components/avatar.tsx +35 -0
  5. package/src/components/badge.tsx +42 -0
  6. package/src/components/breadcrumb.tsx +102 -0
  7. package/src/components/button.tsx +56 -0
  8. package/src/components/card.tsx +92 -0
  9. package/src/components/checkbox.tsx +27 -0
  10. package/src/components/color-field.tsx +248 -0
  11. package/src/components/color-swatch.tsx +52 -0
  12. package/src/components/context-menu.tsx +249 -0
  13. package/src/components/dialog.tsx +137 -0
  14. package/src/components/dropdown-menu.tsx +249 -0
  15. package/src/components/empty-state.tsx +32 -0
  16. package/src/components/input.tsx +20 -0
  17. package/src/components/label.tsx +18 -0
  18. package/src/components/navigation-menu.tsx +144 -0
  19. package/src/components/number-field.tsx +428 -0
  20. package/src/components/popover.tsx +82 -0
  21. package/src/components/select.tsx +201 -0
  22. package/src/components/separator.tsx +25 -0
  23. package/src/components/sheet.tsx +124 -0
  24. package/src/components/side-panel.tsx +166 -0
  25. package/src/components/sidebar.tsx +735 -0
  26. package/src/components/skeleton.tsx +14 -0
  27. package/src/components/slider.tsx +75 -0
  28. package/src/components/snackbar.tsx +141 -0
  29. package/src/components/status-bar.tsx +38 -0
  30. package/src/components/tabs.tsx +78 -0
  31. package/src/components/textarea.tsx +18 -0
  32. package/src/components/toggle-group.tsx +86 -0
  33. package/src/components/toggle.tsx +43 -0
  34. package/src/components/toolbar.tsx +307 -0
  35. package/src/components/tooltip.tsx +54 -0
  36. package/src/graphics/adsr.ts +70 -0
  37. package/src/graphics/curve-editor.tsx +525 -0
  38. package/src/graphics/curve-edits.ts +129 -0
  39. package/src/graphics/index.ts +16 -0
  40. package/src/graphics/scale.ts +22 -0
  41. package/src/graphics/use-canvas.ts +56 -0
  42. package/src/graphics/use-tokens.ts +32 -0
  43. package/src/icons.ts +3 -0
  44. package/src/index.css +199 -0
  45. package/src/index.ts +37 -0
  46. package/src/lib/use-mobile.ts +18 -0
  47. package/src/lib/utils.ts +6 -0
@@ -0,0 +1,307 @@
1
+ import * as React from "react"
2
+ import { Toolbar as ToolbarPrimitive } from "@base-ui/react/toolbar"
3
+
4
+ import { cn } from "../lib/utils"
5
+ import { buttonVariants } from "./button"
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuShortcut,
11
+ DropdownMenuTrigger,
12
+ } from "./dropdown-menu"
13
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
14
+
15
+ // Toolbar: props and callbacks only, no app state. The rules (native-apps.md,
16
+ // ux-patterns.md): icon only, tooltip with name and shortcut, groups split by
17
+ // separators, toggles show a pressed state, and every item is a command that
18
+ // also lives in the menu. The toolbar is a subset of the menu, not a second
19
+ // home for commands.
20
+ //
21
+ // Two ways in. Data-driven: `<Toolbar groups={[[item, item], [item]]} onCommand={run} />`
22
+ // where `id` is the menu's command id. Composed: `ToolbarGroup`, `ToolbarSeparator`,
23
+ // `ToolbarSpacer` and `ToolbarButton` as children, for anything the table cannot say.
24
+ //
25
+ // Roving focus and arrow-key navigation come from Base UI's Toolbar. Disabled
26
+ // buttons stay focusable and keep their tooltip, which is how a gated item
27
+ // says why it is gated (Oblique's `disabledHint`, M&T's `disabledReason`).
28
+
29
+ /** An entry in a dropdown item's menu. */
30
+ interface ToolbarMenuEntry {
31
+ id: string
32
+ label: string
33
+ shortcut?: string
34
+ /** Default true. */
35
+ enabled?: boolean
36
+ }
37
+
38
+ /** One toolbar item. `id` is the command id shared with the menu. */
39
+ interface ToolbarItemSpec {
40
+ id: string
41
+ label: string
42
+ icon: React.ReactNode
43
+ /** Shown in the tooltip, in the platform's own notation ("⌘S"). */
44
+ shortcut?: string
45
+ /** Default true. */
46
+ enabled?: boolean
47
+ /** Replaces the shortcut in the tooltip while the item is disabled. */
48
+ disabledReason?: string
49
+ /** Present (true or false) makes the item a toggle. Absent means a plain button. */
50
+ pressed?: boolean
51
+ /** Present makes the item a dropdown; picking an entry calls `onCommand(entry.id)`. */
52
+ menu?: ToolbarMenuEntry[]
53
+ }
54
+
55
+ function Toolbar({
56
+ className,
57
+ ...props
58
+ }: React.ComponentProps<typeof ToolbarPrimitive.Root>) {
59
+ return (
60
+ <ToolbarPrimitive.Root
61
+ data-slot="toolbar"
62
+ className={cn(
63
+ "flex items-center gap-1 border-b border-border bg-background px-2 py-1 data-[orientation=vertical]:w-fit data-[orientation=vertical]:flex-col data-[orientation=vertical]:border-r data-[orientation=vertical]:border-b-0",
64
+ className
65
+ )}
66
+ {...props}
67
+ />
68
+ )
69
+ }
70
+
71
+ function ToolbarGroup({
72
+ className,
73
+ ...props
74
+ }: React.ComponentProps<typeof ToolbarPrimitive.Group>) {
75
+ return (
76
+ <ToolbarPrimitive.Group
77
+ data-slot="toolbar-group"
78
+ className={cn("flex items-center gap-0.5 data-[orientation=vertical]:flex-col", className)}
79
+ {...props}
80
+ />
81
+ )
82
+ }
83
+
84
+ function ToolbarSeparator({
85
+ className,
86
+ ...props
87
+ }: React.ComponentProps<typeof ToolbarPrimitive.Separator>) {
88
+ return (
89
+ <ToolbarPrimitive.Separator
90
+ data-slot="toolbar-separator"
91
+ className={cn(
92
+ "mx-1 h-5 w-px shrink-0 bg-border data-[orientation=horizontal]:mx-0 data-[orientation=horizontal]:my-1 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-5",
93
+ className
94
+ )}
95
+ {...props}
96
+ />
97
+ )
98
+ }
99
+
100
+ /** Pushes what follows to the far end (the right-panel toggle sits there). */
101
+ function ToolbarSpacer({ className, ...props }: React.ComponentProps<"div">) {
102
+ return <div data-slot="toolbar-spacer" className={cn("flex-1", className)} {...props} />
103
+ }
104
+
105
+ function TooltipBody({
106
+ label,
107
+ shortcut,
108
+ disabledReason,
109
+ }: Pick<ToolbarItemSpec, "label" | "shortcut" | "disabledReason">) {
110
+ return (
111
+ <>
112
+ <span>{label}</span>
113
+ {disabledReason ? (
114
+ <span className="opacity-70">{disabledReason}</span>
115
+ ) : shortcut ? (
116
+ <kbd data-slot="kbd" className="rounded-sm bg-background/20 px-1 font-sans text-[11px]">
117
+ {shortcut}
118
+ </kbd>
119
+ ) : null}
120
+ </>
121
+ )
122
+ }
123
+
124
+ const buttonClass = (pressed?: boolean) =>
125
+ cn(
126
+ buttonVariants({ variant: "ghost", size: "icon" }),
127
+ "data-disabled:cursor-default data-disabled:opacity-40 data-disabled:hover:bg-transparent",
128
+ pressed && "bg-muted text-foreground"
129
+ )
130
+
131
+ interface ToolbarButtonProps
132
+ extends Omit<ToolbarItemSpec, "id" | "menu">,
133
+ Omit<React.ComponentProps<typeof ToolbarPrimitive.Button>, "children" | "aria-label"> {}
134
+
135
+ /** One icon button: plain, or a toggle when `pressed` is given. Tooltip shows label and shortcut. */
136
+ function ToolbarButton({
137
+ label,
138
+ icon,
139
+ shortcut,
140
+ enabled = true,
141
+ disabledReason,
142
+ pressed,
143
+ className,
144
+ ...props
145
+ }: ToolbarButtonProps) {
146
+ return (
147
+ <Tooltip>
148
+ <TooltipTrigger
149
+ render={
150
+ <ToolbarPrimitive.Button
151
+ data-slot="toolbar-button"
152
+ aria-label={label}
153
+ aria-pressed={pressed}
154
+ disabled={!enabled}
155
+ className={cn(buttonClass(pressed), className)}
156
+ {...props}
157
+ />
158
+ }
159
+ >
160
+ {icon}
161
+ </TooltipTrigger>
162
+ <TooltipContent side="bottom">
163
+ <TooltipBody
164
+ label={label}
165
+ shortcut={shortcut}
166
+ disabledReason={enabled ? undefined : disabledReason}
167
+ />
168
+ </TooltipContent>
169
+ </Tooltip>
170
+ )
171
+ }
172
+
173
+ interface ToolbarDropdownProps extends Omit<ToolbarItemSpec, "id" | "menu" | "pressed"> {
174
+ entries: ToolbarMenuEntry[]
175
+ onCommand?: (id: string) => void
176
+ }
177
+
178
+ /** An icon button that opens a menu of commands. Entries are menu items, so they show shortcuts too. */
179
+ function ToolbarDropdown({
180
+ label,
181
+ icon,
182
+ shortcut,
183
+ enabled = true,
184
+ disabledReason,
185
+ entries,
186
+ onCommand,
187
+ }: ToolbarDropdownProps) {
188
+ return (
189
+ <DropdownMenu>
190
+ <Tooltip>
191
+ <TooltipTrigger
192
+ render={
193
+ <DropdownMenuTrigger
194
+ render={
195
+ <ToolbarPrimitive.Button
196
+ data-slot="toolbar-dropdown"
197
+ aria-label={label}
198
+ disabled={!enabled}
199
+ className={buttonClass()}
200
+ />
201
+ }
202
+ />
203
+ }
204
+ >
205
+ {icon}
206
+ </TooltipTrigger>
207
+ <TooltipContent side="bottom">
208
+ <TooltipBody
209
+ label={label}
210
+ shortcut={shortcut}
211
+ disabledReason={enabled ? undefined : disabledReason}
212
+ />
213
+ </TooltipContent>
214
+ </Tooltip>
215
+ <DropdownMenuContent align="start">
216
+ {entries.map((e) => (
217
+ <DropdownMenuItem
218
+ key={e.id}
219
+ disabled={e.enabled === false}
220
+ onClick={() => onCommand?.(e.id)}
221
+ >
222
+ {e.label}
223
+ {e.shortcut ? <DropdownMenuShortcut>{e.shortcut}</DropdownMenuShortcut> : null}
224
+ </DropdownMenuItem>
225
+ ))}
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ )
229
+ }
230
+
231
+ /** Renders one spec as a button, toggle or dropdown. */
232
+ function ToolbarItem({
233
+ item,
234
+ onCommand,
235
+ }: {
236
+ item: ToolbarItemSpec
237
+ onCommand?: (id: string) => void
238
+ }) {
239
+ if (item.menu) {
240
+ return <ToolbarDropdown {...item} entries={item.menu} onCommand={onCommand} />
241
+ }
242
+ return <ToolbarButton {...item} onClick={() => onCommand?.(item.id)} />
243
+ }
244
+
245
+ interface ToolbarItemsProps
246
+ extends Omit<React.ComponentProps<typeof Toolbar>, "children"> {
247
+ /** Groups of items; a separator is drawn between groups. */
248
+ groups: ToolbarItemSpec[][]
249
+ /** Called with the item's (or menu entry's) id. The app runs the same command the menu does. */
250
+ onCommand?: (id: string) => void
251
+ /** Pinned to the far left, before the groups (the left-panel toggle). */
252
+ leading?: ToolbarItemSpec[]
253
+ /** Pinned to the far right (the right-panel toggle). */
254
+ trailing?: ToolbarItemSpec[]
255
+ /** Extra content between the groups and the trailing items: a slot for anything custom. */
256
+ children?: React.ReactNode
257
+ }
258
+
259
+ /** The data-driven form: a toolbar from an item table. */
260
+ function ToolbarItems({
261
+ groups,
262
+ onCommand,
263
+ leading,
264
+ trailing,
265
+ children,
266
+ ...props
267
+ }: ToolbarItemsProps) {
268
+ const lead = leading?.length ? [leading] : []
269
+ const all = [...lead, ...groups.filter((g) => g.length > 0)]
270
+ return (
271
+ <Toolbar {...props}>
272
+ {all.map((group, i) => (
273
+ <React.Fragment key={i}>
274
+ {i > 0 && <ToolbarSeparator />}
275
+ <ToolbarGroup>
276
+ {group.map((item) => (
277
+ <ToolbarItem key={item.id} item={item} onCommand={onCommand} />
278
+ ))}
279
+ </ToolbarGroup>
280
+ </React.Fragment>
281
+ ))}
282
+ {children}
283
+ {trailing?.length ? (
284
+ <>
285
+ <ToolbarSpacer />
286
+ <ToolbarGroup>
287
+ {trailing.map((item) => (
288
+ <ToolbarItem key={item.id} item={item} onCommand={onCommand} />
289
+ ))}
290
+ </ToolbarGroup>
291
+ </>
292
+ ) : null}
293
+ </Toolbar>
294
+ )
295
+ }
296
+
297
+ export {
298
+ Toolbar,
299
+ ToolbarButton,
300
+ ToolbarDropdown,
301
+ ToolbarGroup,
302
+ ToolbarItem,
303
+ ToolbarItems,
304
+ ToolbarSeparator,
305
+ ToolbarSpacer,
306
+ }
307
+ export type { ToolbarItemSpec, ToolbarMenuEntry }
@@ -0,0 +1,54 @@
1
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
2
+
3
+ import { cn } from "../lib/utils"
4
+
5
+ // Base UI: `delay` (ms) replaces Radix's `delayDuration`. Triggers take
6
+ // `render={<Button />}` where Radix used `asChild`.
7
+ function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
8
+ return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />
9
+ }
10
+
11
+ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
12
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
13
+ }
14
+
15
+ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
16
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
17
+ }
18
+
19
+ function TooltipContent({
20
+ className,
21
+ side = "top",
22
+ sideOffset = 4,
23
+ align = "center",
24
+ alignOffset = 0,
25
+ children,
26
+ ...props
27
+ }: TooltipPrimitive.Popup.Props &
28
+ Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
29
+ return (
30
+ <TooltipPrimitive.Portal>
31
+ <TooltipPrimitive.Positioner
32
+ align={align}
33
+ alignOffset={alignOffset}
34
+ side={side}
35
+ sideOffset={sideOffset}
36
+ className="isolate z-50"
37
+ >
38
+ <TooltipPrimitive.Popup
39
+ data-slot="tooltip-content"
40
+ className={cn(
41
+ "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
42
+ className
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
48
+ </TooltipPrimitive.Popup>
49
+ </TooltipPrimitive.Positioner>
50
+ </TooltipPrimitive.Portal>
51
+ )
52
+ }
53
+
54
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
@@ -0,0 +1,70 @@
1
+ import { clamp, type Curve } from "@preset.nz/math"
2
+
3
+ import type { CurveConstraint } from "./curve-edits"
4
+
5
+ /** An ADSR envelope's four controls. Times are in the curve's x units; sustain is a level, 0..1. */
6
+ export interface Adsr {
7
+ attack: number
8
+ decay: number
9
+ sustain: number
10
+ release: number
11
+ }
12
+
13
+ /**
14
+ * The curve an ADSR draws: start at rest, peak, sustain level, end at rest, with the sustain
15
+ * mark on the third point. Shard's sliders and the editor both write these four points.
16
+ */
17
+ export function adsrCurve({ attack, decay, sustain, release }: Adsr): Curve {
18
+ const a = Math.max(0, attack)
19
+ const d = Math.max(0, decay)
20
+ const r = Math.max(0, release)
21
+ const s = clamp(sustain, 0, 1)
22
+ return {
23
+ points: [
24
+ { x: 0, y: 0, basis: "linear" },
25
+ { x: a, y: 1, basis: "linear" },
26
+ { x: a + d, y: s, basis: "linear" },
27
+ { x: a + d + r, y: 0, basis: "linear" },
28
+ ],
29
+ sustain: 2,
30
+ }
31
+ }
32
+
33
+ /** The ADSR a curve draws, or `null` when it no longer has that shape. */
34
+ export function adsrOf(curve: Curve): Adsr | null {
35
+ const [p0, p1, p2, p3] = curve.points
36
+ if (curve.points.length !== 4 || !p0 || !p1 || !p2 || !p3 || curve.sustain !== 2) return null
37
+ if (p0.x !== 0 || p0.y !== 0 || p1.y !== 1 || p3.y !== 0) return null
38
+ return { attack: p1.x - p0.x, decay: p2.x - p1.x, sustain: p2.y, release: p3.x - p2.x }
39
+ }
40
+
41
+ /**
42
+ * The limits that keep a curve an ADSR. No points are added or removed and sustain stays on the
43
+ * third point. The start is pinned at rest, the peak's level at 1 and the end's at rest. Moving
44
+ * a point changes only the stage that ends there, and the later points ride along, so a longer
45
+ * attack doesn't eat the decay. The whole shape stays within `maxX`.
46
+ */
47
+ export function constrainAdsr(maxX = Infinity): CurveConstraint {
48
+ return (next, prev) => {
49
+ if (next.points.length !== 4 || prev.points.length !== 4 || next.sustain !== 2) return null
50
+ const was = durations(prev)
51
+ const now = durations(next)
52
+ // The stage whose end moved takes the new length; the others keep theirs.
53
+ const moved = [1, 2, 3].find((i) => next.points[i]!.x !== prev.points[i]!.x)
54
+ const len = was.map((d, k) => (moved === k + 1 ? Math.max(0, now[k]!) : d))
55
+ const over = len[0]! + len[1]! + len[2]! - maxX
56
+ if (over > 0 && moved !== undefined) len[moved - 1] = Math.max(0, len[moved - 1]! - over)
57
+ const ys = [0, 1, clamp(next.points[2]!.y, 0, 1), 0]
58
+ let x = 0
59
+ const points = next.points.map((p, i) => {
60
+ if (i > 0) x += len[i - 1]!
61
+ return { ...p, x, y: ys[i]! }
62
+ })
63
+ return { points, sustain: 2 }
64
+ }
65
+ }
66
+
67
+ function durations(curve: Curve): number[] {
68
+ const p = curve.points
69
+ return [p[1]!.x - p[0]!.x, p[2]!.x - p[1]!.x, p[3]!.x - p[2]!.x]
70
+ }