@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.
@@ -3,9 +3,13 @@
3
3
  * components plus a single `registerBuiltinRenderers` entry point. Splitting
4
4
  * each renderer into its own file would be over-organisation for code that
5
5
  * never hot-reloads in isolation. */
6
- import { useMemo, useRef } from "react"
6
+ import { useEffect, useId, useMemo, useRef } from "react"
7
+ import { Checkbox } from "@/components/ui/checkbox"
8
+ import { ColorField } from "@/components/ui/color-field"
7
9
  import { Input } from "@/components/ui/input"
8
10
  import { Label } from "@/components/ui/label"
11
+ import { NumberField } from "@/components/ui/number-field"
12
+ import { PathField } from "@/components/ui/path-field"
9
13
  import {
10
14
  Select,
11
15
  SelectContent,
@@ -13,27 +17,25 @@ import {
13
17
  SelectTrigger,
14
18
  SelectValue,
15
19
  } from "@/components/ui/select"
16
- import { Checkbox } from "@/components/ui/checkbox"
17
20
  import { Separator } from "@/components/ui/separator"
18
- import { NumberField } from "@/components/ui/number-field"
19
21
  import { Slider } from "@/components/ui/slider"
20
- import { ColorField } from "@/components/ui/color-field"
21
- import { registerFieldRenderer } from "./registry"
22
+ import { filtersOf, useFileHandlers, useMissing, useRecent } from "./file-handlers"
22
23
  import { COLUMN_ROW, NUMBER_WIDTH, NUMBER_WIDTH_INSIDE, useLabelLayout } from "./layout"
24
+ import { registerFieldRenderer } from "./registry"
23
25
  import type {
24
26
  CheckboxFieldDef,
25
27
  ColorFieldDef,
26
28
  FieldRenderer,
27
29
  FieldRendererProps,
28
- NumberFieldDef,
29
- SelectFieldDef,
30
- SliderFieldDef,
31
- TextFieldDef,
32
- TextareaFieldDef,
33
30
  FileFieldDef,
34
31
  LabelFieldDef,
32
+ NumberFieldDef,
35
33
  PanelView,
34
+ SelectFieldDef,
36
35
  SeparatorFieldDef,
36
+ SliderFieldDef,
37
+ TextareaFieldDef,
38
+ TextFieldDef,
37
39
  VectorFieldDef,
38
40
  } from "./types"
39
41
 
@@ -59,10 +61,13 @@ export function FieldShell({
59
61
  view,
60
62
  top,
61
63
  above,
64
+ htmlFor,
62
65
  children,
63
66
  }: {
64
67
  label?: string
65
68
  view?: PanelView
69
+ /** The id of the control the label names, so clicking the label focuses it. */
70
+ htmlFor?: string
66
71
  /** In a label column: align the label to the top of a tall control. */
67
72
  top?: boolean
68
73
  /** The label sits above the control even in a label column (a wide control, like a vector of four). */
@@ -74,7 +79,10 @@ export function FieldShell({
74
79
  if (view === "collapsed") {
75
80
  return (
76
81
  <div className="grid grid-cols-[5.5rem_minmax(0,1fr)] items-center gap-2">
77
- <Label className="truncate text-[11px] font-medium text-muted-foreground tracking-wide">
82
+ <Label
83
+ htmlFor={htmlFor}
84
+ className="truncate text-[11px] font-medium text-muted-foreground tracking-wide"
85
+ >
78
86
  {label}
79
87
  </Label>
80
88
  <div className="min-w-0">{children}</div>
@@ -86,6 +94,7 @@ export function FieldShell({
86
94
  return (
87
95
  <div className={top ? COLUMN_ROW.replace("items-center", "items-start") : COLUMN_ROW}>
88
96
  <Label
97
+ htmlFor={htmlFor}
89
98
  title={label}
90
99
  className={
91
100
  "block truncate text-[11px] font-medium text-muted-foreground tracking-wide" +
@@ -101,7 +110,10 @@ export function FieldShell({
101
110
  return (
102
111
  <div className="flex flex-col gap-1">
103
112
  {label && (
104
- <Label className="text-[11px] font-medium text-muted-foreground tracking-wide">
113
+ <Label
114
+ htmlFor={htmlFor}
115
+ className="text-[11px] font-medium text-muted-foreground tracking-wide"
116
+ >
105
117
  {label}
106
118
  </Label>
107
119
  )}
@@ -126,13 +138,7 @@ function formatPrimitive(value: unknown): string {
126
138
  return String(value)
127
139
  }
128
140
 
129
- const TextRenderer: FieldRenderer<TextFieldDef> = ({
130
- field,
131
- value,
132
- disabled,
133
- onChange,
134
- view,
135
- }) => {
141
+ const TextRenderer: FieldRenderer<TextFieldDef> = ({ field, value, disabled, onChange, view }) => {
136
142
  const display = formatPrimitive(value)
137
143
  return (
138
144
  <FieldShell label={field.label ?? field.id} view={view}>
@@ -199,11 +205,7 @@ const NumberRenderer: FieldRenderer<NumberFieldDef> = ({
199
205
  }) => {
200
206
  const layout = useLabelLayout()
201
207
  const num =
202
- typeof value === "number"
203
- ? value
204
- : value != null && value !== ""
205
- ? Number(value)
206
- : null
208
+ typeof value === "number" ? value : value != null && value !== "" ? Number(value) : null
207
209
  const finite = num != null && Number.isFinite(num)
208
210
  const label = field.label ?? field.id
209
211
  if (!onChange) {
@@ -299,9 +301,7 @@ const SliderRenderer: FieldRenderer<SliderFieldDef> = ({
299
301
  )}
300
302
  </div>
301
303
  ) : (
302
- <ReadOnlyText>
303
- {Number.isFinite(num) ? num.toLocaleString() : <Empty />}
304
- </ReadOnlyText>
304
+ <ReadOnlyText>{Number.isFinite(num) ? num.toLocaleString() : <Empty />}</ReadOnlyText>
305
305
  )}
306
306
  </FieldShell>
307
307
  )
@@ -324,19 +324,13 @@ const SelectRenderer: FieldRenderer<SelectFieldDef> = ({
324
324
  if (!onChange) {
325
325
  return (
326
326
  <FieldShell label={field.label ?? field.id} view={view}>
327
- <ReadOnlyText>
328
- {matched ? matched.label : current ? current : <Empty />}
329
- </ReadOnlyText>
327
+ <ReadOnlyText>{matched ? matched.label : current ? current : <Empty />}</ReadOnlyText>
330
328
  </FieldShell>
331
329
  )
332
330
  }
333
331
  return (
334
332
  <FieldShell label={field.label ?? field.id} view={view}>
335
- <Select
336
- value={current}
337
- onValueChange={(v) => onChange(v)}
338
- disabled={disabled}
339
- >
333
+ <Select value={current} onValueChange={(v) => onChange(v)} disabled={disabled}>
340
334
  <SelectTrigger className="w-full">
341
335
  {/* Children, not the primitive's own lookup: Base UI's Value shows
342
336
  the raw value unless it's told the label. */}
@@ -432,14 +426,93 @@ const CheckboxRenderer: FieldRenderer<CheckboxFieldDef> = ({
432
426
  const FileRenderer: FieldRenderer<FileFieldDef> = ({
433
427
  field,
434
428
  value,
429
+ disabled,
430
+ onChange,
431
+ error,
435
432
  view,
436
433
  }) => {
437
- const asset = value as { name?: string } | null | undefined
434
+ const label = field.label ?? field.id
435
+ const inputId = useId()
436
+ const h = useFileHandlers()
437
+ const path = typeof value === "string" && value !== "" ? value : null
438
+ const missing = useMissing(path, h)
439
+ const [recent, reloadRecent] = useRecent(field.recentKey, h, path)
440
+
441
+ // What was last committed and not yet seen come back as the value. The host's `write` is the
442
+ // validator and may be asynchronous, so the field cannot tell "accepted" from the call: it
443
+ // sees it when the value prop becomes the committed path. Only then is the path remembered. A
444
+ // refusal (`error`) or any other change of value drops the pending commit.
445
+ const pending = useRef<string | null | undefined>(undefined)
446
+ // biome-ignore lint/correctness/useExhaustiveDependencies: settles when the value changes; the rest is read fresh
447
+ useEffect(() => {
448
+ const committed = pending.current
449
+ pending.current = undefined
450
+ if (committed !== undefined && committed === path && path !== null && field.recentKey) {
451
+ Promise.resolve(h?.remember?.(field.recentKey, path)).then(reloadRecent, () => {})
452
+ }
453
+ }, [path])
454
+ useEffect(() => {
455
+ if (error) pending.current = undefined
456
+ }, [error])
457
+
458
+ // The context menu stays native, and the text menu keeps the field while its text is being
459
+ // edited: whether the input had focus is read before the click that opens the menu moves it.
460
+ const editing = useRef(false)
461
+
462
+ // The old read-only `{ name }` value.
463
+ if (value != null && typeof value === "object") {
464
+ const name = (value as { name?: string }).name
465
+ return (
466
+ <FieldShell label={label} view={view}>
467
+ <ReadOnlyText>{name ? name : <Empty />}</ReadOnlyText>
468
+ </FieldShell>
469
+ )
470
+ }
471
+
472
+ const editable = !!onChange
473
+ const commit = (next: string | null) => {
474
+ pending.current = next
475
+ onChange?.(next)
476
+ }
477
+ const pick = h?.pick
478
+ const menu = h?.contextMenu
479
+ const act = async () => {
480
+ const choice = await menu?.({ missing: missing || path === null, canClear: editable && !!path })
481
+ if (choice === "reveal" && path) {
482
+ try {
483
+ await h?.reveal?.(path, h.base?.())
484
+ } catch {
485
+ // The file went between the check and the click; the next focus check says Missing.
486
+ }
487
+ } else if (choice === "clear" && editable && path) commit(null)
488
+ }
489
+
438
490
  return (
439
- <FieldShell label={field.label ?? field.id} view={view}>
440
- <ReadOnlyText>
441
- {asset?.name ? asset.name : <Empty />}
442
- </ReadOnlyText>
491
+ <FieldShell label={label} view={view} htmlFor={inputId}>
492
+ <PathField
493
+ id={inputId}
494
+ value={path}
495
+ placeholder={field.placeholder}
496
+ disabled={disabled}
497
+ status={error ? "invalid" : missing ? "missing" : "ok"}
498
+ message={error}
499
+ onCommit={editable ? commit : undefined}
500
+ recent={editable && recent.length > 0 ? recent : undefined}
501
+ onBrowse={
502
+ editable && pick
503
+ ? () => pick({ filters: filtersOf(field), title: field.title })
504
+ : undefined
505
+ }
506
+ onMouseDownCapture={(e) => {
507
+ const input = e.currentTarget.querySelector("input")
508
+ editing.current = input !== null && input === document.activeElement
509
+ }}
510
+ onContextMenu={(e) => {
511
+ if (!menu || disabled || editing.current) return
512
+ e.preventDefault()
513
+ void act()
514
+ }}
515
+ />
443
516
  </FieldShell>
444
517
  )
445
518
  }
@@ -454,11 +527,7 @@ function toNumberArray(value: unknown, arity: number): Array<number | null> {
454
527
  })
455
528
  }
456
529
 
457
- function formatComponent(
458
- n: number | null,
459
- integer: boolean,
460
- precision?: number,
461
- ): string {
530
+ function formatComponent(n: number | null, integer: boolean, precision?: number): string {
462
531
  if (n == null) return ""
463
532
  if (integer) return Math.trunc(n).toLocaleString()
464
533
  if (precision != null) return n.toFixed(precision)
@@ -493,6 +562,7 @@ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
493
562
  style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
494
563
  >
495
564
  {field.components.map((c, i) => (
565
+ // biome-ignore lint/suspicious/noArrayIndexKey: components are a fixed positional tuple
496
566
  <div key={i} className="flex flex-col gap-0.5 min-w-0">
497
567
  {c.label && (
498
568
  <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
@@ -505,9 +575,7 @@ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
505
575
  ) : (
506
576
  <>
507
577
  {formatComponent(values[i], isInt, field.precision)}
508
- {c.suffix && (
509
- <span className="text-muted-foreground">{c.suffix}</span>
510
- )}
578
+ {c.suffix && <span className="text-muted-foreground">{c.suffix}</span>}
511
579
  </>
512
580
  )}
513
581
  </div>
@@ -526,6 +594,7 @@ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
526
594
  >
527
595
  {field.components.map((c, i) => (
528
596
  <NumberField
597
+ // biome-ignore lint/suspicious/noArrayIndexKey: components are a fixed positional tuple
529
598
  key={i}
530
599
  label={c.label ?? AXES[i] ?? String(i + 1)}
531
600
  value={values[i] ?? c.min ?? 0}
@@ -566,63 +635,21 @@ const SeparatorRenderer: FieldRenderer<SeparatorFieldDef> = ({ field, view }) =>
566
635
  // The same in every mode: there is nothing to edit. Greys out under
567
636
  // disabledWhen so a hint can follow the field it describes.
568
637
  const LabelRenderer: FieldRenderer<LabelFieldDef> = ({ field, disabled }) => (
569
- <p
570
- className={
571
- "text-xs text-muted-foreground" + (disabled ? " opacity-50" : "")
572
- }
573
- >
574
- {field.label}
575
- </p>
638
+ <p className={`text-xs text-muted-foreground${disabled ? " opacity-50" : ""}`}>{field.label}</p>
576
639
  )
577
640
 
578
641
  export function registerBuiltinRenderers(): void {
579
- registerFieldRenderer(
580
- "separator",
581
- SeparatorRenderer as FieldRenderer<import("./types").FieldDef>,
582
- )
583
- registerFieldRenderer(
584
- "label",
585
- LabelRenderer as FieldRenderer<import("./types").FieldDef>,
586
- )
587
- registerFieldRenderer(
588
- "text",
589
- TextRenderer as FieldRenderer<import("./types").FieldDef>,
590
- )
591
- registerFieldRenderer(
592
- "textarea",
593
- TextareaRenderer as FieldRenderer<import("./types").FieldDef>,
594
- )
595
- registerFieldRenderer(
596
- "number",
597
- NumberRenderer as FieldRenderer<import("./types").FieldDef>,
598
- )
599
- registerFieldRenderer(
600
- "slider",
601
- SliderRenderer as FieldRenderer<import("./types").FieldDef>,
602
- )
603
- registerFieldRenderer(
604
- "select",
605
- SelectRenderer as FieldRenderer<import("./types").FieldDef>,
606
- )
607
- registerFieldRenderer(
608
- "color",
609
- ColorRenderer as FieldRenderer<import("./types").FieldDef>,
610
- )
611
- registerFieldRenderer(
612
- "checkbox",
613
- CheckboxRenderer as FieldRenderer<import("./types").FieldDef>,
614
- )
615
- registerFieldRenderer(
616
- "file",
617
- FileRenderer as FieldRenderer<import("./types").FieldDef>,
618
- )
619
- registerFieldRenderer(
620
- "vector",
621
- VectorRenderer as FieldRenderer<import("./types").FieldDef>,
622
- )
642
+ registerFieldRenderer("separator", SeparatorRenderer as FieldRenderer<import("./types").FieldDef>)
643
+ registerFieldRenderer("label", LabelRenderer as FieldRenderer<import("./types").FieldDef>)
644
+ registerFieldRenderer("text", TextRenderer as FieldRenderer<import("./types").FieldDef>)
645
+ registerFieldRenderer("textarea", TextareaRenderer as FieldRenderer<import("./types").FieldDef>)
646
+ registerFieldRenderer("number", NumberRenderer as FieldRenderer<import("./types").FieldDef>)
647
+ registerFieldRenderer("slider", SliderRenderer as FieldRenderer<import("./types").FieldDef>)
648
+ registerFieldRenderer("select", SelectRenderer as FieldRenderer<import("./types").FieldDef>)
649
+ registerFieldRenderer("color", ColorRenderer as FieldRenderer<import("./types").FieldDef>)
650
+ registerFieldRenderer("checkbox", CheckboxRenderer as FieldRenderer<import("./types").FieldDef>)
651
+ registerFieldRenderer("file", FileRenderer as FieldRenderer<import("./types").FieldDef>)
652
+ registerFieldRenderer("vector", VectorRenderer as FieldRenderer<import("./types").FieldDef>)
623
653
  }
624
654
 
625
- export type {
626
- FieldRenderer,
627
- FieldRendererProps,
628
- }
655
+ export type { FieldRenderer, FieldRendererProps }
@@ -0,0 +1,158 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+ import type { FileFieldDef, FileFilter } from "./types"
3
+
4
+ /** What the user chose in the file field's context menu. */
5
+ export type FileMenuChoice = "reveal" | "clear"
6
+
7
+ /**
8
+ * The native half of the `file` field, supplied by the host so facets depends on neither Tauri
9
+ * nor app-kit. app-kit's functions have exactly these shapes: `recent: recentPaths`,
10
+ * `remember: rememberPath`, `pick: pickFile`, `exists: pathsExist`, `reveal: revealPath`,
11
+ * `contextMenu: pathFieldMenu`. Every member is optional; a field uses what the host gave it
12
+ * and is a plain editable text field when it gave none.
13
+ */
14
+ export interface FileHandlers {
15
+ /** The recent paths of a field kind (`recentKey`), newest first. Feeds the ▾ list. */
16
+ recent?: (key: string) => Promise<string[]>
17
+ /**
18
+ * Put a path at the top of a kind's recent list. Called once the host has accepted a commit
19
+ * (the field's value became the committed path), never for a refused one.
20
+ */
21
+ remember?: (key: string, path: string) => void | Promise<void>
22
+ /** The native Open dialog: the absolute path chosen, or null when cancelled. Feeds the browse icon. */
23
+ pick?: (opts: { filters?: FileFilter[]; title?: string }) => Promise<string | null>
24
+ /**
25
+ * Whether each path exists, in order. Relative paths resolve against `base`. Gone recents are
26
+ * left out of the ▾ list, and the current value shows Missing; both are re-checked when the
27
+ * window regains focus.
28
+ */
29
+ exists?: (paths: string[], base?: string) => Promise<boolean[]>
30
+ /** Show the file in the Finder. */
31
+ reveal?: (path: string, base?: string) => void | Promise<void>
32
+ /**
33
+ * A native menu at the pointer (Reveal in Finder, Clear). Resolves to the choice, or null when
34
+ * dismissed. Without it the field has no context menu of its own.
35
+ */
36
+ contextMenu?: (opts: { missing: boolean; canClear?: boolean }) => Promise<FileMenuChoice | null>
37
+ /** The folder relative paths resolve against: the document's. Read when a field checks or reveals. */
38
+ base?: () => string | undefined
39
+ }
40
+
41
+ let handlers: FileHandlers | null = null
42
+ const listeners = new Set<() => void>()
43
+
44
+ /**
45
+ * Register the host's file handlers, once at startup (like `registerFieldRenderer`): every `file`
46
+ * field in every panel then works the same way. Pass `null` to remove them. Replaces any earlier
47
+ * registration; fields already drawn pick the change up.
48
+ */
49
+ export function registerFileHandlers(next: FileHandlers | null): void {
50
+ handlers = next
51
+ for (const l of listeners) l()
52
+ }
53
+
54
+ export function getFileHandlers(): FileHandlers | null {
55
+ return handlers
56
+ }
57
+
58
+ /** The registered handlers, re-read if they are registered again while the field is drawn. */
59
+ export function useFileHandlers(): FileHandlers | null {
60
+ const [, bump] = useState(0)
61
+ useEffect(() => {
62
+ const l = () => bump((n) => n + 1)
63
+ listeners.add(l)
64
+ return () => {
65
+ listeners.delete(l)
66
+ }
67
+ }, [])
68
+ return handlers
69
+ }
70
+
71
+ /**
72
+ * The Open dialog's filters for a field: `filters`, else the extensions in the deprecated
73
+ * `accept` (`".png,.jpg,image/*"` gives one "Files" filter of png and jpg), else none.
74
+ */
75
+ export function filtersOf(
76
+ field: Pick<FileFieldDef, "filters" | "accept">,
77
+ ): FileFilter[] | undefined {
78
+ if (field.filters?.length) return field.filters
79
+ const extensions = (field.accept ?? "")
80
+ .split(",")
81
+ .map((t) => t.trim())
82
+ .filter((t) => /^\.[^./\\*]+$/.test(t))
83
+ .map((t) => t.slice(1))
84
+ return extensions.length ? [{ name: "Files", extensions }] : undefined
85
+ }
86
+
87
+ /**
88
+ * Whether the path is gone: false until the check says so, re-run when the path or base changes
89
+ * and whenever the window regains focus, so a file put back recovers without a click. Always
90
+ * false without an `exists` handler or a path.
91
+ */
92
+ export function useMissing(path: string | null, h: FileHandlers | null): boolean {
93
+ const [missing, setMissing] = useState(false)
94
+ const exists = h?.exists
95
+ const base = h?.base?.()
96
+ useEffect(() => {
97
+ if (!exists || !path) {
98
+ setMissing(false)
99
+ return
100
+ }
101
+ let live = true
102
+ const check = () =>
103
+ exists([path], base).then(
104
+ (found) => live && setMissing(found[0] === false),
105
+ () => {},
106
+ )
107
+ void check()
108
+ window.addEventListener("focus", check)
109
+ return () => {
110
+ live = false
111
+ window.removeEventListener("focus", check)
112
+ }
113
+ }, [exists, path, base])
114
+ return missing
115
+ }
116
+
117
+ /**
118
+ * The ▾ list: the host's recent paths for a key with the gone ones left out (all of them when
119
+ * the host has no `exists`). Loaded on mount, whenever `refreshOn` changes and when the window
120
+ * regains focus. Returns the list and a function that reloads it.
121
+ */
122
+ export function useRecent(
123
+ key: string | undefined,
124
+ h: FileHandlers | null,
125
+ refreshOn: unknown,
126
+ ): [string[], () => Promise<void>] {
127
+ const [list, setList] = useState<string[]>([])
128
+ const recent = h?.recent
129
+ const exists = h?.exists
130
+ const base = h?.base?.()
131
+ const latest = useRef(0)
132
+ const reload = useRef<() => Promise<void>>(async () => {})
133
+ reload.current = async () => {
134
+ if (!recent || !key) {
135
+ setList([])
136
+ return
137
+ }
138
+ const ticket = ++latest.current
139
+ try {
140
+ const all = await recent(key)
141
+ const found = exists && all.length ? await exists(all, base) : undefined
142
+ if (ticket === latest.current) setList(found ? all.filter((_, i) => found[i] !== false) : all)
143
+ } catch {
144
+ if (ticket === latest.current) setList([])
145
+ }
146
+ }
147
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reload is read through the ref; these are what should trigger it
148
+ useEffect(() => {
149
+ void reload.current()
150
+ const onFocus = () => void reload.current()
151
+ window.addEventListener("focus", onFocus)
152
+ return () => {
153
+ window.removeEventListener("focus", onFocus)
154
+ latest.current++
155
+ }
156
+ }, [key, recent, exists, base, refreshOn])
157
+ return [list, () => reload.current()]
158
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,19 @@
1
- export * from "./types"
1
+ export { FieldShell, ReadOnlyText, registerBuiltinRenderers } from "./field-renderers"
2
2
  export {
3
- registerScope,
4
- unregisterScope,
3
+ type FileHandlers,
4
+ type FileMenuChoice,
5
+ filtersOf,
6
+ getFileHandlers,
7
+ registerFileHandlers,
8
+ } from "./file-handlers"
9
+ export { COLUMN_ROW, useLabelLayout } from "./layout"
10
+ export { schemaProblems } from "./pages"
11
+ export { isDisabled, PropertyPanel, promotedFields, showsIn } from "./panel"
12
+ export {
13
+ getFieldRenderer,
5
14
  getScope,
6
15
  registerFieldRenderer,
7
- getFieldRenderer,
16
+ registerScope,
17
+ unregisterScope,
8
18
  } from "./registry"
9
- export { PropertyPanel, promotedFields, showsIn } from "./panel"
10
- export { registerBuiltinRenderers, FieldShell, ReadOnlyText } from "./field-renderers"
11
- export { useLabelLayout, COLUMN_ROW } from "./layout"
19
+ export * from "./types"
package/src/layout.ts CHANGED
@@ -15,6 +15,9 @@ export const LABEL_WIDTH_VAR = "--facets-label-w"
15
15
  /** The grid every `"column"` row sits on: the label, then the control. */
16
16
  export const COLUMN_ROW = `grid min-h-8 grid-cols-[var(${LABEL_WIDTH_VAR})_minmax(0,1fr)] items-center gap-x-3`
17
17
 
18
+ /** A field's help in `"column"` layout: the same columns, the text under the control. */
19
+ export const HELP_ROW = `grid grid-cols-[var(${LABEL_WIDTH_VAR})_minmax(0,1fr)] gap-x-3`
20
+
18
21
  /**
19
22
  * One width for every number field in a panel, whatever its label: the box is this wide in a label
20
23
  * column or under a label, and `NUMBER_WIDTH_INSIDE` when the label sits inside it (the default
@@ -30,7 +33,8 @@ export const NUMBER_WIDTH_INSIDE = "w-44 ml-auto"
30
33
  */
31
34
  export function labelColumnWidth(fields: FieldDef[]): string {
32
35
  const longest = fields.reduce(
33
- (n, f) => (f.kind === "separator" || f.kind === "label" ? n : Math.max(n, (f.label ?? f.id).length)),
36
+ (n, f) =>
37
+ f.kind === "separator" || f.kind === "label" ? n : Math.max(n, (f.label ?? f.id).length),
34
38
  0,
35
39
  )
36
40
  return `${Math.min(16, Math.max(8, longest))}ch`
package/src/pages.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { PropertySchema } from "./types"
2
+
3
+ /**
4
+ * What the JSON Schema can't say about pages and advanced groups, since it
5
+ * needs one part of the schema checked against another. An empty list means
6
+ * the schema is sound. `registerScope` throws on any problem, so a bad schema
7
+ * fails where it is registered rather than drawing a page with nothing on it.
8
+ *
9
+ * - `pages`, when present, is not empty and its ids are unique.
10
+ * - With pages, every group names a declared page; without, none names one.
11
+ * - Every page, including the one page of a schema without `pages`, has a group
12
+ * that isn't `advanced`. A schema with no groups at all is fine.
13
+ */
14
+ export function schemaProblems(schema: PropertySchema): string[] {
15
+ const problems: string[] = []
16
+ const at = (i: number) => `groups[${i}] ("${schema.groups[i].id}")`
17
+ const pages = schema.pages
18
+ if (!pages) {
19
+ schema.groups.forEach((g, i) => {
20
+ if (g.page !== undefined)
21
+ problems.push(`${at(i)}: names page "${g.page}", but there are no pages`)
22
+ })
23
+ if (schema.groups.length > 0 && schema.groups.every((g) => g.advanced)) {
24
+ problems.push("every group is advanced: the panel would show only a closed Advanced row")
25
+ }
26
+ return problems
27
+ }
28
+ if (pages.length === 0) problems.push("pages is empty: omit it for one page")
29
+ const ids = new Set<string>()
30
+ for (const p of pages) {
31
+ if (p.id === "") {
32
+ problems.push("pages: an id is empty")
33
+ continue
34
+ }
35
+ if (ids.has(p.id)) problems.push(`pages: "${p.id}" is declared twice`)
36
+ ids.add(p.id)
37
+ }
38
+ schema.groups.forEach((g, i) => {
39
+ if (g.page === undefined) problems.push(`${at(i)}: names no page`)
40
+ else if (!ids.has(g.page)) problems.push(`${at(i)}: page "${g.page}" is not declared`)
41
+ })
42
+ for (const id of ids) {
43
+ const groups = schema.groups.filter((g) => g.page === id)
44
+ if (groups.length === 0) problems.push(`page "${id}" has no groups`)
45
+ else if (groups.every((g) => g.advanced)) {
46
+ problems.push(`page "${id}" has only advanced groups: give it one that isn't`)
47
+ }
48
+ }
49
+ return problems
50
+ }