@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/panel.tsx
CHANGED
|
@@ -1,28 +1,190 @@
|
|
|
1
|
-
import { useId, useMemo, useState } from "react"
|
|
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
12
|
import type {
|
|
13
|
+
DisabledWhen,
|
|
14
|
+
DisabledWhenAny,
|
|
5
15
|
FieldDef,
|
|
16
|
+
FileFieldDef,
|
|
17
|
+
LabelLayout,
|
|
18
|
+
PanelView,
|
|
6
19
|
PropertyGroupDef,
|
|
20
|
+
PropertySchema,
|
|
7
21
|
ScopeContext,
|
|
8
22
|
} from "./types"
|
|
9
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
|
+
|
|
10
40
|
interface PropertyPanelProps {
|
|
11
41
|
scopeKey: string
|
|
12
42
|
selection: unknown
|
|
13
43
|
ctx: ScopeContext
|
|
14
44
|
readOnly?: boolean
|
|
15
45
|
emptyState?: React.ReactNode
|
|
46
|
+
/** Defaults to `"inspector"`, every field. See `PanelView`. */
|
|
47
|
+
view?: PanelView
|
|
48
|
+
/** The card's heading. Overrides the schema's `title`; neither means no heading. */
|
|
49
|
+
title?: React.ReactNode
|
|
50
|
+
/**
|
|
51
|
+
* Where labels sit in the inspector and card views: `"column"` (default since 0.2.0: one shared label column), `"auto"` (the 0.1 layout)
|
|
52
|
+
* (one shared label column) or `"stacked"` (label above). See `LabelLayout`. The collapsed view
|
|
53
|
+
* ignores it.
|
|
54
|
+
*/
|
|
55
|
+
labelLayout?: LabelLayout
|
|
56
|
+
/** `"column"` only: the label column's width as a CSS length. Default: fits the longest label, 8ch to 16ch. */
|
|
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
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The scope's gesture, bound to its selection and ctx, for the field slots below. */
|
|
77
|
+
interface GestureHandlers {
|
|
78
|
+
begin: (path: string) => void
|
|
79
|
+
end: (path: string) => void
|
|
80
|
+
cancel: (path: string) => void
|
|
81
|
+
}
|
|
82
|
+
const GestureContext = createContext<GestureHandlers | null>(null)
|
|
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
|
+
|
|
97
|
+
type Row = Array<FieldDef | FieldDef[]>[number]
|
|
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
|
+
})
|
|
16
119
|
}
|
|
17
120
|
|
|
18
|
-
|
|
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
|
+
|
|
133
|
+
/** Whether a field shows in a view: every field in the inspector, else only those promoted to it. */
|
|
134
|
+
export function showsIn(field: FieldDef, view: PanelView): boolean {
|
|
135
|
+
return view === "inspector" || (field.promote?.includes(view) ?? false)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The fields a view draws, in schema order, with paired rows flattened. */
|
|
139
|
+
export function promotedFields(schema: PropertySchema, view: PanelView): FieldDef[] {
|
|
140
|
+
return schema.groups
|
|
141
|
+
.flatMap((g) => g.rows)
|
|
142
|
+
.flatMap((row) => (Array.isArray(row) ? row : [row]))
|
|
143
|
+
.filter((f) => showsIn(f, view))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A group's rows kept to one view: fields filtered, empty rows dropped. */
|
|
147
|
+
function rowsIn(rows: Row[], view: PanelView): FieldDef[][] {
|
|
148
|
+
return rows
|
|
149
|
+
.map((row) => (Array.isArray(row) ? row : [row]).filter((f) => showsIn(f, view)))
|
|
150
|
+
.filter((fields) => fields.length > 0)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function PropertyPanel(props: PropertyPanelProps) {
|
|
154
|
+
const { scopeKey, selection, ctx, readOnly = false } = props
|
|
155
|
+
const gesture = getScope(scopeKey)?.gesture
|
|
156
|
+
const handlers = useMemo<GestureHandlers | null>(
|
|
157
|
+
() =>
|
|
158
|
+
readOnly || !gesture
|
|
159
|
+
? null
|
|
160
|
+
: {
|
|
161
|
+
begin: (path) => gesture.begin(path, selection, ctx),
|
|
162
|
+
end: (path) => gesture.end(path, selection, ctx),
|
|
163
|
+
cancel: (path) => gesture.cancel?.(path, selection, ctx),
|
|
164
|
+
},
|
|
165
|
+
[readOnly, gesture, selection, ctx],
|
|
166
|
+
)
|
|
167
|
+
return (
|
|
168
|
+
<GestureContext.Provider value={handlers}>
|
|
169
|
+
<PanelBody {...props} />
|
|
170
|
+
</GestureContext.Provider>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function PanelBody({
|
|
19
175
|
scopeKey,
|
|
20
176
|
selection,
|
|
21
177
|
ctx,
|
|
22
178
|
readOnly = false,
|
|
23
179
|
emptyState,
|
|
180
|
+
view = "inspector",
|
|
181
|
+
title,
|
|
182
|
+
labelLayout = "column",
|
|
183
|
+
labelWidth,
|
|
184
|
+
...pageProps
|
|
24
185
|
}: PropertyPanelProps) {
|
|
25
186
|
const scope = getScope(scopeKey)
|
|
187
|
+
const [refused, setRefused] = useState<Refusals>({})
|
|
26
188
|
|
|
27
189
|
const values = useMemo(() => {
|
|
28
190
|
if (!scope) return {} as Record<string, unknown>
|
|
@@ -34,25 +196,313 @@ export function PropertyPanel({
|
|
|
34
196
|
}
|
|
35
197
|
}, [scope, scopeKey, selection, ctx])
|
|
36
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
|
+
|
|
37
208
|
if (!scope) return <>{emptyState ?? null}</>
|
|
38
209
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
)
|
|
42
235
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
236
|
+
// Collapsed: what a folded scope still shows, one compact row per field.
|
|
237
|
+
// The host draws the title bar that folds it. Nothing when nothing is
|
|
238
|
+
// promoted.
|
|
239
|
+
if (view === "collapsed") {
|
|
240
|
+
const fields = promotedFields(scope.schema, "collapsed")
|
|
241
|
+
if (fields.length === 0) return null
|
|
242
|
+
return wrap(
|
|
243
|
+
<div className="px-3 py-2">
|
|
244
|
+
<CompactRows fields={fields} values={values} ctx={ctx} onChange={onChange} />
|
|
245
|
+
</div>,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Card: an optional heading, then the fields promoted to it, with no group
|
|
250
|
+
// chrome.
|
|
251
|
+
if (view === "card") {
|
|
252
|
+
const rows = scope.schema.groups.flatMap((g) => rowsIn(g.rows, "card"))
|
|
253
|
+
const heading = title ?? scope.schema.title
|
|
254
|
+
if (rows.length === 0 && !heading) return null
|
|
255
|
+
return wrap(
|
|
256
|
+
<LabelLayoutContext.Provider value={labelLayout}>
|
|
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>,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// One label column across every page, so it doesn't jump between tabs.
|
|
274
|
+
const all = scope.schema.groups.flatMap((g) => rowsIn(g.rows, "inspector")).flat()
|
|
275
|
+
return wrap(
|
|
276
|
+
<LabelLayoutContext.Provider value={labelLayout}>
|
|
277
|
+
<div className="flex flex-col" style={columnStyle(labelLayout, labelWidth, all)}>
|
|
278
|
+
<Inspector
|
|
279
|
+
schema={scope.schema}
|
|
49
280
|
values={values}
|
|
50
281
|
ctx={ctx}
|
|
51
282
|
onChange={onChange}
|
|
52
|
-
|
|
283
|
+
changed={changed}
|
|
284
|
+
{...pageProps}
|
|
53
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>
|
|
54
379
|
))}
|
|
55
|
-
</
|
|
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
|
+
</>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The shared label width, as a style on the panel root; nothing outside `"column"`. */
|
|
447
|
+
function columnStyle(layout: LabelLayout, width: string | undefined, fields: FieldDef[]) {
|
|
448
|
+
return layout === "column"
|
|
449
|
+
? ({ [LABEL_WIDTH_VAR]: width ?? labelColumnWidth(fields) } as React.CSSProperties)
|
|
450
|
+
: undefined
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Space between rows: tighter in a label column, where the rows read as a table. */
|
|
454
|
+
const rowGap = (layout: LabelLayout) => (layout === "column" ? "gap-1.5" : "gap-3")
|
|
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
|
+
</>
|
|
56
506
|
)
|
|
57
507
|
}
|
|
58
508
|
|
|
@@ -61,23 +511,29 @@ function PropertyGroup({
|
|
|
61
511
|
values,
|
|
62
512
|
ctx,
|
|
63
513
|
onChange,
|
|
514
|
+
changed,
|
|
515
|
+
flipped,
|
|
516
|
+
onFlip,
|
|
64
517
|
isLast,
|
|
65
518
|
}: {
|
|
66
519
|
group: PropertyGroupDef
|
|
67
520
|
values: Record<string, unknown>
|
|
68
521
|
ctx: ScopeContext
|
|
69
522
|
onChange?: (path: string, val: unknown) => void
|
|
523
|
+
changed: ReadonlySet<string>
|
|
524
|
+
/** Folded the other way from `defaultCollapsed`. */
|
|
525
|
+
flipped: boolean
|
|
526
|
+
onFlip: () => void
|
|
70
527
|
isLast: boolean
|
|
71
528
|
}) {
|
|
72
|
-
// Collapsing needs a title to click. Open or closed is the
|
|
73
|
-
//
|
|
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.
|
|
74
531
|
const collapsible = Boolean(group.collapsible && group.title)
|
|
75
|
-
const
|
|
76
|
-
collapsible && Boolean(group.defaultCollapsed),
|
|
77
|
-
)
|
|
532
|
+
const collapsed = collapsible && Boolean(group.defaultCollapsed) !== flipped
|
|
78
533
|
const bodyId = useId()
|
|
79
|
-
const
|
|
80
|
-
|
|
534
|
+
const layout = useLabelLayout()
|
|
535
|
+
// A closed group keeps its "collapsed" fields, as compact rows under its title.
|
|
536
|
+
const headerFields = collapsible && collapsed ? rowsIn(group.rows, "collapsed").flat() : []
|
|
81
537
|
|
|
82
538
|
return (
|
|
83
539
|
<section className="flex flex-col gap-3 px-3 py-3">
|
|
@@ -85,102 +541,174 @@ function PropertyGroup({
|
|
|
85
541
|
<header>
|
|
86
542
|
{group.title &&
|
|
87
543
|
(collapsible ? (
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
>
|
|
96
|
-
<svg
|
|
97
|
-
aria-hidden
|
|
98
|
-
viewBox="0 0 16 16"
|
|
99
|
-
className={
|
|
100
|
-
"size-3 shrink-0 transition-transform " +
|
|
101
|
-
(collapsed ? "-rotate-90" : "")
|
|
102
|
-
}
|
|
103
|
-
>
|
|
104
|
-
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
105
|
-
</svg>
|
|
106
|
-
{group.title}
|
|
107
|
-
</button>
|
|
108
|
-
</h3>
|
|
544
|
+
<FoldHeader
|
|
545
|
+
title={group.title}
|
|
546
|
+
open={!collapsed}
|
|
547
|
+
onToggle={onFlip}
|
|
548
|
+
controls={bodyId}
|
|
549
|
+
changed={collapsed && hasChanged([group], changed)}
|
|
550
|
+
/>
|
|
109
551
|
) : (
|
|
110
|
-
<h3 className={
|
|
552
|
+
<h3 className={TITLE_CLASS}>{group.title}</h3>
|
|
111
553
|
))}
|
|
112
554
|
{group.description && !(collapsible && collapsed) && (
|
|
113
|
-
<p className="mt-1 text-xs text-muted-foreground">
|
|
114
|
-
{group.description}
|
|
115
|
-
</p>
|
|
555
|
+
<p className="mt-1 text-xs text-muted-foreground">{group.description}</p>
|
|
116
556
|
)}
|
|
117
557
|
</header>
|
|
118
558
|
)}
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
ctx={ctx}
|
|
135
|
-
onChange={onChange}
|
|
136
|
-
/>
|
|
137
|
-
))}
|
|
138
|
-
</div>
|
|
139
|
-
)
|
|
140
|
-
})}
|
|
559
|
+
{headerFields.length > 0 && (
|
|
560
|
+
<CompactRows fields={headerFields} values={values} ctx={ctx} onChange={onChange} />
|
|
561
|
+
)}
|
|
562
|
+
<div
|
|
563
|
+
id={bodyId}
|
|
564
|
+
hidden={collapsible && collapsed}
|
|
565
|
+
className={`flex flex-col ${rowGap(layout)}`}
|
|
566
|
+
>
|
|
567
|
+
<Rows
|
|
568
|
+
rows={rowsIn(group.rows, "inspector")}
|
|
569
|
+
values={values}
|
|
570
|
+
ctx={ctx}
|
|
571
|
+
onChange={onChange}
|
|
572
|
+
view="inspector"
|
|
573
|
+
/>
|
|
141
574
|
</div>
|
|
142
575
|
{!isLast && <Separator className="mt-1" />}
|
|
143
576
|
</section>
|
|
144
577
|
)
|
|
145
578
|
}
|
|
146
579
|
|
|
580
|
+
/** One field per row, label and control side by side: the collapsed views. */
|
|
581
|
+
function CompactRows({
|
|
582
|
+
fields,
|
|
583
|
+
values,
|
|
584
|
+
ctx,
|
|
585
|
+
onChange,
|
|
586
|
+
}: {
|
|
587
|
+
fields: FieldDef[]
|
|
588
|
+
values: Record<string, unknown>
|
|
589
|
+
ctx: ScopeContext
|
|
590
|
+
onChange?: (path: string, val: unknown) => void
|
|
591
|
+
}) {
|
|
592
|
+
return (
|
|
593
|
+
<div className="flex flex-col gap-1.5">
|
|
594
|
+
{fields.map((f) => (
|
|
595
|
+
<FieldSlot
|
|
596
|
+
key={f.id}
|
|
597
|
+
field={f}
|
|
598
|
+
values={values}
|
|
599
|
+
ctx={ctx}
|
|
600
|
+
onChange={onChange}
|
|
601
|
+
view="collapsed"
|
|
602
|
+
/>
|
|
603
|
+
))}
|
|
604
|
+
</div>
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function Rows({
|
|
609
|
+
rows,
|
|
610
|
+
values,
|
|
611
|
+
ctx,
|
|
612
|
+
onChange,
|
|
613
|
+
view,
|
|
614
|
+
}: {
|
|
615
|
+
rows: FieldDef[][]
|
|
616
|
+
values: Record<string, unknown>
|
|
617
|
+
ctx: ScopeContext
|
|
618
|
+
onChange?: (path: string, val: unknown) => void
|
|
619
|
+
view: PanelView
|
|
620
|
+
}) {
|
|
621
|
+
// In a label column a pair has no room for two labels: its fields take a row each.
|
|
622
|
+
const layout = useLabelLayout()
|
|
623
|
+
return rows.map((fields, idx) => (
|
|
624
|
+
<div
|
|
625
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable id; the list is rebuilt from the schema
|
|
626
|
+
key={idx}
|
|
627
|
+
className={
|
|
628
|
+
fields.length > 1
|
|
629
|
+
? layout === "column"
|
|
630
|
+
? "contents"
|
|
631
|
+
: "grid grid-cols-2 gap-2"
|
|
632
|
+
: undefined
|
|
633
|
+
}
|
|
634
|
+
>
|
|
635
|
+
{fields.map((f) => (
|
|
636
|
+
<FieldSlot key={f.id} field={f} values={values} ctx={ctx} onChange={onChange} view={view} />
|
|
637
|
+
))}
|
|
638
|
+
</div>
|
|
639
|
+
))
|
|
640
|
+
}
|
|
641
|
+
|
|
147
642
|
function FieldSlot({
|
|
148
643
|
field,
|
|
149
644
|
values,
|
|
150
645
|
ctx,
|
|
151
646
|
onChange,
|
|
647
|
+
view,
|
|
152
648
|
}: {
|
|
153
649
|
field: FieldDef
|
|
154
650
|
values: Record<string, unknown>
|
|
155
651
|
ctx: ScopeContext
|
|
156
652
|
onChange?: (path: string, val: unknown) => void
|
|
653
|
+
view: PanelView
|
|
157
654
|
}) {
|
|
655
|
+
const gesture = useContext(GestureContext)
|
|
656
|
+
const refusals = useContext(RefusalContext)
|
|
158
657
|
const renderer = getFieldRenderer(field.kind)
|
|
159
658
|
if (!renderer) {
|
|
160
659
|
console.warn("[properties] no renderer for kind:", field.kind)
|
|
161
660
|
return null
|
|
162
661
|
}
|
|
163
|
-
const disabled = (
|
|
164
|
-
const cond = field.disabledWhen
|
|
165
|
-
if (!cond) return false
|
|
166
|
-
const v = values[cond.path]
|
|
167
|
-
if (Object.prototype.hasOwnProperty.call(cond, "equals"))
|
|
168
|
-
return v === cond.equals
|
|
169
|
-
if (Object.prototype.hasOwnProperty.call(cond, "notEquals"))
|
|
170
|
-
return v !== cond.notEquals
|
|
171
|
-
if (cond.in) return cond.in.includes(v)
|
|
172
|
-
if (cond.notIn) return !cond.notIn.includes(v)
|
|
173
|
-
return false
|
|
174
|
-
})()
|
|
662
|
+
const disabled = isDisabled(field.disabledWhen, values)
|
|
175
663
|
|
|
176
664
|
// Display-only kinds (separator, label) have no path: no value, no writes.
|
|
177
665
|
const path = "path" in field ? field.path : undefined
|
|
178
|
-
|
|
666
|
+
const refusal = path === undefined ? undefined : refusals[path]
|
|
667
|
+
const control = renderer({
|
|
179
668
|
field,
|
|
180
669
|
value: path === undefined ? undefined : values[path],
|
|
181
670
|
disabled,
|
|
182
|
-
onChange:
|
|
183
|
-
|
|
671
|
+
onChange: onChange && path !== undefined ? (val) => onChange(path, val) : undefined,
|
|
672
|
+
onGestureBegin: gesture && path !== undefined ? () => gesture.begin(path) : undefined,
|
|
673
|
+
onGestureEnd: gesture && path !== undefined ? () => gesture.end(path) : undefined,
|
|
674
|
+
onGestureCancel: gesture && path !== undefined ? () => gesture.cancel(path) : undefined,
|
|
675
|
+
error: refusal && Object.is(refusal.at, values[path as string]) ? refusal.message : undefined,
|
|
184
676
|
ctx,
|
|
185
|
-
|
|
677
|
+
view,
|
|
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
|
|
186
714
|
}
|