@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,428 @@
1
+ import * as React from "react"
2
+ import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
3
+
4
+ import { cn } from "../lib/utils"
5
+ import { ArrowsHorizontalIcon } from "../icons"
6
+
7
+ // NumberField: a number you can drag. The label is the scrub handle (Base UI's ScrubArea);
8
+ // click it, or tab in, to type. Behaviour is Oblique's InputNumber on Base UI's NumberField:
9
+ // Shift is coarse (x10), Alt/Option is fine (x0.1), arrows step the same way, a typed value
10
+ // commits on blur or Enter and Escape puts the old value back, and an `integer` field never
11
+ // goes fractional under Alt (Oblique's integerStepFix). Props and callbacks only.
12
+ //
13
+ // The undo boundary is `onScrubStart` / `onScrubEnd`, as ColorField's `onPickStart/End`:
14
+ // between them, every `onValueChange` has `reason: "scrub"` and the app applies it inside one
15
+ // gesture. Escape during a scrub reverts the field and calls `onScrubCancel` instead of
16
+ // `onScrubEnd`. Typed values arrive once, as `reason: "typed"`, never per keystroke.
17
+ //
18
+ // While scrubbing the field shows only its own value and ignores incoming `value` props: an app
19
+ // that round-trips every change (rhizome gesture, then a refreshed prop) would otherwise fight
20
+ // the pointer with stale values. On release it re-syncs to the prop (a late echo of the scrub's
21
+ // own frames is skipped until the prop reaches the released value, or half a second passes).
22
+ // Outgoing scrub changes are sent at most once per animation frame, the last one flushed on release.
23
+
24
+ type ChangeReason = "scrub" | "step" | "typed"
25
+
26
+ interface NumberChange {
27
+ /** `scrub`: live, between onScrubStart and onScrubEnd. `step`: arrow key, wheel or button. `typed`: committed text. */
28
+ reason: ChangeReason
29
+ }
30
+
31
+ interface NumberFieldProps
32
+ extends Omit<
33
+ React.ComponentProps<"div">,
34
+ "onChange" | "defaultValue" | "children" | "value"
35
+ > {
36
+ value: number
37
+ onValueChange?: (value: number, details: NumberChange) => void
38
+ min?: number
39
+ max?: number
40
+ /** One scrubbed pixel, one arrow key press. Default 1. */
41
+ step?: number
42
+ /** Alt/Option. Default `step / 10`, or `step` when `integer`. */
43
+ smallStep?: number
44
+ /** Shift. Default `step * 10`. */
45
+ largeStep?: number
46
+ /** Whole numbers only: no fractional display, and Alt never goes finer than `step`. */
47
+ integer?: boolean
48
+ /** Most decimal places shown and kept. Default: the places `smallStep` needs. */
49
+ precision?: number
50
+ /** Intl options for the display, merged over the precision and no digit grouping. */
51
+ format?: Intl.NumberFormatOptions
52
+ /** The scrub handle and the input's accessible name. */
53
+ label: string
54
+ /**
55
+ * Where the label sits. `inside` (default): the handle at the left of the box. `column`: the handle
56
+ * is a grid item in the caller's label column (the field spans two columns of a grid, as a
57
+ * subgrid) and the box is the second, so labels and boxes line up across rows. `above`: the handle
58
+ * stacks over a full-width box.
59
+ */
60
+ labelPlacement?: "inside" | "column" | "above"
61
+ /**
62
+ * Classes for the input box when the label sits outside it (`column`, `above`); with `inside` the
63
+ * box is the root, so `className` does it. Panels use it to give every number one width.
64
+ */
65
+ boxClassName?: string
66
+ /** A muted unit after the number, inside the box (`px`, `°`). */
67
+ suffix?: React.ReactNode
68
+ disabled?: boolean
69
+ readOnly?: boolean
70
+ /** The pointer went down on the label and started to move: open the app's undo transaction. */
71
+ onScrubStart?: () => void
72
+ /** The pointer came up after a scrub that changed something: close it. */
73
+ onScrubEnd?: () => void
74
+ /** Escape during a scrub: the field is back at its starting value, discard the transaction. Without it Escape does nothing. */
75
+ onScrubCancel?: () => void
76
+ }
77
+
78
+ /** Decimal places a step implies: 0.1 gives 1, 5 gives 0. */
79
+ function placesOf(step: number): number {
80
+ if (!Number.isFinite(step) || step === 0) return 0
81
+ const s = Math.abs(step).toString()
82
+ if (s.includes("e-")) return Number(s.split("e-")[1])
83
+ const dot = s.indexOf(".")
84
+ return dot === -1 ? 0 : s.length - dot - 1
85
+ }
86
+
87
+ function NumberField({
88
+ value,
89
+ onValueChange,
90
+ min,
91
+ max,
92
+ step = 1,
93
+ smallStep,
94
+ largeStep,
95
+ integer = false,
96
+ precision,
97
+ format,
98
+ label,
99
+ labelPlacement = "inside",
100
+ boxClassName,
101
+ suffix,
102
+ disabled,
103
+ readOnly,
104
+ onScrubStart,
105
+ onScrubEnd,
106
+ onScrubCancel,
107
+ className,
108
+ ...props
109
+ }: NumberFieldProps) {
110
+ const small = smallStep ?? (integer ? step : step / 10)
111
+ const large = largeStep ?? step * 10
112
+ const digits = integer ? 0 : (precision ?? placesOf(small))
113
+ const options = React.useMemo<Intl.NumberFormatOptions>(
114
+ () => ({ maximumFractionDigits: digits, useGrouping: false, ...format }),
115
+ [digits, format]
116
+ )
117
+
118
+ // The field's own value, so a scrub follows the pointer without waiting for the app's round
119
+ // trip. It follows the prop except while a scrub is in flight (and for a moment after it).
120
+ const [local, setLocal] = React.useState<number | null>(value)
121
+ const localRef = React.useRef<number | null>(value)
122
+ const valueRef = React.useRef(value)
123
+ const show = (v: number | null) => {
124
+ localRef.current = v
125
+ setLocal(v)
126
+ }
127
+ // Between the pointer going down on the label and coming up. `started`: it moved far enough
128
+ // to change the value (a plain click is not a scrub).
129
+ const scrub = React.useRef({ down: false, started: false, cancelled: false, from: value })
130
+ const [scrubbing, setScrubbing] = React.useState(false)
131
+ const inputRef = React.useRef<HTMLInputElement>(null)
132
+ // Set when the next blur must not report a typed value: Escape reverted it, or a scrub ended.
133
+ const skipCommit = React.useRef(false)
134
+ // After a release: the value the app is expected to echo back, and when to stop waiting for it.
135
+ const settle = React.useRef<{ v: number; until: number; timer: number } | null>(null)
136
+ // One outgoing scrub change per animation frame.
137
+ const pending = React.useRef<{ v: number | null; raf: number }>({ v: null, raf: 0 })
138
+
139
+ const clearSettle = () => {
140
+ if (settle.current) window.clearTimeout(settle.current.timer)
141
+ settle.current = null
142
+ }
143
+ const dropPending = () => {
144
+ cancelAnimationFrame(pending.current.raf)
145
+ pending.current = { v: null, raf: 0 }
146
+ }
147
+ const flushPending = () => {
148
+ cancelAnimationFrame(pending.current.raf)
149
+ const v = pending.current.v
150
+ pending.current = { v: null, raf: 0 }
151
+ if (v !== null) onValueChange?.(v, { reason: "scrub" })
152
+ }
153
+ const onValueChangeRef = React.useRef(onValueChange)
154
+ React.useEffect(() => {
155
+ onValueChangeRef.current = onValueChange
156
+ })
157
+ const queue = (v: number) => {
158
+ pending.current.v = v
159
+ if (pending.current.raf) return
160
+ pending.current.raf = requestAnimationFrame(() => {
161
+ pending.current.raf = 0
162
+ const p = pending.current.v
163
+ pending.current.v = null
164
+ if (p !== null) onValueChangeRef.current?.(p, { reason: "scrub" })
165
+ })
166
+ }
167
+
168
+ React.useEffect(() => {
169
+ valueRef.current = value
170
+ if (scrub.current.started) return
171
+ const s = settle.current
172
+ if (s && value !== s.v && Date.now() < s.until) return
173
+ clearSettle()
174
+ show(value)
175
+ }, [value])
176
+
177
+ React.useEffect(
178
+ () => () => {
179
+ clearSettle()
180
+ cancelAnimationFrame(pending.current.raf)
181
+ },
182
+ []
183
+ )
184
+
185
+ // Escape while scrubbing. Captured so the app's own Escape (clear selection) doesn't also run.
186
+ React.useEffect(() => {
187
+ if (!scrubbing || !onScrubCancel) return
188
+ const onKey = (e: KeyboardEvent) => {
189
+ const s = scrub.current
190
+ if (e.key !== "Escape" || !s.started || s.cancelled) return
191
+ e.preventDefault()
192
+ e.stopPropagation()
193
+ s.cancelled = true
194
+ dropPending()
195
+ show(s.from)
196
+ onScrubCancel()
197
+ }
198
+ window.addEventListener("keydown", onKey, true)
199
+ return () => window.removeEventListener("keydown", onKey, true)
200
+ }, [scrubbing, onScrubCancel])
201
+
202
+ const change = (v: number | null, d: { reason: string }) => {
203
+ if (d.reason === "scrub") {
204
+ const s = scrub.current
205
+ if (!s.down) {
206
+ clearSettle()
207
+ s.down = true
208
+ s.started = false
209
+ s.cancelled = false
210
+ s.from = valueRef.current
211
+ }
212
+ if (s.cancelled || v === null) return
213
+ if (!s.started) {
214
+ s.started = true
215
+ setScrubbing(true)
216
+ onScrubStart?.()
217
+ }
218
+ show(v)
219
+ queue(v)
220
+ } else if (d.reason === "keyboard" || d.reason === "wheel" || d.reason.endsWith("-press")) {
221
+ if (v === null) return
222
+ show(v)
223
+ onValueChange?.(v, { reason: "step" })
224
+ } else {
225
+ // Typing, paste, blur formatting: keep the text, tell the app on commit.
226
+ show(v)
227
+ }
228
+ }
229
+
230
+ const commit = (v: number | null, d: { reason: string }) => {
231
+ if (d.reason === "scrub") {
232
+ const s = scrub.current
233
+ const { started, cancelled } = s
234
+ s.down = false
235
+ s.started = false
236
+ s.cancelled = false
237
+ setScrubbing(false)
238
+ if (started) {
239
+ // A dragged label must not leave the input focused: Cmd+Z would then undo its text.
240
+ skipCommit.current = true
241
+ inputRef.current?.blur()
242
+ if (cancelled) {
243
+ dropPending()
244
+ show(valueRef.current)
245
+ } else {
246
+ flushPending()
247
+ // Re-sync to the prop, once it has caught up with what was scrubbed.
248
+ const v = localRef.current
249
+ if (v !== null && v !== valueRef.current) {
250
+ clearSettle()
251
+ settle.current = {
252
+ v,
253
+ until: Date.now() + 500,
254
+ timer: window.setTimeout(() => {
255
+ settle.current = null
256
+ show(valueRef.current)
257
+ }, 500),
258
+ }
259
+ }
260
+ onScrubEnd?.()
261
+ }
262
+ }
263
+ return
264
+ }
265
+ if (d.reason === "input-blur" || d.reason === "input-clear") {
266
+ if (skipCommit.current) {
267
+ skipCommit.current = false
268
+ show(valueRef.current)
269
+ return
270
+ }
271
+ if (v === null) show(valueRef.current)
272
+ else if (v !== valueRef.current) onValueChange?.(v, { reason: "typed" })
273
+ }
274
+ }
275
+
276
+ const outside = labelPlacement !== "inside"
277
+ const input = (
278
+ <NumberFieldPrimitive.Input
279
+ ref={inputRef}
280
+ data-slot="number-field-input"
281
+ aria-label={label}
282
+ className="h-full w-full min-w-0 bg-transparent px-2 text-right tabular-nums outline-none selection:bg-selection/40 disabled:cursor-not-allowed"
283
+ onFocus={(e) => {
284
+ skipCommit.current = false
285
+ e.currentTarget.select()
286
+ }}
287
+ onKeyDown={(e) => {
288
+ if (e.key === "Enter") {
289
+ e.preventDefault()
290
+ e.currentTarget.blur()
291
+ } else if (e.key === "Escape") {
292
+ skipCommit.current = true
293
+ show(valueRef.current)
294
+ e.currentTarget.blur()
295
+ }
296
+ }}
297
+ />
298
+ )
299
+ const unit = suffix != null && (
300
+ <span data-slot="number-field-suffix" className="flex shrink-0 items-center pr-2 text-muted-foreground select-none">
301
+ {suffix}
302
+ </span>
303
+ )
304
+ const boxClass =
305
+ "flex h-8 min-w-0 flex-1 items-stretch rounded-none border border-input bg-transparent text-xs transition-colors focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/50 data-disabled:opacity-50 dark:bg-input/30"
306
+
307
+ return (
308
+ <NumberFieldPrimitive.Root
309
+ data-slot="number-field"
310
+ value={local}
311
+ onValueChange={change}
312
+ onValueCommitted={commit}
313
+ min={min}
314
+ max={max}
315
+ step={step}
316
+ smallStep={small}
317
+ largeStep={large}
318
+ format={options}
319
+ disabled={disabled}
320
+ readOnly={readOnly}
321
+ locale="en-NZ"
322
+ className={cn(
323
+ "min-w-0",
324
+ !outside && `group/number-field relative ${boxClass}`,
325
+ labelPlacement === "column" && "col-span-2 grid grid-cols-subgrid items-center data-disabled:opacity-50",
326
+ labelPlacement === "above" && "flex flex-col gap-1 data-disabled:opacity-50",
327
+ className
328
+ )}
329
+ {...props}
330
+ >
331
+ <NumberFieldPrimitive.ScrubArea
332
+ data-slot="number-field-label"
333
+ title={outside ? label : undefined}
334
+ pixelSensitivity={pixelsPerStep(min, max, step)}
335
+ className={cn(
336
+ outside
337
+ ? "block min-w-0 truncate text-[11px] font-medium tracking-wide text-muted-foreground select-none"
338
+ : "flex shrink-0 items-center border-r border-input px-2 text-muted-foreground select-none",
339
+ disabled || readOnly
340
+ ? "cursor-default"
341
+ : cn(
342
+ "cursor-ew-resize hover:text-foreground data-scrubbing:text-foreground",
343
+ !outside && "hover:bg-muted data-scrubbing:bg-selection/20"
344
+ )
345
+ )}
346
+ >
347
+ {label}
348
+ <NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-sm">
349
+ <ArrowsHorizontalIcon className="size-4 text-foreground" weight="bold" />
350
+ </NumberFieldPrimitive.ScrubAreaCursor>
351
+ </NumberFieldPrimitive.ScrubArea>
352
+ {outside ? (
353
+ <div className={cn(boxClass, boxClassName)}>
354
+ {input}
355
+ {unit}
356
+ </div>
357
+ ) : (
358
+ <>
359
+ {input}
360
+ {unit}
361
+ </>
362
+ )}
363
+ </NumberFieldPrimitive.Root>
364
+ )
365
+ }
366
+
367
+ const AXES = ["X", "Y", "Z", "W"]
368
+
369
+ interface VectorFieldProps
370
+ extends Omit<
371
+ NumberFieldProps,
372
+ "value" | "onValueChange" | "label" | "className"
373
+ > {
374
+ value: number[]
375
+ /** The whole vector with the changed component replaced. */
376
+ onValueChange?: (value: number[], details: NumberChange) => void
377
+ /** One per component. Default X, Y, Z, W, then 1, 2, 3. */
378
+ labels?: string[]
379
+ /** `row` puts the fields side by side (default), `column` stacks them. */
380
+ orientation?: "row" | "column"
381
+ className?: string
382
+ }
383
+
384
+ /** N NumberFields with axis labels. The scrub callbacks are the single field's, for whichever component is dragged. */
385
+ function VectorField({
386
+ value,
387
+ onValueChange,
388
+ labels,
389
+ orientation = "row",
390
+ className,
391
+ ...props
392
+ }: VectorFieldProps) {
393
+ return (
394
+ <div
395
+ data-slot="vector-field"
396
+ className={cn(
397
+ "flex min-w-0 gap-1",
398
+ orientation === "column" && "flex-col",
399
+ className
400
+ )}
401
+ >
402
+ {value.map((x, i) => (
403
+ <NumberField
404
+ key={i}
405
+ {...props}
406
+ label={labels?.[i] ?? (value.length <= AXES.length ? AXES[i] : String(i + 1))}
407
+ value={x}
408
+ onValueChange={(n, d) =>
409
+ onValueChange?.(value.map((v, j) => (j === i ? n : v)), d)
410
+ }
411
+ />
412
+ ))}
413
+ </div>
414
+ )
415
+ }
416
+
417
+ export { NumberField, VectorField }
418
+ export type { NumberChange, NumberFieldProps, VectorFieldProps }
419
+
420
+ /**
421
+ * Pixels of drag per step, so a full-range scrub is about 300px whatever the step: a 0–8 field
422
+ * with 0.25 steps moves one step per ~9px instead of per pixel, which read as jumping.
423
+ * Unbounded ranges keep one step per pixel.
424
+ */
425
+ function pixelsPerStep(min: number | undefined, max: number | undefined, step: number | undefined): number {
426
+ if (min == null || max == null || !step || max <= min) return 1
427
+ return Math.min(12, Math.max(1, Math.round((300 * step) / (max - min))))
428
+ }
@@ -0,0 +1,82 @@
1
+ import * as React from "react"
2
+ import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
3
+
4
+ import { cn } from "../lib/utils"
5
+
6
+ function Popover({ ...props }: PopoverPrimitive.Root.Props) {
7
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />
8
+ }
9
+
10
+ function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
11
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
12
+ }
13
+
14
+ function PopoverContent({
15
+ className,
16
+ align = "center",
17
+ alignOffset = 0,
18
+ side = "bottom",
19
+ sideOffset = 4,
20
+ ...props
21
+ }: PopoverPrimitive.Popup.Props &
22
+ Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
23
+ return (
24
+ <PopoverPrimitive.Portal>
25
+ <PopoverPrimitive.Positioner
26
+ align={align}
27
+ alignOffset={alignOffset}
28
+ side={side}
29
+ sideOffset={sideOffset}
30
+ className="isolate z-50"
31
+ >
32
+ <PopoverPrimitive.Popup
33
+ data-slot="popover-content"
34
+ className={cn(
35
+ "z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 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-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",
36
+ className
37
+ )}
38
+ {...props}
39
+ />
40
+ </PopoverPrimitive.Positioner>
41
+ </PopoverPrimitive.Portal>
42
+ )
43
+ }
44
+
45
+ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
46
+ return (
47
+ <div
48
+ data-slot="popover-header"
49
+ className={cn("flex flex-col gap-1 text-sm", className)}
50
+ {...props}
51
+ />
52
+ )
53
+ }
54
+
55
+ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
56
+ return (
57
+ <PopoverPrimitive.Title
58
+ data-slot="popover-title"
59
+ className={cn("font-medium", className)}
60
+ {...props}
61
+ />
62
+ )
63
+ }
64
+
65
+ function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
66
+ return (
67
+ <PopoverPrimitive.Description
68
+ data-slot="popover-description"
69
+ className={cn("text-muted-foreground", className)}
70
+ {...props}
71
+ />
72
+ )
73
+ }
74
+
75
+ export {
76
+ Popover,
77
+ PopoverContent,
78
+ PopoverDescription,
79
+ PopoverHeader,
80
+ PopoverTitle,
81
+ PopoverTrigger,
82
+ }