@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/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
- export function PropertyPanel({
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 onChange = readOnly || !scope.write
40
- ? undefined
41
- : (path: string, val: unknown) => scope.write!(path, val, selection, ctx)
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
- return (
44
- <div className="flex flex-col">
45
- {scope.schema.groups.map((group, idx) => (
46
- <PropertyGroup
47
- key={group.id}
48
- group={group}
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
- isLast={idx === scope.schema.groups.length - 1}
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
- </div>
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 group's own
73
- // state; a host that wants it remembered across selections keys the panel.
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 [collapsed, setCollapsed] = useState(
76
- collapsible && Boolean(group.defaultCollapsed),
77
- )
532
+ const collapsed = collapsible && Boolean(group.defaultCollapsed) !== flipped
78
533
  const bodyId = useId()
79
- const titleClass =
80
- "text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
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
- <h3 className={titleClass}>
89
- <button
90
- type="button"
91
- aria-expanded={!collapsed}
92
- aria-controls={bodyId}
93
- onClick={() => setCollapsed((c) => !c)}
94
- className="-mx-1 flex w-[calc(100%+0.5rem)] items-center gap-1 px-1 text-left uppercase hover:text-foreground"
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={titleClass}>{group.title}</h3>
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
- <div id={bodyId} hidden={collapsible && collapsed} className="flex flex-col gap-3">
120
- {group.rows.map((row, idx) => {
121
- const fields = Array.isArray(row) ? row : [row]
122
- return (
123
- <div
124
- key={idx}
125
- className={
126
- fields.length > 1 ? "grid grid-cols-2 gap-2" : undefined
127
- }
128
- >
129
- {fields.map((f) => (
130
- <FieldSlot
131
- key={f.id}
132
- field={f}
133
- values={values}
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
- return renderer({
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
- onChange && path !== undefined ? (val) => onChange(path, val) : undefined,
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
  }