@preset.nz/ux-kit 0.0.0-stage → 0.1.1

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