@preset.nz/facets 0.1.3 → 0.2.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,7 +3,7 @@
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 { useMemo, useRef } from "react"
7
7
  import { Input } from "@/components/ui/input"
8
8
  import { Label } from "@/components/ui/label"
9
9
  import {
@@ -15,7 +15,11 @@ import {
15
15
  } from "@/components/ui/select"
16
16
  import { Checkbox } from "@/components/ui/checkbox"
17
17
  import { Separator } from "@/components/ui/separator"
18
+ import { NumberField } from "@/components/ui/number-field"
19
+ import { Slider } from "@/components/ui/slider"
20
+ import { ColorField } from "@/components/ui/color-field"
18
21
  import { registerFieldRenderer } from "./registry"
22
+ import { COLUMN_ROW, NUMBER_WIDTH, NUMBER_WIDTH_INSIDE, useLabelLayout } from "./layout"
19
23
  import type {
20
24
  CheckboxFieldDef,
21
25
  ColorFieldDef,
@@ -28,11 +32,13 @@ import type {
28
32
  TextareaFieldDef,
29
33
  FileFieldDef,
30
34
  LabelFieldDef,
35
+ PanelView,
31
36
  SeparatorFieldDef,
32
37
  VectorFieldDef,
33
38
  } from "./types"
34
39
 
35
- function ReadOnlyText({ children }: { children: React.ReactNode }) {
40
+ /** A read-only value at the height of an editable row, so read-only and editable panels line up. */
41
+ export function ReadOnlyText({ children }: { children: React.ReactNode }) {
36
42
  return (
37
43
  <div className="min-h-8 px-0 py-1 text-xs text-foreground tabular-nums break-all">
38
44
  {children}
@@ -44,13 +50,54 @@ function Empty() {
44
50
  return <span className="text-muted-foreground">—</span>
45
51
  }
46
52
 
47
- function FieldShell({
53
+ /**
54
+ * A field's label and control, placed as the panel's label layout and view say. Exported so a
55
+ * custom renderer sits on the same label column as the built-ins.
56
+ */
57
+ export function FieldShell({
48
58
  label,
59
+ view,
60
+ top,
61
+ above,
49
62
  children,
50
63
  }: {
51
64
  label?: string
65
+ view?: PanelView
66
+ /** In a label column: align the label to the top of a tall control. */
67
+ top?: boolean
68
+ /** The label sits above the control even in a label column (a wide control, like a vector of four). */
69
+ above?: boolean
52
70
  children: React.ReactNode
53
71
  }) {
72
+ const layout = useLabelLayout()
73
+ // Collapsed: one compact row, label in a fixed column beside the control.
74
+ if (view === "collapsed") {
75
+ return (
76
+ <div className="grid grid-cols-[5.5rem_minmax(0,1fr)] items-center gap-2">
77
+ <Label className="truncate text-[11px] font-medium text-muted-foreground tracking-wide">
78
+ {label}
79
+ </Label>
80
+ <div className="min-w-0">{children}</div>
81
+ </div>
82
+ )
83
+ }
84
+ // Column: one label column shared by the panel, the control on a common edge.
85
+ if (layout === "column" && !above) {
86
+ return (
87
+ <div className={top ? COLUMN_ROW.replace("items-center", "items-start") : COLUMN_ROW}>
88
+ <Label
89
+ title={label}
90
+ className={
91
+ "block truncate text-[11px] font-medium text-muted-foreground tracking-wide" +
92
+ (top ? " pt-2" : "")
93
+ }
94
+ >
95
+ {label}
96
+ </Label>
97
+ <div className="min-w-0">{children}</div>
98
+ </div>
99
+ )
100
+ }
54
101
  return (
55
102
  <div className="flex flex-col gap-1">
56
103
  {label && (
@@ -63,6 +110,14 @@ function FieldShell({
63
110
  )
64
111
  }
65
112
 
113
+ /** Decimal places a step implies: 0.05 gives 2, 5 gives 0. */
114
+ function placesOf(step: number): number {
115
+ const s = String(step)
116
+ if (s.includes("e-")) return Number(s.split("e-")[1])
117
+ const dot = s.indexOf(".")
118
+ return dot === -1 ? 0 : s.length - dot - 1
119
+ }
120
+
66
121
  function formatPrimitive(value: unknown): string {
67
122
  if (value == null || value === "") return ""
68
123
  if (typeof value === "boolean") return value ? "Yes" : "No"
@@ -76,10 +131,11 @@ const TextRenderer: FieldRenderer<TextFieldDef> = ({
76
131
  value,
77
132
  disabled,
78
133
  onChange,
134
+ view,
79
135
  }) => {
80
136
  const display = formatPrimitive(value)
81
137
  return (
82
- <FieldShell label={field.label ?? field.id}>
138
+ <FieldShell label={field.label ?? field.id} view={view}>
83
139
  {onChange ? (
84
140
  <Input
85
141
  aria-label={field.label ?? field.id}
@@ -104,15 +160,16 @@ const TextareaRenderer: FieldRenderer<TextareaFieldDef> = ({
104
160
  value,
105
161
  disabled,
106
162
  onChange,
163
+ view,
107
164
  }) => {
108
165
  const display = formatPrimitive(value)
109
166
  return (
110
- <FieldShell label={field.label ?? field.id}>
167
+ <FieldShell label={field.label ?? field.id} view={view} top>
111
168
  {onChange ? (
112
169
  <textarea
113
170
  aria-label={field.label ?? field.id}
114
171
  value={display}
115
- rows={field.rows ?? 3}
172
+ rows={view === "collapsed" ? 1 : (field.rows ?? 3)}
116
173
  disabled={disabled}
117
174
  placeholder={field.placeholder}
118
175
  onChange={(e) => onChange(e.target.value)}
@@ -136,37 +193,55 @@ const NumberRenderer: FieldRenderer<NumberFieldDef> = ({
136
193
  value,
137
194
  disabled,
138
195
  onChange,
196
+ onGestureBegin,
197
+ onGestureEnd,
198
+ onGestureCancel,
139
199
  }) => {
200
+ const layout = useLabelLayout()
140
201
  const num =
141
202
  typeof value === "number"
142
203
  ? value
143
204
  : value != null && value !== ""
144
205
  ? Number(value)
145
206
  : 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>
207
+ const finite = num != null && Number.isFinite(num)
208
+ const label = field.label ?? field.id
209
+ if (!onChange) {
210
+ return (
211
+ <FieldShell label={label}>
212
+ <ReadOnlyText>{finite ? num.toLocaleString() : <Empty />}</ReadOnlyText>
213
+ </FieldShell>
214
+ )
215
+ }
216
+ // The field's own label is the scrub handle, so the panel draws none beside it: in "column" and
217
+ // "stacked" it tells the field where to put it. Hosts that ignore labelPlacement keep it inside.
218
+ const placed =
219
+ layout === "auto"
220
+ ? {}
221
+ : {
222
+ labelPlacement: layout === "column" ? ("column" as const) : ("above" as const),
223
+ boxClassName: NUMBER_WIDTH,
224
+ suffix: field.suffix,
225
+ }
226
+ const input = (
227
+ <NumberField
228
+ label={label}
229
+ value={finite ? num : (field.min ?? 0)}
230
+ min={field.min}
231
+ max={field.max}
232
+ step={field.step ?? 1}
233
+ integer={field.integer}
234
+ className={layout === "auto" ? NUMBER_WIDTH_INSIDE : undefined}
235
+ {...placed}
236
+ disabled={disabled}
237
+ onValueChange={(next) => onChange(next)}
238
+ onScrubStart={onGestureBegin}
239
+ onScrubEnd={onGestureEnd}
240
+ onScrubCancel={onGestureCancel}
241
+ />
169
242
  )
243
+ // The field spans the label and control columns of a row of its own.
244
+ return layout === "column" ? <div className={COLUMN_ROW}>{input}</div> : input
170
245
  }
171
246
 
172
247
  const SliderRenderer: FieldRenderer<SliderFieldDef> = ({
@@ -174,22 +249,55 @@ const SliderRenderer: FieldRenderer<SliderFieldDef> = ({
174
249
  value,
175
250
  disabled,
176
251
  onChange,
252
+ view,
253
+ onGestureBegin,
254
+ onGestureEnd,
177
255
  }) => {
178
256
  const num = typeof value === "number" ? value : Number(value ?? 0)
257
+ // A drag is a pointer press that moves the value; arrow keys are plain edits.
258
+ const pressed = useRef(false)
259
+ const dragging = useRef(false)
260
+ const layout = useLabelLayout()
261
+ // With labels in a column or above, the value reads out to the right, on a shared edge.
262
+ const readout = layout !== "auto" && view !== "collapsed"
179
263
  return (
180
- <FieldShell label={field.label ?? field.id}>
264
+ <FieldShell label={field.label ?? field.id} view={view}>
181
265
  {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
- />
266
+ <div
267
+ className={readout ? "flex min-h-8 items-center gap-2" : "flex min-h-8 items-center"}
268
+ onPointerDownCapture={() => {
269
+ pressed.current = true
270
+ }}
271
+ >
272
+ <Slider
273
+ className="min-w-0 flex-1"
274
+ aria-label={field.label ?? field.id}
275
+ value={Number.isFinite(num) ? num : (field.min ?? 0)}
276
+ min={field.min}
277
+ max={field.max}
278
+ step={field.step ?? 1}
279
+ disabled={disabled}
280
+ onValueChange={(next) => {
281
+ if (pressed.current && !dragging.current) {
282
+ dragging.current = true
283
+ onGestureBegin?.()
284
+ }
285
+ onChange(Array.isArray(next) ? next[0] : next)
286
+ }}
287
+ onValueCommitted={() => {
288
+ pressed.current = false
289
+ if (dragging.current) {
290
+ dragging.current = false
291
+ onGestureEnd?.()
292
+ }
293
+ }}
294
+ />
295
+ {readout && (
296
+ <span className="w-12 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
297
+ {Number.isFinite(num) ? num.toFixed(placesOf(field.step ?? 1)) : ""}
298
+ </span>
299
+ )}
300
+ </div>
193
301
  ) : (
194
302
  <ReadOnlyText>
195
303
  {Number.isFinite(num) ? num.toLocaleString() : <Empty />}
@@ -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,7 +323,7 @@ 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}>
326
+ <FieldShell label={field.label ?? field.id} view={view}>
218
327
  <ReadOnlyText>
219
328
  {matched ? matched.label : current ? current : <Empty />}
220
329
  </ReadOnlyText>
@@ -222,13 +331,13 @@ const SelectRenderer: FieldRenderer<SelectFieldDef> = ({
222
331
  )
223
332
  }
224
333
  return (
225
- <FieldShell label={field.label ?? field.id}>
334
+ <FieldShell label={field.label ?? field.id} view={view}>
226
335
  <Select
227
336
  value={current}
228
337
  onValueChange={(v) => onChange(v)}
229
338
  disabled={disabled}
230
339
  >
231
- <SelectTrigger>
340
+ <SelectTrigger className="w-full">
232
341
  {/* Children, not the primitive's own lookup: Base UI's Value shows
233
342
  the raw value unless it's told the label. */}
234
343
  <SelectValue>{matched ? matched.label : current}</SelectValue>
@@ -250,36 +359,32 @@ const ColorRenderer: FieldRenderer<ColorFieldDef> = ({
250
359
  value,
251
360
  disabled,
252
361
  onChange,
362
+ view,
363
+ onGestureBegin,
364
+ onGestureEnd,
253
365
  }) => {
254
366
  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>
367
+ const label = field.label ?? field.id
368
+ const layout = useLabelLayout()
369
+ // Collapsed rows and a label column keep the label in their column; otherwise the field draws it.
370
+ const inColumn = view === "collapsed" || layout === "column"
371
+ const picker = (
372
+ <ColorField
373
+ label={inColumn ? undefined : label}
374
+ value={hex}
375
+ presets={field.presets}
376
+ disabled={disabled}
377
+ onChange={onChange ? (next) => onChange(next ?? "") : undefined}
378
+ onPickStart={onGestureBegin}
379
+ onPickEnd={onGestureEnd}
380
+ />
381
+ )
382
+ return inColumn ? (
383
+ <FieldShell label={label} view={view}>
384
+ {picker}
282
385
  </FieldShell>
386
+ ) : (
387
+ picker
283
388
  )
284
389
  }
285
390
 
@@ -288,36 +393,50 @@ const CheckboxRenderer: FieldRenderer<CheckboxFieldDef> = ({
288
393
  value,
289
394
  disabled,
290
395
  onChange,
396
+ view,
291
397
  }) => {
292
398
  const checked = Boolean(value)
399
+ const layout = useLabelLayout()
293
400
  if (!onChange) {
294
401
  return (
295
- <FieldShell label={field.label ?? field.id}>
402
+ <FieldShell label={field.label ?? field.id} view={view}>
296
403
  <ReadOnlyText>{checked ? "Yes" : "No"}</ReadOnlyText>
297
404
  </FieldShell>
298
405
  )
299
406
  }
407
+ // Collapsed rows and a label column keep the label in their column, like every other kind.
408
+ if (view === "collapsed" || layout === "column") {
409
+ return (
410
+ <FieldShell label={field.label ?? field.id} view={view}>
411
+ <Checkbox
412
+ checked={checked}
413
+ disabled={disabled}
414
+ onCheckedChange={(next) => onChange(Boolean(next))}
415
+ />
416
+ </FieldShell>
417
+ )
418
+ }
300
419
  return (
301
- <div className="flex items-center gap-2">
420
+ // One line: the box, then its label, which toggles it.
421
+ <Label className="min-h-8 gap-2 text-xs text-foreground">
302
422
  <Checkbox
303
423
  checked={checked}
304
424
  disabled={disabled}
305
425
  onCheckedChange={(next) => onChange(Boolean(next))}
306
426
  />
307
- {field.label && (
308
- <Label className="text-xs text-foreground">{field.label}</Label>
309
- )}
310
- </div>
427
+ {field.label}
428
+ </Label>
311
429
  )
312
430
  }
313
431
 
314
432
  const FileRenderer: FieldRenderer<FileFieldDef> = ({
315
433
  field,
316
434
  value,
435
+ view,
317
436
  }) => {
318
437
  const asset = value as { name?: string } | null | undefined
319
438
  return (
320
- <FieldShell label={field.label ?? field.id}>
439
+ <FieldShell label={field.label ?? field.id} view={view}>
321
440
  <ReadOnlyText>
322
441
  {asset?.name ? asset.name : <Empty />}
323
442
  </ReadOnlyText>
@@ -346,19 +465,29 @@ function formatComponent(
346
465
  return n.toLocaleString()
347
466
  }
348
467
 
468
+ const AXES = ["X", "Y", "Z", "W"]
469
+
349
470
  const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
350
471
  field,
351
472
  value,
352
473
  disabled,
353
474
  onChange,
475
+ view,
476
+ onGestureBegin,
477
+ onGestureEnd,
478
+ onGestureCancel,
354
479
  }) => {
480
+ const layout = useLabelLayout()
355
481
  const arity = field.components.length
356
482
  const values = toNumberArray(value, arity)
357
483
  const isInt = Boolean(field.integer)
484
+ // Three or more components take too much room beside a label column: the label goes above and
485
+ // the components spread across the full width.
486
+ const wide = arity >= 3
358
487
 
359
488
  if (!onChange) {
360
489
  return (
361
- <FieldShell label={field.label ?? field.id}>
490
+ <FieldShell label={field.label ?? field.id} view={view} above={wide}>
362
491
  <div
363
492
  className="grid gap-x-3 gap-y-1"
364
493
  style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
@@ -390,48 +519,40 @@ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
390
519
  }
391
520
 
392
521
  return (
393
- <FieldShell label={field.label ?? field.id}>
522
+ <FieldShell label={field.label ?? field.id} view={view} above={wide}>
394
523
  <div
395
- className="grid gap-x-2"
524
+ className="grid gap-x-1"
396
525
  style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
397
526
  >
398
527
  {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>
528
+ <NumberField
529
+ key={i}
530
+ label={c.label ?? AXES[i] ?? String(i + 1)}
531
+ value={values[i] ?? c.min ?? 0}
532
+ min={c.min}
533
+ max={c.max}
534
+ step={c.step ?? (isInt ? 1 : 0.01)}
535
+ integer={isInt}
536
+ precision={field.precision}
537
+ suffix={layout === "auto" ? undefined : c.suffix}
538
+ disabled={disabled}
539
+ onValueChange={(next) => {
540
+ const out = values.map((v) => v ?? 0)
541
+ out[i] = isInt ? Math.trunc(next) : next
542
+ onChange(out)
543
+ }}
544
+ onScrubStart={onGestureBegin}
545
+ onScrubEnd={onGestureEnd}
546
+ onScrubCancel={onGestureCancel}
547
+ />
427
548
  ))}
428
549
  </div>
429
550
  </FieldShell>
430
551
  )
431
552
  }
432
553
 
433
- const SeparatorRenderer: FieldRenderer<SeparatorFieldDef> = ({ field }) =>
434
- field.label ? (
554
+ const SeparatorRenderer: FieldRenderer<SeparatorFieldDef> = ({ field, view }) =>
555
+ view === "collapsed" ? null : field.label ? (
435
556
  <div className="my-1 flex items-center gap-2">
436
557
  <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
437
558
  {field.label}
package/src/index.ts CHANGED
@@ -6,5 +6,6 @@ export {
6
6
  registerFieldRenderer,
7
7
  getFieldRenderer,
8
8
  } from "./registry"
9
- export { PropertyPanel } from "./panel"
10
- export { registerBuiltinRenderers } from "./field-renderers"
9
+ export { PropertyPanel, promotedFields, showsIn } from "./panel"
10
+ export { registerBuiltinRenderers, FieldShell, ReadOnlyText } from "./field-renderers"
11
+ export { useLabelLayout, COLUMN_ROW } from "./layout"
package/src/layout.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { createContext, useContext } from "react"
2
+ import type { FieldDef, LabelLayout } from "./types"
3
+
4
+ /**
5
+ * The panel's label layout, for the built-in renderers. Context rather than a renderer prop, so
6
+ * custom renderers need no change; one that wants to follow the layout may read it with
7
+ * `useLabelLayout()`. Outside a `PropertyPanel` it is `"auto"`.
8
+ */
9
+ export const LabelLayoutContext = createContext<LabelLayout>("auto")
10
+ export const useLabelLayout = () => useContext(LabelLayoutContext)
11
+
12
+ /** The CSS variable that holds the label column's width in `"column"` layout. Set on the panel root. */
13
+ export const LABEL_WIDTH_VAR = "--facets-label-w"
14
+
15
+ /** The grid every `"column"` row sits on: the label, then the control. */
16
+ export const COLUMN_ROW = `grid min-h-8 grid-cols-[var(${LABEL_WIDTH_VAR})_minmax(0,1fr)] items-center gap-x-3`
17
+
18
+ /**
19
+ * One width for every number field in a panel, whatever its label: the box is this wide in a label
20
+ * column or under a label, and `NUMBER_WIDTH_INSIDE` when the label sits inside it (the default
21
+ * layout, where it lines the boxes up on the right edge).
22
+ */
23
+ export const NUMBER_WIDTH = "w-28"
24
+ export const NUMBER_WIDTH_INSIDE = "w-44 ml-auto"
25
+
26
+ /**
27
+ * One width for the whole panel: the longest label among the fields the view draws, in `ch`,
28
+ * kept between 8 and 16 so one long label doesn't squeeze every control. A longer label
29
+ * truncates, with its full text as a tooltip.
30
+ */
31
+ export function labelColumnWidth(fields: FieldDef[]): string {
32
+ const longest = fields.reduce(
33
+ (n, f) => (f.kind === "separator" || f.kind === "label" ? n : Math.max(n, (f.label ?? f.id).length)),
34
+ 0,
35
+ )
36
+ return `${Math.min(16, Math.max(8, longest))}ch`
37
+ }