@preset.nz/facets 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -8
- package/package.json +6 -3
- package/schema/v0.1.json +198 -125
- package/src/field-renderers.tsx +130 -103
- package/src/file-handlers.ts +158 -0
- package/src/index.ts +15 -7
- package/src/layout.ts +5 -1
- package/src/pages.ts +50 -0
- package/src/panel.tsx +434 -87
- package/src/registry.ts +8 -7
- package/src/types.ts +86 -16
package/src/field-renderers.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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={
|
|
440
|
-
<
|
|
441
|
-
{
|
|
442
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
)
|
|
583
|
-
registerFieldRenderer(
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
)
|
|
587
|
-
registerFieldRenderer(
|
|
588
|
-
|
|
589
|
-
|
|
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
|
|
1
|
+
export { FieldShell, ReadOnlyText, registerBuiltinRenderers } from "./field-renderers"
|
|
2
2
|
export {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
16
|
+
registerScope,
|
|
17
|
+
unregisterScope,
|
|
8
18
|
} from "./registry"
|
|
9
|
-
export
|
|
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) =>
|
|
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
|
+
}
|