@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.
- package/LICENSE +21 -0
- package/README.md +90 -2
- package/package.json +51 -3
- package/src/components/avatar.tsx +35 -0
- package/src/components/badge.tsx +42 -0
- package/src/components/breadcrumb.tsx +102 -0
- package/src/components/button.tsx +56 -0
- package/src/components/card.tsx +92 -0
- package/src/components/checkbox.tsx +27 -0
- package/src/components/color-field.tsx +248 -0
- package/src/components/color-swatch.tsx +52 -0
- package/src/components/context-menu.tsx +249 -0
- package/src/components/dialog.tsx +137 -0
- package/src/components/dropdown-menu.tsx +249 -0
- package/src/components/empty-state.tsx +32 -0
- package/src/components/input.tsx +20 -0
- package/src/components/label.tsx +18 -0
- package/src/components/navigation-menu.tsx +144 -0
- package/src/components/number-field.tsx +428 -0
- package/src/components/popover.tsx +82 -0
- package/src/components/select.tsx +201 -0
- package/src/components/separator.tsx +25 -0
- package/src/components/sheet.tsx +124 -0
- package/src/components/side-panel.tsx +166 -0
- package/src/components/sidebar.tsx +735 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +75 -0
- package/src/components/snackbar.tsx +141 -0
- package/src/components/status-bar.tsx +38 -0
- package/src/components/tabs.tsx +78 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/toggle-group.tsx +86 -0
- package/src/components/toggle.tsx +43 -0
- package/src/components/toolbar.tsx +307 -0
- package/src/components/tooltip.tsx +54 -0
- package/src/graphics/adsr.ts +70 -0
- package/src/graphics/curve-editor.tsx +525 -0
- package/src/graphics/curve-edits.ts +129 -0
- package/src/graphics/index.ts +16 -0
- package/src/graphics/scale.ts +22 -0
- package/src/graphics/use-canvas.ts +56 -0
- package/src/graphics/use-tokens.ts +32 -0
- package/src/icons.ts +3 -0
- package/src/index.css +199 -0
- package/src/index.ts +37 -0
- package/src/lib/use-mobile.ts +18 -0
- 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
|
+
}
|