@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.
- package/README.md +159 -7
- package/package.json +1 -1
- package/schema/v0.1.json +59 -0
- package/src/field-renderers.tsx +232 -111
- package/src/index.ts +3 -2
- package/src/layout.ts +37 -0
- package/src/panel.tsx +206 -25
- package/src/types.ts +62 -0
package/src/field-renderers.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
{
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
<
|
|
400
|
-
{
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
onChange
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
+
}
|