@preset.nz/facets 0.1.3 → 0.3.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.
@@ -3,9 +3,13 @@
3
3
  * components plus a single `registerBuiltinRenderers` entry point. Splitting
4
4
  * each renderer into its own file would be over-organisation for code that
5
5
  * never hot-reloads in isolation. */
6
- import { useMemo } from "react"
6
+ import { useEffect, useId, useMemo, useRef } from "react"
7
+ import { Checkbox } from "@/components/ui/checkbox"
8
+ import { ColorField } from "@/components/ui/color-field"
7
9
  import { Input } from "@/components/ui/input"
8
10
  import { Label } from "@/components/ui/label"
11
+ import { NumberField } from "@/components/ui/number-field"
12
+ import { PathField } from "@/components/ui/path-field"
9
13
  import {
10
14
  Select,
11
15
  SelectContent,
@@ -13,26 +17,30 @@ import {
13
17
  SelectTrigger,
14
18
  SelectValue,
15
19
  } from "@/components/ui/select"
16
- import { Checkbox } from "@/components/ui/checkbox"
17
20
  import { Separator } from "@/components/ui/separator"
21
+ import { Slider } from "@/components/ui/slider"
22
+ import { filtersOf, useFileHandlers, useMissing, useRecent } from "./file-handlers"
23
+ import { COLUMN_ROW, NUMBER_WIDTH, NUMBER_WIDTH_INSIDE, useLabelLayout } from "./layout"
18
24
  import { registerFieldRenderer } from "./registry"
19
25
  import type {
20
26
  CheckboxFieldDef,
21
27
  ColorFieldDef,
22
28
  FieldRenderer,
23
29
  FieldRendererProps,
30
+ FileFieldDef,
31
+ LabelFieldDef,
24
32
  NumberFieldDef,
33
+ PanelView,
25
34
  SelectFieldDef,
35
+ SeparatorFieldDef,
26
36
  SliderFieldDef,
27
- TextFieldDef,
28
37
  TextareaFieldDef,
29
- FileFieldDef,
30
- LabelFieldDef,
31
- SeparatorFieldDef,
38
+ TextFieldDef,
32
39
  VectorFieldDef,
33
40
  } from "./types"
34
41
 
35
- function ReadOnlyText({ children }: { children: React.ReactNode }) {
42
+ /** A read-only value at the height of an editable row, so read-only and editable panels line up. */
43
+ export function ReadOnlyText({ children }: { children: React.ReactNode }) {
36
44
  return (
37
45
  <div className="min-h-8 px-0 py-1 text-xs text-foreground tabular-nums break-all">
38
46
  {children}
@@ -44,17 +52,68 @@ function Empty() {
44
52
  return <span className="text-muted-foreground">—</span>
45
53
  }
46
54
 
47
- function FieldShell({
55
+ /**
56
+ * A field's label and control, placed as the panel's label layout and view say. Exported so a
57
+ * custom renderer sits on the same label column as the built-ins.
58
+ */
59
+ export function FieldShell({
48
60
  label,
61
+ view,
62
+ top,
63
+ above,
64
+ htmlFor,
49
65
  children,
50
66
  }: {
51
67
  label?: string
68
+ view?: PanelView
69
+ /** The id of the control the label names, so clicking the label focuses it. */
70
+ htmlFor?: string
71
+ /** In a label column: align the label to the top of a tall control. */
72
+ top?: boolean
73
+ /** The label sits above the control even in a label column (a wide control, like a vector of four). */
74
+ above?: boolean
52
75
  children: React.ReactNode
53
76
  }) {
77
+ const layout = useLabelLayout()
78
+ // Collapsed: one compact row, label in a fixed column beside the control.
79
+ if (view === "collapsed") {
80
+ return (
81
+ <div className="grid grid-cols-[5.5rem_minmax(0,1fr)] items-center gap-2">
82
+ <Label
83
+ htmlFor={htmlFor}
84
+ className="truncate text-[11px] font-medium text-muted-foreground tracking-wide"
85
+ >
86
+ {label}
87
+ </Label>
88
+ <div className="min-w-0">{children}</div>
89
+ </div>
90
+ )
91
+ }
92
+ // Column: one label column shared by the panel, the control on a common edge.
93
+ if (layout === "column" && !above) {
94
+ return (
95
+ <div className={top ? COLUMN_ROW.replace("items-center", "items-start") : COLUMN_ROW}>
96
+ <Label
97
+ htmlFor={htmlFor}
98
+ title={label}
99
+ className={
100
+ "block truncate text-[11px] font-medium text-muted-foreground tracking-wide" +
101
+ (top ? " pt-2" : "")
102
+ }
103
+ >
104
+ {label}
105
+ </Label>
106
+ <div className="min-w-0">{children}</div>
107
+ </div>
108
+ )
109
+ }
54
110
  return (
55
111
  <div className="flex flex-col gap-1">
56
112
  {label && (
57
- <Label className="text-[11px] font-medium text-muted-foreground tracking-wide">
113
+ <Label
114
+ htmlFor={htmlFor}
115
+ className="text-[11px] font-medium text-muted-foreground tracking-wide"
116
+ >
58
117
  {label}
59
118
  </Label>
60
119
  )}
@@ -63,6 +122,14 @@ function FieldShell({
63
122
  )
64
123
  }
65
124
 
125
+ /** Decimal places a step implies: 0.05 gives 2, 5 gives 0. */
126
+ function placesOf(step: number): number {
127
+ const s = String(step)
128
+ if (s.includes("e-")) return Number(s.split("e-")[1])
129
+ const dot = s.indexOf(".")
130
+ return dot === -1 ? 0 : s.length - dot - 1
131
+ }
132
+
66
133
  function formatPrimitive(value: unknown): string {
67
134
  if (value == null || value === "") return ""
68
135
  if (typeof value === "boolean") return value ? "Yes" : "No"
@@ -71,15 +138,10 @@ function formatPrimitive(value: unknown): string {
71
138
  return String(value)
72
139
  }
73
140
 
74
- const TextRenderer: FieldRenderer<TextFieldDef> = ({
75
- field,
76
- value,
77
- disabled,
78
- onChange,
79
- }) => {
141
+ const TextRenderer: FieldRenderer<TextFieldDef> = ({ field, value, disabled, onChange, view }) => {
80
142
  const display = formatPrimitive(value)
81
143
  return (
82
- <FieldShell label={field.label ?? field.id}>
144
+ <FieldShell label={field.label ?? field.id} view={view}>
83
145
  {onChange ? (
84
146
  <Input
85
147
  aria-label={field.label ?? field.id}
@@ -104,15 +166,16 @@ const TextareaRenderer: FieldRenderer<TextareaFieldDef> = ({
104
166
  value,
105
167
  disabled,
106
168
  onChange,
169
+ view,
107
170
  }) => {
108
171
  const display = formatPrimitive(value)
109
172
  return (
110
- <FieldShell label={field.label ?? field.id}>
173
+ <FieldShell label={field.label ?? field.id} view={view} top>
111
174
  {onChange ? (
112
175
  <textarea
113
176
  aria-label={field.label ?? field.id}
114
177
  value={display}
115
- rows={field.rows ?? 3}
178
+ rows={view === "collapsed" ? 1 : (field.rows ?? 3)}
116
179
  disabled={disabled}
117
180
  placeholder={field.placeholder}
118
181
  onChange={(e) => onChange(e.target.value)}
@@ -136,37 +199,51 @@ const NumberRenderer: FieldRenderer<NumberFieldDef> = ({
136
199
  value,
137
200
  disabled,
138
201
  onChange,
202
+ onGestureBegin,
203
+ onGestureEnd,
204
+ onGestureCancel,
139
205
  }) => {
206
+ const layout = useLabelLayout()
140
207
  const num =
141
- typeof value === "number"
142
- ? value
143
- : value != null && value !== ""
144
- ? Number(value)
145
- : null
146
- const display =
147
- num != null && Number.isFinite(num) ? num.toLocaleString() : ""
148
- return (
149
- <FieldShell label={field.label ?? field.id}>
150
- {onChange ? (
151
- <Input
152
- type="number"
153
- aria-label={field.label ?? field.id}
154
- value={num != null && Number.isFinite(num) ? String(num) : ""}
155
- min={field.min}
156
- max={field.max}
157
- step={field.step ?? 1}
158
- disabled={disabled}
159
- onChange={(e) => onChange(Number(e.target.value))}
160
- />
161
- ) : display ? (
162
- <ReadOnlyText>{display}</ReadOnlyText>
163
- ) : (
164
- <ReadOnlyText>
165
- <Empty />
166
- </ReadOnlyText>
167
- )}
168
- </FieldShell>
208
+ typeof value === "number" ? value : value != null && value !== "" ? Number(value) : null
209
+ const finite = num != null && Number.isFinite(num)
210
+ const label = field.label ?? field.id
211
+ if (!onChange) {
212
+ return (
213
+ <FieldShell label={label}>
214
+ <ReadOnlyText>{finite ? num.toLocaleString() : <Empty />}</ReadOnlyText>
215
+ </FieldShell>
216
+ )
217
+ }
218
+ // The field's own label is the scrub handle, so the panel draws none beside it: in "column" and
219
+ // "stacked" it tells the field where to put it. Hosts that ignore labelPlacement keep it inside.
220
+ const placed =
221
+ layout === "auto"
222
+ ? {}
223
+ : {
224
+ labelPlacement: layout === "column" ? ("column" as const) : ("above" as const),
225
+ boxClassName: NUMBER_WIDTH,
226
+ suffix: field.suffix,
227
+ }
228
+ const input = (
229
+ <NumberField
230
+ label={label}
231
+ value={finite ? num : (field.min ?? 0)}
232
+ min={field.min}
233
+ max={field.max}
234
+ step={field.step ?? 1}
235
+ integer={field.integer}
236
+ className={layout === "auto" ? NUMBER_WIDTH_INSIDE : undefined}
237
+ {...placed}
238
+ disabled={disabled}
239
+ onValueChange={(next) => onChange(next)}
240
+ onScrubStart={onGestureBegin}
241
+ onScrubEnd={onGestureEnd}
242
+ onScrubCancel={onGestureCancel}
243
+ />
169
244
  )
245
+ // The field spans the label and control columns of a row of its own.
246
+ return layout === "column" ? <div className={COLUMN_ROW}>{input}</div> : input
170
247
  }
171
248
 
172
249
  const SliderRenderer: FieldRenderer<SliderFieldDef> = ({
@@ -174,26 +251,57 @@ const SliderRenderer: FieldRenderer<SliderFieldDef> = ({
174
251
  value,
175
252
  disabled,
176
253
  onChange,
254
+ view,
255
+ onGestureBegin,
256
+ onGestureEnd,
177
257
  }) => {
178
258
  const num = typeof value === "number" ? value : Number(value ?? 0)
259
+ // A drag is a pointer press that moves the value; arrow keys are plain edits.
260
+ const pressed = useRef(false)
261
+ const dragging = useRef(false)
262
+ const layout = useLabelLayout()
263
+ // With labels in a column or above, the value reads out to the right, on a shared edge.
264
+ const readout = layout !== "auto" && view !== "collapsed"
179
265
  return (
180
- <FieldShell label={field.label ?? field.id}>
266
+ <FieldShell label={field.label ?? field.id} view={view}>
181
267
  {onChange ? (
182
- <input
183
- type="range"
184
- aria-label={field.label ?? field.id}
185
- value={Number.isFinite(num) ? num : (field.min ?? 0)}
186
- min={field.min}
187
- max={field.max}
188
- step={field.step ?? 1}
189
- disabled={disabled}
190
- onChange={(e) => onChange(Number(e.target.value))}
191
- className="w-full"
192
- />
268
+ <div
269
+ className={readout ? "flex min-h-8 items-center gap-2" : "flex min-h-8 items-center"}
270
+ onPointerDownCapture={() => {
271
+ pressed.current = true
272
+ }}
273
+ >
274
+ <Slider
275
+ className="min-w-0 flex-1"
276
+ aria-label={field.label ?? field.id}
277
+ value={Number.isFinite(num) ? num : (field.min ?? 0)}
278
+ min={field.min}
279
+ max={field.max}
280
+ step={field.step ?? 1}
281
+ disabled={disabled}
282
+ onValueChange={(next) => {
283
+ if (pressed.current && !dragging.current) {
284
+ dragging.current = true
285
+ onGestureBegin?.()
286
+ }
287
+ onChange(Array.isArray(next) ? next[0] : next)
288
+ }}
289
+ onValueCommitted={() => {
290
+ pressed.current = false
291
+ if (dragging.current) {
292
+ dragging.current = false
293
+ onGestureEnd?.()
294
+ }
295
+ }}
296
+ />
297
+ {readout && (
298
+ <span className="w-12 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
299
+ {Number.isFinite(num) ? num.toFixed(placesOf(field.step ?? 1)) : ""}
300
+ </span>
301
+ )}
302
+ </div>
193
303
  ) : (
194
- <ReadOnlyText>
195
- {Number.isFinite(num) ? num.toLocaleString() : <Empty />}
196
- </ReadOnlyText>
304
+ <ReadOnlyText>{Number.isFinite(num) ? num.toLocaleString() : <Empty />}</ReadOnlyText>
197
305
  )}
198
306
  </FieldShell>
199
307
  )
@@ -205,6 +313,7 @@ const SelectRenderer: FieldRenderer<SelectFieldDef> = ({
205
313
  disabled,
206
314
  onChange,
207
315
  ctx,
316
+ view,
208
317
  }) => {
209
318
  const options = useMemo(
210
319
  () => (field.optionsProvider ? field.optionsProvider(ctx) : (field.options ?? [])),
@@ -214,21 +323,15 @@ const SelectRenderer: FieldRenderer<SelectFieldDef> = ({
214
323
  const matched = options.find((o) => o.value === current)
215
324
  if (!onChange) {
216
325
  return (
217
- <FieldShell label={field.label ?? field.id}>
218
- <ReadOnlyText>
219
- {matched ? matched.label : current ? current : <Empty />}
220
- </ReadOnlyText>
326
+ <FieldShell label={field.label ?? field.id} view={view}>
327
+ <ReadOnlyText>{matched ? matched.label : current ? current : <Empty />}</ReadOnlyText>
221
328
  </FieldShell>
222
329
  )
223
330
  }
224
331
  return (
225
- <FieldShell label={field.label ?? field.id}>
226
- <Select
227
- value={current}
228
- onValueChange={(v) => onChange(v)}
229
- disabled={disabled}
230
- >
231
- <SelectTrigger>
332
+ <FieldShell label={field.label ?? field.id} view={view}>
333
+ <Select value={current} onValueChange={(v) => onChange(v)} disabled={disabled}>
334
+ <SelectTrigger className="w-full">
232
335
  {/* Children, not the primitive's own lookup: Base UI's Value shows
233
336
  the raw value unless it's told the label. */}
234
337
  <SelectValue>{matched ? matched.label : current}</SelectValue>
@@ -250,36 +353,32 @@ const ColorRenderer: FieldRenderer<ColorFieldDef> = ({
250
353
  value,
251
354
  disabled,
252
355
  onChange,
356
+ view,
357
+ onGestureBegin,
358
+ onGestureEnd,
253
359
  }) => {
254
360
  const hex = typeof value === "string" && value.length > 0 ? value : null
255
- return (
256
- <FieldShell label={field.label ?? field.id}>
257
- <div className="flex items-center gap-2">
258
- {hex ? (
259
- <span
260
- aria-hidden
261
- className="inline-block size-4 shrink-0 border border-border"
262
- style={{ backgroundColor: hex }}
263
- />
264
- ) : (
265
- <span className="inline-block size-4 shrink-0 border border-dashed border-border" />
266
- )}
267
- {onChange ? (
268
- <Input
269
- type="text"
270
- aria-label={field.label ?? field.id}
271
- value={hex ?? ""}
272
- disabled={disabled}
273
- onChange={(e) => onChange(e.target.value)}
274
- className="font-mono"
275
- />
276
- ) : (
277
- <ReadOnlyText>
278
- <span className="font-mono">{hex ?? <Empty />}</span>
279
- </ReadOnlyText>
280
- )}
281
- </div>
361
+ const label = field.label ?? field.id
362
+ const layout = useLabelLayout()
363
+ // Collapsed rows and a label column keep the label in their column; otherwise the field draws it.
364
+ const inColumn = view === "collapsed" || layout === "column"
365
+ const picker = (
366
+ <ColorField
367
+ label={inColumn ? undefined : label}
368
+ value={hex}
369
+ presets={field.presets}
370
+ disabled={disabled}
371
+ onChange={onChange ? (next) => onChange(next ?? "") : undefined}
372
+ onPickStart={onGestureBegin}
373
+ onPickEnd={onGestureEnd}
374
+ />
375
+ )
376
+ return inColumn ? (
377
+ <FieldShell label={label} view={view}>
378
+ {picker}
282
379
  </FieldShell>
380
+ ) : (
381
+ picker
283
382
  )
284
383
  }
285
384
 
@@ -288,39 +387,132 @@ const CheckboxRenderer: FieldRenderer<CheckboxFieldDef> = ({
288
387
  value,
289
388
  disabled,
290
389
  onChange,
390
+ view,
291
391
  }) => {
292
392
  const checked = Boolean(value)
393
+ const layout = useLabelLayout()
293
394
  if (!onChange) {
294
395
  return (
295
- <FieldShell label={field.label ?? field.id}>
396
+ <FieldShell label={field.label ?? field.id} view={view}>
296
397
  <ReadOnlyText>{checked ? "Yes" : "No"}</ReadOnlyText>
297
398
  </FieldShell>
298
399
  )
299
400
  }
401
+ // Collapsed rows and a label column keep the label in their column, like every other kind.
402
+ if (view === "collapsed" || layout === "column") {
403
+ return (
404
+ <FieldShell label={field.label ?? field.id} view={view}>
405
+ <Checkbox
406
+ checked={checked}
407
+ disabled={disabled}
408
+ onCheckedChange={(next) => onChange(Boolean(next))}
409
+ />
410
+ </FieldShell>
411
+ )
412
+ }
300
413
  return (
301
- <div className="flex items-center gap-2">
414
+ // One line: the box, then its label, which toggles it.
415
+ <Label className="min-h-8 gap-2 text-xs text-foreground">
302
416
  <Checkbox
303
417
  checked={checked}
304
418
  disabled={disabled}
305
419
  onCheckedChange={(next) => onChange(Boolean(next))}
306
420
  />
307
- {field.label && (
308
- <Label className="text-xs text-foreground">{field.label}</Label>
309
- )}
310
- </div>
421
+ {field.label}
422
+ </Label>
311
423
  )
312
424
  }
313
425
 
314
426
  const FileRenderer: FieldRenderer<FileFieldDef> = ({
315
427
  field,
316
428
  value,
429
+ disabled,
430
+ onChange,
431
+ error,
432
+ view,
317
433
  }) => {
318
- const asset = value as { name?: string } | null | undefined
434
+ const label = field.label ?? field.id
435
+ const inputId = useId()
436
+ const h = useFileHandlers()
437
+ const path = typeof value === "string" && value !== "" ? value : null
438
+ const missing = useMissing(path, h)
439
+ const [recent, reloadRecent] = useRecent(field.recentKey, h, path)
440
+
441
+ // What was last committed and not yet seen come back as the value. The host's `write` is the
442
+ // validator and may be asynchronous, so the field cannot tell "accepted" from the call: it
443
+ // sees it when the value prop becomes the committed path. Only then is the path remembered. A
444
+ // refusal (`error`) or any other change of value drops the pending commit.
445
+ const pending = useRef<string | null | undefined>(undefined)
446
+ // biome-ignore lint/correctness/useExhaustiveDependencies: settles when the value changes; the rest is read fresh
447
+ useEffect(() => {
448
+ const committed = pending.current
449
+ pending.current = undefined
450
+ if (committed !== undefined && committed === path && path !== null && field.recentKey) {
451
+ Promise.resolve(h?.remember?.(field.recentKey, path)).then(reloadRecent, () => {})
452
+ }
453
+ }, [path])
454
+ useEffect(() => {
455
+ if (error) pending.current = undefined
456
+ }, [error])
457
+
458
+ // The context menu stays native, and the text menu keeps the field while its text is being
459
+ // edited: whether the input had focus is read before the click that opens the menu moves it.
460
+ const editing = useRef(false)
461
+
462
+ // The old read-only `{ name }` value.
463
+ if (value != null && typeof value === "object") {
464
+ const name = (value as { name?: string }).name
465
+ return (
466
+ <FieldShell label={label} view={view}>
467
+ <ReadOnlyText>{name ? name : <Empty />}</ReadOnlyText>
468
+ </FieldShell>
469
+ )
470
+ }
471
+
472
+ const editable = !!onChange
473
+ const commit = (next: string | null) => {
474
+ pending.current = next
475
+ onChange?.(next)
476
+ }
477
+ const pick = h?.pick
478
+ const menu = h?.contextMenu
479
+ const act = async () => {
480
+ const choice = await menu?.({ missing: missing || path === null, canClear: editable && !!path })
481
+ if (choice === "reveal" && path) {
482
+ try {
483
+ await h?.reveal?.(path, h.base?.())
484
+ } catch {
485
+ // The file went between the check and the click; the next focus check says Missing.
486
+ }
487
+ } else if (choice === "clear" && editable && path) commit(null)
488
+ }
489
+
319
490
  return (
320
- <FieldShell label={field.label ?? field.id}>
321
- <ReadOnlyText>
322
- {asset?.name ? asset.name : <Empty />}
323
- </ReadOnlyText>
491
+ <FieldShell label={label} view={view} htmlFor={inputId}>
492
+ <PathField
493
+ id={inputId}
494
+ value={path}
495
+ placeholder={field.placeholder}
496
+ disabled={disabled}
497
+ status={error ? "invalid" : missing ? "missing" : "ok"}
498
+ message={error}
499
+ onCommit={editable ? commit : undefined}
500
+ recent={editable && recent.length > 0 ? recent : undefined}
501
+ onBrowse={
502
+ editable && pick
503
+ ? () => pick({ filters: filtersOf(field), title: field.title })
504
+ : undefined
505
+ }
506
+ onMouseDownCapture={(e) => {
507
+ const input = e.currentTarget.querySelector("input")
508
+ editing.current = input !== null && input === document.activeElement
509
+ }}
510
+ onContextMenu={(e) => {
511
+ if (!menu || disabled || editing.current) return
512
+ e.preventDefault()
513
+ void act()
514
+ }}
515
+ />
324
516
  </FieldShell>
325
517
  )
326
518
  }
@@ -335,35 +527,42 @@ function toNumberArray(value: unknown, arity: number): Array<number | null> {
335
527
  })
336
528
  }
337
529
 
338
- function formatComponent(
339
- n: number | null,
340
- integer: boolean,
341
- precision?: number,
342
- ): string {
530
+ function formatComponent(n: number | null, integer: boolean, precision?: number): string {
343
531
  if (n == null) return ""
344
532
  if (integer) return Math.trunc(n).toLocaleString()
345
533
  if (precision != null) return n.toFixed(precision)
346
534
  return n.toLocaleString()
347
535
  }
348
536
 
537
+ const AXES = ["X", "Y", "Z", "W"]
538
+
349
539
  const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
350
540
  field,
351
541
  value,
352
542
  disabled,
353
543
  onChange,
544
+ view,
545
+ onGestureBegin,
546
+ onGestureEnd,
547
+ onGestureCancel,
354
548
  }) => {
549
+ const layout = useLabelLayout()
355
550
  const arity = field.components.length
356
551
  const values = toNumberArray(value, arity)
357
552
  const isInt = Boolean(field.integer)
553
+ // Three or more components take too much room beside a label column: the label goes above and
554
+ // the components spread across the full width.
555
+ const wide = arity >= 3
358
556
 
359
557
  if (!onChange) {
360
558
  return (
361
- <FieldShell label={field.label ?? field.id}>
559
+ <FieldShell label={field.label ?? field.id} view={view} above={wide}>
362
560
  <div
363
561
  className="grid gap-x-3 gap-y-1"
364
562
  style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
365
563
  >
366
564
  {field.components.map((c, i) => (
565
+ // biome-ignore lint/suspicious/noArrayIndexKey: components are a fixed positional tuple
367
566
  <div key={i} className="flex flex-col gap-0.5 min-w-0">
368
567
  {c.label && (
369
568
  <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
@@ -376,9 +575,7 @@ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
376
575
  ) : (
377
576
  <>
378
577
  {formatComponent(values[i], isInt, field.precision)}
379
- {c.suffix && (
380
- <span className="text-muted-foreground">{c.suffix}</span>
381
- )}
578
+ {c.suffix && <span className="text-muted-foreground">{c.suffix}</span>}
382
579
  </>
383
580
  )}
384
581
  </div>
@@ -390,48 +587,41 @@ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
390
587
  }
391
588
 
392
589
  return (
393
- <FieldShell label={field.label ?? field.id}>
590
+ <FieldShell label={field.label ?? field.id} view={view} above={wide}>
394
591
  <div
395
- className="grid gap-x-2"
592
+ className="grid gap-x-1"
396
593
  style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
397
594
  >
398
595
  {field.components.map((c, i) => (
399
- <div key={i} className="flex flex-col gap-0.5 min-w-0">
400
- {c.label && (
401
- <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
402
- {c.label}
403
- </span>
404
- )}
405
- <Input
406
- type="number"
407
- aria-label={c.label ?? `${field.id}[${i}]`}
408
- value={values[i] != null ? String(values[i]) : ""}
409
- min={c.min}
410
- max={c.max}
411
- step={c.step ?? (isInt ? 1 : 0.01)}
412
- disabled={disabled}
413
- onChange={(e) => {
414
- const raw = e.target.value
415
- const parsed = raw === "" ? null : Number(raw)
416
- const next = values.slice()
417
- next[i] =
418
- parsed != null && Number.isFinite(parsed)
419
- ? isInt
420
- ? Math.trunc(parsed)
421
- : parsed
422
- : null
423
- onChange(next)
424
- }}
425
- />
426
- </div>
596
+ <NumberField
597
+ // biome-ignore lint/suspicious/noArrayIndexKey: components are a fixed positional tuple
598
+ key={i}
599
+ label={c.label ?? AXES[i] ?? String(i + 1)}
600
+ value={values[i] ?? c.min ?? 0}
601
+ min={c.min}
602
+ max={c.max}
603
+ step={c.step ?? (isInt ? 1 : 0.01)}
604
+ integer={isInt}
605
+ precision={field.precision}
606
+ suffix={layout === "auto" ? undefined : c.suffix}
607
+ disabled={disabled}
608
+ onValueChange={(next) => {
609
+ const out = values.map((v) => v ?? 0)
610
+ out[i] = isInt ? Math.trunc(next) : next
611
+ onChange(out)
612
+ }}
613
+ onScrubStart={onGestureBegin}
614
+ onScrubEnd={onGestureEnd}
615
+ onScrubCancel={onGestureCancel}
616
+ />
427
617
  ))}
428
618
  </div>
429
619
  </FieldShell>
430
620
  )
431
621
  }
432
622
 
433
- const SeparatorRenderer: FieldRenderer<SeparatorFieldDef> = ({ field }) =>
434
- field.label ? (
623
+ const SeparatorRenderer: FieldRenderer<SeparatorFieldDef> = ({ field, view }) =>
624
+ view === "collapsed" ? null : field.label ? (
435
625
  <div className="my-1 flex items-center gap-2">
436
626
  <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
437
627
  {field.label}
@@ -445,63 +635,21 @@ const SeparatorRenderer: FieldRenderer<SeparatorFieldDef> = ({ field }) =>
445
635
  // The same in every mode: there is nothing to edit. Greys out under
446
636
  // disabledWhen so a hint can follow the field it describes.
447
637
  const LabelRenderer: FieldRenderer<LabelFieldDef> = ({ field, disabled }) => (
448
- <p
449
- className={
450
- "text-xs text-muted-foreground" + (disabled ? " opacity-50" : "")
451
- }
452
- >
453
- {field.label}
454
- </p>
638
+ <p className={`text-xs text-muted-foreground${disabled ? " opacity-50" : ""}`}>{field.label}</p>
455
639
  )
456
640
 
457
641
  export function registerBuiltinRenderers(): void {
458
- registerFieldRenderer(
459
- "separator",
460
- SeparatorRenderer as FieldRenderer<import("./types").FieldDef>,
461
- )
462
- registerFieldRenderer(
463
- "label",
464
- LabelRenderer as FieldRenderer<import("./types").FieldDef>,
465
- )
466
- registerFieldRenderer(
467
- "text",
468
- TextRenderer as FieldRenderer<import("./types").FieldDef>,
469
- )
470
- registerFieldRenderer(
471
- "textarea",
472
- TextareaRenderer as FieldRenderer<import("./types").FieldDef>,
473
- )
474
- registerFieldRenderer(
475
- "number",
476
- NumberRenderer as FieldRenderer<import("./types").FieldDef>,
477
- )
478
- registerFieldRenderer(
479
- "slider",
480
- SliderRenderer as FieldRenderer<import("./types").FieldDef>,
481
- )
482
- registerFieldRenderer(
483
- "select",
484
- SelectRenderer as FieldRenderer<import("./types").FieldDef>,
485
- )
486
- registerFieldRenderer(
487
- "color",
488
- ColorRenderer as FieldRenderer<import("./types").FieldDef>,
489
- )
490
- registerFieldRenderer(
491
- "checkbox",
492
- CheckboxRenderer as FieldRenderer<import("./types").FieldDef>,
493
- )
494
- registerFieldRenderer(
495
- "file",
496
- FileRenderer as FieldRenderer<import("./types").FieldDef>,
497
- )
498
- registerFieldRenderer(
499
- "vector",
500
- VectorRenderer as FieldRenderer<import("./types").FieldDef>,
501
- )
642
+ registerFieldRenderer("separator", SeparatorRenderer as FieldRenderer<import("./types").FieldDef>)
643
+ registerFieldRenderer("label", LabelRenderer as FieldRenderer<import("./types").FieldDef>)
644
+ registerFieldRenderer("text", TextRenderer as FieldRenderer<import("./types").FieldDef>)
645
+ registerFieldRenderer("textarea", TextareaRenderer as FieldRenderer<import("./types").FieldDef>)
646
+ registerFieldRenderer("number", NumberRenderer as FieldRenderer<import("./types").FieldDef>)
647
+ registerFieldRenderer("slider", SliderRenderer as FieldRenderer<import("./types").FieldDef>)
648
+ registerFieldRenderer("select", SelectRenderer as FieldRenderer<import("./types").FieldDef>)
649
+ registerFieldRenderer("color", ColorRenderer as FieldRenderer<import("./types").FieldDef>)
650
+ registerFieldRenderer("checkbox", CheckboxRenderer as FieldRenderer<import("./types").FieldDef>)
651
+ registerFieldRenderer("file", FileRenderer as FieldRenderer<import("./types").FieldDef>)
652
+ registerFieldRenderer("vector", VectorRenderer as FieldRenderer<import("./types").FieldDef>)
502
653
  }
503
654
 
504
- export type {
505
- FieldRenderer,
506
- FieldRendererProps,
507
- }
655
+ export type { FieldRenderer, FieldRendererProps }