@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.
- package/README.md +314 -11
- package/package.json +6 -3
- package/schema/v0.1.json +253 -121
- package/src/field-renderers.tsx +354 -206
- package/src/file-handlers.ts +158 -0
- package/src/index.ts +15 -6
- package/src/layout.ts +41 -0
- package/src/pages.ts +50 -0
- package/src/panel.tsx +611 -83
- package/src/registry.ts +8 -7
- package/src/types.ts +148 -16
package/src/field-renderers.tsx
CHANGED
|
@@ -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
|
-
|
|
30
|
-
LabelFieldDef,
|
|
31
|
-
SeparatorFieldDef,
|
|
38
|
+
TextFieldDef,
|
|
32
39
|
VectorFieldDef,
|
|
33
40
|
} from "./types"
|
|
34
41
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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={
|
|
321
|
-
<
|
|
322
|
-
{
|
|
323
|
-
|
|
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-
|
|
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
|
-
<
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
)
|
|
462
|
-
registerFieldRenderer(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
)
|
|
466
|
-
registerFieldRenderer(
|
|
467
|
-
|
|
468
|
-
|
|
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 }
|