@preset.nz/facets 0.2.0 → 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 +159 -8
- package/package.json +6 -3
- package/schema/v0.1.json +198 -125
- package/src/field-renderers.tsx +130 -103
- package/src/file-handlers.ts +158 -0
- package/src/index.ts +15 -7
- package/src/layout.ts +5 -1
- package/src/pages.ts +50 -0
- package/src/panel.tsx +434 -87
- package/src/registry.ts +8 -7
- package/src/types.ts +86 -16
package/src/panel.tsx
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { createContext, useContext, useId, useMemo, useState } from "react"
|
|
2
2
|
import { Separator } from "@/components/ui/separator"
|
|
3
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
4
|
+
import {
|
|
5
|
+
HELP_ROW,
|
|
6
|
+
LABEL_WIDTH_VAR,
|
|
7
|
+
LabelLayoutContext,
|
|
8
|
+
labelColumnWidth,
|
|
9
|
+
useLabelLayout,
|
|
10
|
+
} from "./layout"
|
|
3
11
|
import { getFieldRenderer, getScope } from "./registry"
|
|
4
|
-
import { LABEL_WIDTH_VAR, LabelLayoutContext, labelColumnWidth, useLabelLayout } from "./layout"
|
|
5
12
|
import type {
|
|
13
|
+
DisabledWhen,
|
|
14
|
+
DisabledWhenAny,
|
|
6
15
|
FieldDef,
|
|
16
|
+
FileFieldDef,
|
|
7
17
|
LabelLayout,
|
|
8
18
|
PanelView,
|
|
9
19
|
PropertyGroupDef,
|
|
@@ -11,6 +21,22 @@ import type {
|
|
|
11
21
|
ScopeContext,
|
|
12
22
|
} from "./types"
|
|
13
23
|
|
|
24
|
+
/** Whether a field's `disabledWhen` holds for these values; a list holds when any entry does. */
|
|
25
|
+
export function isDisabled(
|
|
26
|
+
cond: DisabledWhenAny | undefined,
|
|
27
|
+
values: Record<string, unknown>,
|
|
28
|
+
): boolean {
|
|
29
|
+
if (!cond) return false
|
|
30
|
+
if (Array.isArray(cond)) return cond.some((c) => isDisabled(c, values))
|
|
31
|
+
const one = cond as DisabledWhen
|
|
32
|
+
const v = values[one.path]
|
|
33
|
+
if (Object.hasOwn(one, "equals")) return v === one.equals
|
|
34
|
+
if (Object.hasOwn(one, "notEquals")) return v !== one.notEquals
|
|
35
|
+
if (one.in) return one.in.includes(v)
|
|
36
|
+
if (one.notIn) return !one.notIn.includes(v)
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
interface PropertyPanelProps {
|
|
15
41
|
scopeKey: string
|
|
16
42
|
selection: unknown
|
|
@@ -29,6 +55,22 @@ interface PropertyPanelProps {
|
|
|
29
55
|
labelLayout?: LabelLayout
|
|
30
56
|
/** `"column"` only: the label column's width as a CSS length. Default: fits the longest label, 8ch to 16ch. */
|
|
31
57
|
labelWidth?: string
|
|
58
|
+
/**
|
|
59
|
+
* The open page's id, for a host that remembers it. Omit it and the panel
|
|
60
|
+
* keeps its own, starting on the first page. An id the schema doesn't
|
|
61
|
+
* declare shows the first page.
|
|
62
|
+
*/
|
|
63
|
+
page?: string
|
|
64
|
+
/** A tab was chosen. Called whether or not `page` is set. */
|
|
65
|
+
onPageChange?: (page: string) => void
|
|
66
|
+
/**
|
|
67
|
+
* The pages whose Advanced row is open, for a host that remembers them.
|
|
68
|
+
* Omit it and the panel keeps its own, every row closed. A schema without
|
|
69
|
+
* `pages` has one page, whose id is `""`.
|
|
70
|
+
*/
|
|
71
|
+
advancedOpen?: readonly string[]
|
|
72
|
+
/** An Advanced row was opened or closed: the full list of open pages. */
|
|
73
|
+
onAdvancedOpenChange?: (pages: string[]) => void
|
|
32
74
|
}
|
|
33
75
|
|
|
34
76
|
/** The scope's gesture, bound to its selection and ctx, for the field slots below. */
|
|
@@ -39,8 +81,55 @@ interface GestureHandlers {
|
|
|
39
81
|
}
|
|
40
82
|
const GestureContext = createContext<GestureHandlers | null>(null)
|
|
41
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The host's refusals by path (see `WriteResult`), each with the value the field held when it was
|
|
86
|
+
* refused: it shows while the field still holds that value, so any other change clears it.
|
|
87
|
+
*/
|
|
88
|
+
type Refusals = Readonly<Record<string, { message: string; at: unknown }>>
|
|
89
|
+
const RefusalContext = createContext<Refusals>({})
|
|
90
|
+
|
|
91
|
+
/** The message of a rejected write: an Error's message, or the string a Tauri command rejects with. */
|
|
92
|
+
function messageOf(e: unknown): string {
|
|
93
|
+
if (e instanceof Error) return e.message
|
|
94
|
+
return typeof e === "string" && e !== "" ? e : "Refused"
|
|
95
|
+
}
|
|
96
|
+
|
|
42
97
|
type Row = Array<FieldDef | FieldDef[]>[number]
|
|
43
98
|
|
|
99
|
+
/** One page of the inspector: its groups, and those behind its Advanced row. */
|
|
100
|
+
interface Page {
|
|
101
|
+
id: string
|
|
102
|
+
label: string
|
|
103
|
+
groups: PropertyGroupDef[]
|
|
104
|
+
advanced: PropertyGroupDef[]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The schema's pages, in order. No `pages` is one page, id `""`, holding every group. */
|
|
108
|
+
function pagesOf(schema: PropertySchema): Page[] {
|
|
109
|
+
const declared = schema.pages?.length ? schema.pages : [{ id: "", label: "" }]
|
|
110
|
+
return declared.map(({ id, label }) => {
|
|
111
|
+
const on = schema.pages?.length ? schema.groups.filter((g) => g.page === id) : schema.groups
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
label,
|
|
115
|
+
groups: on.filter((g) => !g.advanced),
|
|
116
|
+
advanced: on.filter((g) => g.advanced),
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether any field in these groups has a path in `changed`. */
|
|
122
|
+
function hasChanged(groups: PropertyGroupDef[], changed: ReadonlySet<string>): boolean {
|
|
123
|
+
if (changed.size === 0) return false
|
|
124
|
+
return groups.some((g) =>
|
|
125
|
+
g.rows.some((row) =>
|
|
126
|
+
(Array.isArray(row) ? row : [row]).some(
|
|
127
|
+
(f) => "path" in f && f.path !== undefined && changed.has(f.path),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
44
133
|
/** Whether a field shows in a view: every field in the inspector, else only those promoted to it. */
|
|
45
134
|
export function showsIn(field: FieldDef, view: PanelView): boolean {
|
|
46
135
|
return view === "inspector" || (field.promote?.includes(view) ?? false)
|
|
@@ -92,8 +181,10 @@ function PanelBody({
|
|
|
92
181
|
title,
|
|
93
182
|
labelLayout = "column",
|
|
94
183
|
labelWidth,
|
|
184
|
+
...pageProps
|
|
95
185
|
}: PropertyPanelProps) {
|
|
96
186
|
const scope = getScope(scopeKey)
|
|
187
|
+
const [refused, setRefused] = useState<Refusals>({})
|
|
97
188
|
|
|
98
189
|
const values = useMemo(() => {
|
|
99
190
|
if (!scope) return {} as Record<string, unknown>
|
|
@@ -105,11 +196,42 @@ function PanelBody({
|
|
|
105
196
|
}
|
|
106
197
|
}, [scope, scopeKey, selection, ctx])
|
|
107
198
|
|
|
199
|
+
const changed = useMemo(() => {
|
|
200
|
+
try {
|
|
201
|
+
return new Set(scope?.changed?.(selection, ctx) ?? [])
|
|
202
|
+
} catch (e) {
|
|
203
|
+
console.error("[properties] scope changed failed:", scopeKey, e)
|
|
204
|
+
return new Set<string>()
|
|
205
|
+
}
|
|
206
|
+
}, [scope, scopeKey, selection, ctx])
|
|
207
|
+
|
|
108
208
|
if (!scope) return <>{emptyState ?? null}</>
|
|
109
209
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
210
|
+
const refuse = (path: string, message: string, at: unknown) =>
|
|
211
|
+
setRefused((r) => ({ ...r, [path]: { message, at } }))
|
|
212
|
+
const onChange =
|
|
213
|
+
readOnly || !scope.write
|
|
214
|
+
? undefined
|
|
215
|
+
: (path: string, val: unknown) => {
|
|
216
|
+
const at = values[path]
|
|
217
|
+
setRefused((r) => {
|
|
218
|
+
if (!(path in r)) return r
|
|
219
|
+
const { [path]: _gone, ...rest } = r
|
|
220
|
+
return rest
|
|
221
|
+
})
|
|
222
|
+
const out = scope.write?.(path, val, selection, ctx)
|
|
223
|
+
if (out && typeof (out as Promise<unknown>).then === "function") {
|
|
224
|
+
;(out as Promise<{ error: string } | undefined>).then(
|
|
225
|
+
(r) => r?.error && refuse(path, r.error, at),
|
|
226
|
+
(e) => refuse(path, messageOf(e), at),
|
|
227
|
+
)
|
|
228
|
+
} else if (out && typeof out === "object" && "error" in out) {
|
|
229
|
+
refuse(path, out.error, at)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const wrap = (node: React.ReactNode) => (
|
|
233
|
+
<RefusalContext.Provider value={refused}>{node}</RefusalContext.Provider>
|
|
234
|
+
)
|
|
113
235
|
|
|
114
236
|
// Collapsed: what a folded scope still shows, one compact row per field.
|
|
115
237
|
// The host draws the title bar that folds it. Nothing when nothing is
|
|
@@ -117,10 +239,10 @@ function PanelBody({
|
|
|
117
239
|
if (view === "collapsed") {
|
|
118
240
|
const fields = promotedFields(scope.schema, "collapsed")
|
|
119
241
|
if (fields.length === 0) return null
|
|
120
|
-
return (
|
|
242
|
+
return wrap(
|
|
121
243
|
<div className="px-3 py-2">
|
|
122
244
|
<CompactRows fields={fields} values={values} ctx={ctx} onChange={onChange} />
|
|
123
|
-
</div
|
|
245
|
+
</div>,
|
|
124
246
|
)
|
|
125
247
|
}
|
|
126
248
|
|
|
@@ -130,40 +252,194 @@ function PanelBody({
|
|
|
130
252
|
const rows = scope.schema.groups.flatMap((g) => rowsIn(g.rows, "card"))
|
|
131
253
|
const heading = title ?? scope.schema.title
|
|
132
254
|
if (rows.length === 0 && !heading) return null
|
|
133
|
-
return (
|
|
255
|
+
return wrap(
|
|
134
256
|
<LabelLayoutContext.Provider value={labelLayout}>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
</LabelLayoutContext.Provider
|
|
257
|
+
<div className="flex flex-col" style={columnStyle(labelLayout, labelWidth, rows.flat())}>
|
|
258
|
+
{heading && (
|
|
259
|
+
<header className="border-b border-border px-3 py-2 text-xs font-semibold">
|
|
260
|
+
{heading}
|
|
261
|
+
</header>
|
|
262
|
+
)}
|
|
263
|
+
{rows.length > 0 && (
|
|
264
|
+
<div className={`flex flex-col px-3 py-3 ${rowGap(labelLayout)}`}>
|
|
265
|
+
<Rows rows={rows} values={values} ctx={ctx} onChange={onChange} view="card" />
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
</LabelLayoutContext.Provider>,
|
|
148
270
|
)
|
|
149
271
|
}
|
|
150
272
|
|
|
273
|
+
// One label column across every page, so it doesn't jump between tabs.
|
|
151
274
|
const all = scope.schema.groups.flatMap((g) => rowsIn(g.rows, "inspector")).flat()
|
|
152
|
-
return (
|
|
275
|
+
return wrap(
|
|
153
276
|
<LabelLayoutContext.Provider value={labelLayout}>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
key={group.id}
|
|
158
|
-
group={group}
|
|
277
|
+
<div className="flex flex-col" style={columnStyle(labelLayout, labelWidth, all)}>
|
|
278
|
+
<Inspector
|
|
279
|
+
schema={scope.schema}
|
|
159
280
|
values={values}
|
|
160
281
|
ctx={ctx}
|
|
161
282
|
onChange={onChange}
|
|
162
|
-
|
|
283
|
+
changed={changed}
|
|
284
|
+
{...pageProps}
|
|
163
285
|
/>
|
|
286
|
+
</div>
|
|
287
|
+
</LabelLayoutContext.Provider>,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type PageProps = Pick<
|
|
292
|
+
PropertyPanelProps,
|
|
293
|
+
"page" | "onPageChange" | "advancedOpen" | "onAdvancedOpenChange"
|
|
294
|
+
>
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The inspector view: the tab row when there are two pages or more, then the
|
|
298
|
+
* open page. The open page, each page's Advanced row and each group's fold are
|
|
299
|
+
* held here, above the tabs, so they survive switching pages.
|
|
300
|
+
*/
|
|
301
|
+
function Inspector({
|
|
302
|
+
schema,
|
|
303
|
+
values,
|
|
304
|
+
ctx,
|
|
305
|
+
onChange,
|
|
306
|
+
changed,
|
|
307
|
+
page,
|
|
308
|
+
onPageChange,
|
|
309
|
+
advancedOpen,
|
|
310
|
+
onAdvancedOpenChange,
|
|
311
|
+
}: PageProps & {
|
|
312
|
+
schema: PropertySchema
|
|
313
|
+
values: Record<string, unknown>
|
|
314
|
+
ctx: ScopeContext
|
|
315
|
+
onChange?: (path: string, val: unknown) => void
|
|
316
|
+
changed: ReadonlySet<string>
|
|
317
|
+
}) {
|
|
318
|
+
const pages = pagesOf(schema)
|
|
319
|
+
const [ownPage, setOwnPage] = useState<string>()
|
|
320
|
+
const [ownAdvanced, setOwnAdvanced] = useState<readonly string[]>([])
|
|
321
|
+
// Groups folded the other way from their `defaultCollapsed`, so a group
|
|
322
|
+
// added later still starts as its schema says.
|
|
323
|
+
const [flipped, setFlipped] = useState<ReadonlySet<string>>(new Set())
|
|
324
|
+
|
|
325
|
+
const wanted = page ?? ownPage
|
|
326
|
+
const current = pages.find((p) => p.id === wanted) ?? pages[0]
|
|
327
|
+
const open = advancedOpen ?? ownAdvanced
|
|
328
|
+
const choosePage = (id: string) => {
|
|
329
|
+
setOwnPage(id)
|
|
330
|
+
onPageChange?.(id)
|
|
331
|
+
}
|
|
332
|
+
const toggleAdvanced = (id: string) => {
|
|
333
|
+
const next = open.includes(id) ? open.filter((p) => p !== id) : [...open, id]
|
|
334
|
+
setOwnAdvanced(next)
|
|
335
|
+
onAdvancedOpenChange?.(next)
|
|
336
|
+
}
|
|
337
|
+
const flip = (id: string) =>
|
|
338
|
+
setFlipped((prev) => {
|
|
339
|
+
const next = new Set(prev)
|
|
340
|
+
if (!next.delete(id)) next.add(id)
|
|
341
|
+
return next
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const body = (p: Page) => (
|
|
345
|
+
<PageBody
|
|
346
|
+
page={p}
|
|
347
|
+
values={values}
|
|
348
|
+
ctx={ctx}
|
|
349
|
+
onChange={onChange}
|
|
350
|
+
changed={changed}
|
|
351
|
+
flipped={flipped}
|
|
352
|
+
onFlip={flip}
|
|
353
|
+
advancedOpen={open.includes(p.id)}
|
|
354
|
+
onAdvancedToggle={() => toggleAdvanced(p.id)}
|
|
355
|
+
/>
|
|
356
|
+
)
|
|
357
|
+
if (pages.length < 2) return body(current)
|
|
358
|
+
|
|
359
|
+
// The open tab shows its own fields, so only the others carry the mark.
|
|
360
|
+
// TabsContent's own text size would differ from an unpaged panel's.
|
|
361
|
+
return (
|
|
362
|
+
<Tabs value={current.id} onValueChange={(v) => choosePage(String(v))}>
|
|
363
|
+
<div className="px-3 pt-3">
|
|
364
|
+
<TabsList>
|
|
365
|
+
{pages.map((p) => (
|
|
366
|
+
<TabsTrigger key={p.id} value={p.id}>
|
|
367
|
+
{p.label}
|
|
368
|
+
{p.id !== current.id && hasChanged([...p.groups, ...p.advanced], changed) && (
|
|
369
|
+
<ChangedMark />
|
|
370
|
+
)}
|
|
371
|
+
</TabsTrigger>
|
|
372
|
+
))}
|
|
373
|
+
</TabsList>
|
|
374
|
+
</div>
|
|
375
|
+
{pages.map((p) => (
|
|
376
|
+
<TabsContent key={p.id} value={p.id} className="text-[length:inherit]">
|
|
377
|
+
{body(p)}
|
|
378
|
+
</TabsContent>
|
|
164
379
|
))}
|
|
165
|
-
</
|
|
166
|
-
|
|
380
|
+
</Tabs>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A page's groups, then its advanced groups behind one closed Advanced row. */
|
|
385
|
+
function PageBody({
|
|
386
|
+
page,
|
|
387
|
+
values,
|
|
388
|
+
ctx,
|
|
389
|
+
onChange,
|
|
390
|
+
changed,
|
|
391
|
+
flipped,
|
|
392
|
+
onFlip,
|
|
393
|
+
advancedOpen,
|
|
394
|
+
onAdvancedToggle,
|
|
395
|
+
}: {
|
|
396
|
+
page: Page
|
|
397
|
+
values: Record<string, unknown>
|
|
398
|
+
ctx: ScopeContext
|
|
399
|
+
onChange?: (path: string, val: unknown) => void
|
|
400
|
+
changed: ReadonlySet<string>
|
|
401
|
+
flipped: ReadonlySet<string>
|
|
402
|
+
onFlip: (id: string) => void
|
|
403
|
+
advancedOpen: boolean
|
|
404
|
+
onAdvancedToggle: () => void
|
|
405
|
+
}) {
|
|
406
|
+
const advancedId = useId()
|
|
407
|
+
const group = (g: PropertyGroupDef, isLast: boolean) => (
|
|
408
|
+
<PropertyGroup
|
|
409
|
+
key={g.id}
|
|
410
|
+
group={g}
|
|
411
|
+
values={values}
|
|
412
|
+
ctx={ctx}
|
|
413
|
+
onChange={onChange}
|
|
414
|
+
changed={changed}
|
|
415
|
+
flipped={flipped.has(g.id)}
|
|
416
|
+
onFlip={() => onFlip(g.id)}
|
|
417
|
+
isLast={isLast}
|
|
418
|
+
/>
|
|
419
|
+
)
|
|
420
|
+
const { groups, advanced } = page
|
|
421
|
+
return (
|
|
422
|
+
<>
|
|
423
|
+
{groups.map((g, idx) => group(g, idx === groups.length - 1 && advanced.length === 0))}
|
|
424
|
+
{advanced.length > 0 && (
|
|
425
|
+
<>
|
|
426
|
+
<section className="flex flex-col gap-3 px-3 py-3">
|
|
427
|
+
<header>
|
|
428
|
+
<FoldHeader
|
|
429
|
+
title="Advanced"
|
|
430
|
+
open={advancedOpen}
|
|
431
|
+
onToggle={onAdvancedToggle}
|
|
432
|
+
controls={advancedId}
|
|
433
|
+
changed={!advancedOpen && hasChanged(advanced, changed)}
|
|
434
|
+
/>
|
|
435
|
+
</header>
|
|
436
|
+
</section>
|
|
437
|
+
<div id={advancedId} hidden={!advancedOpen} className="flex flex-col">
|
|
438
|
+
{advanced.map((g, idx) => group(g, idx === advanced.length - 1))}
|
|
439
|
+
</div>
|
|
440
|
+
</>
|
|
441
|
+
)}
|
|
442
|
+
</>
|
|
167
443
|
)
|
|
168
444
|
}
|
|
169
445
|
|
|
@@ -177,31 +453,87 @@ function columnStyle(layout: LabelLayout, width: string | undefined, fields: Fie
|
|
|
177
453
|
/** Space between rows: tighter in a label column, where the rows read as a table. */
|
|
178
454
|
const rowGap = (layout: LabelLayout) => (layout === "column" ? "gap-1.5" : "gap-3")
|
|
179
455
|
|
|
456
|
+
const TITLE_CLASS = "text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* A fold's title: chevron and title, a button that opens and closes `controls`.
|
|
460
|
+
* Collapsible groups and the Advanced row both draw it. `changed` marks a
|
|
461
|
+
* closed fold that hides a changed field.
|
|
462
|
+
*/
|
|
463
|
+
function FoldHeader({
|
|
464
|
+
title,
|
|
465
|
+
open,
|
|
466
|
+
onToggle,
|
|
467
|
+
controls,
|
|
468
|
+
changed,
|
|
469
|
+
}: {
|
|
470
|
+
title: React.ReactNode
|
|
471
|
+
open: boolean
|
|
472
|
+
onToggle: () => void
|
|
473
|
+
controls: string
|
|
474
|
+
changed: boolean
|
|
475
|
+
}) {
|
|
476
|
+
return (
|
|
477
|
+
<h3 className={TITLE_CLASS}>
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
aria-expanded={open}
|
|
481
|
+
aria-controls={controls}
|
|
482
|
+
onClick={onToggle}
|
|
483
|
+
className="-mx-1 flex w-[calc(100%+0.5rem)] items-center gap-1 px-1 text-left uppercase hover:text-foreground"
|
|
484
|
+
>
|
|
485
|
+
<svg
|
|
486
|
+
aria-hidden
|
|
487
|
+
viewBox="0 0 16 16"
|
|
488
|
+
className={`size-3 shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
|
|
489
|
+
>
|
|
490
|
+
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
491
|
+
</svg>
|
|
492
|
+
{title}
|
|
493
|
+
{changed && <ChangedMark />}
|
|
494
|
+
</button>
|
|
495
|
+
</h3>
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The mark on a tab or fold that hides a changed field: a dot, read as "changed". The row's gap spaces it. */
|
|
500
|
+
function ChangedMark() {
|
|
501
|
+
return (
|
|
502
|
+
<>
|
|
503
|
+
<span aria-hidden>•</span>
|
|
504
|
+
<span className="sr-only">, changed</span>
|
|
505
|
+
</>
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
180
509
|
function PropertyGroup({
|
|
181
510
|
group,
|
|
182
511
|
values,
|
|
183
512
|
ctx,
|
|
184
513
|
onChange,
|
|
514
|
+
changed,
|
|
515
|
+
flipped,
|
|
516
|
+
onFlip,
|
|
185
517
|
isLast,
|
|
186
518
|
}: {
|
|
187
519
|
group: PropertyGroupDef
|
|
188
520
|
values: Record<string, unknown>
|
|
189
521
|
ctx: ScopeContext
|
|
190
522
|
onChange?: (path: string, val: unknown) => void
|
|
523
|
+
changed: ReadonlySet<string>
|
|
524
|
+
/** Folded the other way from `defaultCollapsed`. */
|
|
525
|
+
flipped: boolean
|
|
526
|
+
onFlip: () => void
|
|
191
527
|
isLast: boolean
|
|
192
528
|
}) {
|
|
193
|
-
// Collapsing needs a title to click. Open or closed is the
|
|
194
|
-
//
|
|
529
|
+
// Collapsing needs a title to click. Open or closed is the panel's state;
|
|
530
|
+
// a host that wants it remembered across selections keys the panel.
|
|
195
531
|
const collapsible = Boolean(group.collapsible && group.title)
|
|
196
|
-
const
|
|
197
|
-
collapsible && Boolean(group.defaultCollapsed),
|
|
198
|
-
)
|
|
532
|
+
const collapsed = collapsible && Boolean(group.defaultCollapsed) !== flipped
|
|
199
533
|
const bodyId = useId()
|
|
200
534
|
const layout = useLabelLayout()
|
|
201
535
|
// A closed group keeps its "collapsed" fields, as compact rows under its title.
|
|
202
536
|
const headerFields = collapsible && collapsed ? rowsIn(group.rows, "collapsed").flat() : []
|
|
203
|
-
const titleClass =
|
|
204
|
-
"text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
205
537
|
|
|
206
538
|
return (
|
|
207
539
|
<section className="flex flex-col gap-3 px-3 py-3">
|
|
@@ -209,41 +541,29 @@ function PropertyGroup({
|
|
|
209
541
|
<header>
|
|
210
542
|
{group.title &&
|
|
211
543
|
(collapsible ? (
|
|
212
|
-
<
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
>
|
|
220
|
-
<svg
|
|
221
|
-
aria-hidden
|
|
222
|
-
viewBox="0 0 16 16"
|
|
223
|
-
className={
|
|
224
|
-
"size-3 shrink-0 transition-transform " +
|
|
225
|
-
(collapsed ? "-rotate-90" : "")
|
|
226
|
-
}
|
|
227
|
-
>
|
|
228
|
-
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
229
|
-
</svg>
|
|
230
|
-
{group.title}
|
|
231
|
-
</button>
|
|
232
|
-
</h3>
|
|
544
|
+
<FoldHeader
|
|
545
|
+
title={group.title}
|
|
546
|
+
open={!collapsed}
|
|
547
|
+
onToggle={onFlip}
|
|
548
|
+
controls={bodyId}
|
|
549
|
+
changed={collapsed && hasChanged([group], changed)}
|
|
550
|
+
/>
|
|
233
551
|
) : (
|
|
234
|
-
<h3 className={
|
|
552
|
+
<h3 className={TITLE_CLASS}>{group.title}</h3>
|
|
235
553
|
))}
|
|
236
554
|
{group.description && !(collapsible && collapsed) && (
|
|
237
|
-
<p className="mt-1 text-xs text-muted-foreground">
|
|
238
|
-
{group.description}
|
|
239
|
-
</p>
|
|
555
|
+
<p className="mt-1 text-xs text-muted-foreground">{group.description}</p>
|
|
240
556
|
)}
|
|
241
557
|
</header>
|
|
242
558
|
)}
|
|
243
559
|
{headerFields.length > 0 && (
|
|
244
560
|
<CompactRows fields={headerFields} values={values} ctx={ctx} onChange={onChange} />
|
|
245
561
|
)}
|
|
246
|
-
<div
|
|
562
|
+
<div
|
|
563
|
+
id={bodyId}
|
|
564
|
+
hidden={collapsible && collapsed}
|
|
565
|
+
className={`flex flex-col ${rowGap(layout)}`}
|
|
566
|
+
>
|
|
247
567
|
<Rows
|
|
248
568
|
rows={rowsIn(group.rows, "inspector")}
|
|
249
569
|
values={values}
|
|
@@ -272,7 +592,14 @@ function CompactRows({
|
|
|
272
592
|
return (
|
|
273
593
|
<div className="flex flex-col gap-1.5">
|
|
274
594
|
{fields.map((f) => (
|
|
275
|
-
<FieldSlot
|
|
595
|
+
<FieldSlot
|
|
596
|
+
key={f.id}
|
|
597
|
+
field={f}
|
|
598
|
+
values={values}
|
|
599
|
+
ctx={ctx}
|
|
600
|
+
onChange={onChange}
|
|
601
|
+
view="collapsed"
|
|
602
|
+
/>
|
|
276
603
|
))}
|
|
277
604
|
</div>
|
|
278
605
|
)
|
|
@@ -295,6 +622,7 @@ function Rows({
|
|
|
295
622
|
const layout = useLabelLayout()
|
|
296
623
|
return rows.map((fields, idx) => (
|
|
297
624
|
<div
|
|
625
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable id; the list is rebuilt from the schema
|
|
298
626
|
key={idx}
|
|
299
627
|
className={
|
|
300
628
|
fields.length > 1
|
|
@@ -305,14 +633,7 @@ function Rows({
|
|
|
305
633
|
}
|
|
306
634
|
>
|
|
307
635
|
{fields.map((f) => (
|
|
308
|
-
<FieldSlot
|
|
309
|
-
key={f.id}
|
|
310
|
-
field={f}
|
|
311
|
-
values={values}
|
|
312
|
-
ctx={ctx}
|
|
313
|
-
onChange={onChange}
|
|
314
|
-
view={view}
|
|
315
|
-
/>
|
|
636
|
+
<FieldSlot key={f.id} field={f} values={values} ctx={ctx} onChange={onChange} view={view} />
|
|
316
637
|
))}
|
|
317
638
|
</div>
|
|
318
639
|
))
|
|
@@ -332,36 +653,62 @@ function FieldSlot({
|
|
|
332
653
|
view: PanelView
|
|
333
654
|
}) {
|
|
334
655
|
const gesture = useContext(GestureContext)
|
|
656
|
+
const refusals = useContext(RefusalContext)
|
|
335
657
|
const renderer = getFieldRenderer(field.kind)
|
|
336
658
|
if (!renderer) {
|
|
337
659
|
console.warn("[properties] no renderer for kind:", field.kind)
|
|
338
660
|
return null
|
|
339
661
|
}
|
|
340
|
-
const disabled = (
|
|
341
|
-
const cond = field.disabledWhen
|
|
342
|
-
if (!cond) return false
|
|
343
|
-
const v = values[cond.path]
|
|
344
|
-
if (Object.prototype.hasOwnProperty.call(cond, "equals"))
|
|
345
|
-
return v === cond.equals
|
|
346
|
-
if (Object.prototype.hasOwnProperty.call(cond, "notEquals"))
|
|
347
|
-
return v !== cond.notEquals
|
|
348
|
-
if (cond.in) return cond.in.includes(v)
|
|
349
|
-
if (cond.notIn) return !cond.notIn.includes(v)
|
|
350
|
-
return false
|
|
351
|
-
})()
|
|
662
|
+
const disabled = isDisabled(field.disabledWhen, values)
|
|
352
663
|
|
|
353
664
|
// Display-only kinds (separator, label) have no path: no value, no writes.
|
|
354
665
|
const path = "path" in field ? field.path : undefined
|
|
355
|
-
|
|
666
|
+
const refusal = path === undefined ? undefined : refusals[path]
|
|
667
|
+
const control = renderer({
|
|
356
668
|
field,
|
|
357
669
|
value: path === undefined ? undefined : values[path],
|
|
358
670
|
disabled,
|
|
359
|
-
onChange:
|
|
360
|
-
onChange && path !== undefined ? (val) => onChange(path, val) : undefined,
|
|
671
|
+
onChange: onChange && path !== undefined ? (val) => onChange(path, val) : undefined,
|
|
361
672
|
onGestureBegin: gesture && path !== undefined ? () => gesture.begin(path) : undefined,
|
|
362
673
|
onGestureEnd: gesture && path !== undefined ? () => gesture.end(path) : undefined,
|
|
363
674
|
onGestureCancel: gesture && path !== undefined ? () => gesture.cancel(path) : undefined,
|
|
675
|
+
error: refusal && Object.is(refusal.at, values[path as string]) ? refusal.message : undefined,
|
|
364
676
|
ctx,
|
|
365
677
|
view,
|
|
366
|
-
|
|
678
|
+
// Renderers are synchronous; React's FC type allows a promise, which nothing returns.
|
|
679
|
+
}) as React.ReactNode
|
|
680
|
+
const help = view === "inspector" ? helpOf(field) : undefined
|
|
681
|
+
if (!help) return control
|
|
682
|
+
// One block, so in a grid row of two (the auto layout) the help stays in its field's cell.
|
|
683
|
+
return (
|
|
684
|
+
<div className="flex flex-col gap-0.5">
|
|
685
|
+
{control}
|
|
686
|
+
<FieldHelp text={help} />
|
|
687
|
+
</div>
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** A field's help; file fields' deprecated `helperText` when it has none. */
|
|
692
|
+
function helpOf(field: FieldDef): string | undefined {
|
|
693
|
+
if ("help" in field && field.help) return field.help
|
|
694
|
+
if (field.kind === "file") return (field as FileFieldDef).helperText
|
|
695
|
+
return undefined
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The help under a field: in a label column it starts at the control's edge,
|
|
700
|
+
* otherwise under the label. Quieter than the label, and selectable.
|
|
701
|
+
*/
|
|
702
|
+
function FieldHelp({ text }: { text: string }) {
|
|
703
|
+
const layout = useLabelLayout()
|
|
704
|
+
const p = <p className="select-text text-[11px] leading-snug text-muted-foreground">{text}</p>
|
|
705
|
+
if (layout === "column") {
|
|
706
|
+
return (
|
|
707
|
+
<div className={HELP_ROW}>
|
|
708
|
+
<span aria-hidden="true" />
|
|
709
|
+
{p}
|
|
710
|
+
</div>
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
return p
|
|
367
714
|
}
|