@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/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 onChange = readOnly || !scope.write
111
- ? undefined
112
- : (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
+ )
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
- <div className="flex flex-col" style={columnStyle(labelLayout, labelWidth, rows.flat())}>
136
- {heading && (
137
- <header className="border-b border-border px-3 py-2 text-xs font-semibold">
138
- {heading}
139
- </header>
140
- )}
141
- {rows.length > 0 && (
142
- <div className={`flex flex-col px-3 py-3 ${rowGap(labelLayout)}`}>
143
- <Rows rows={rows} values={values} ctx={ctx} onChange={onChange} view="card" />
144
- </div>
145
- )}
146
- </div>
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
- <div className="flex flex-col" style={columnStyle(labelLayout, labelWidth, all)}>
155
- {scope.schema.groups.map((group, idx) => (
156
- <PropertyGroup
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
- isLast={idx === scope.schema.groups.length - 1}
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
- </div>
166
- </LabelLayoutContext.Provider>
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 group's own
194
- // 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.
195
531
  const collapsible = Boolean(group.collapsible && group.title)
196
- const [collapsed, setCollapsed] = useState(
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
- <h3 className={titleClass}>
213
- <button
214
- type="button"
215
- aria-expanded={!collapsed}
216
- aria-controls={bodyId}
217
- onClick={() => setCollapsed((c) => !c)}
218
- className="-mx-1 flex w-[calc(100%+0.5rem)] items-center gap-1 px-1 text-left uppercase hover:text-foreground"
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={titleClass}>{group.title}</h3>
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 id={bodyId} hidden={collapsible && collapsed} className={`flex flex-col ${rowGap(layout)}`}>
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 key={f.id} field={f} values={values} ctx={ctx} onChange={onChange} view="collapsed" />
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
- return renderer({
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
  }