@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.
@@ -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,10 +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 } from "./panel"
10
- export { registerBuiltinRenderers } from "./field-renderers"
19
+ export * from "./types"
package/src/layout.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { createContext, useContext } from "react"
2
+ import type { FieldDef, LabelLayout } from "./types"
3
+
4
+ /**
5
+ * The panel's label layout, for the built-in renderers. Context rather than a renderer prop, so
6
+ * custom renderers need no change; one that wants to follow the layout may read it with
7
+ * `useLabelLayout()`. Outside a `PropertyPanel` it is `"auto"`.
8
+ */
9
+ export const LabelLayoutContext = createContext<LabelLayout>("auto")
10
+ export const useLabelLayout = () => useContext(LabelLayoutContext)
11
+
12
+ /** The CSS variable that holds the label column's width in `"column"` layout. Set on the panel root. */
13
+ export const LABEL_WIDTH_VAR = "--facets-label-w"
14
+
15
+ /** The grid every `"column"` row sits on: the label, then the control. */
16
+ export const COLUMN_ROW = `grid min-h-8 grid-cols-[var(${LABEL_WIDTH_VAR})_minmax(0,1fr)] items-center gap-x-3`
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
+
21
+ /**
22
+ * One width for every number field in a panel, whatever its label: the box is this wide in a label
23
+ * column or under a label, and `NUMBER_WIDTH_INSIDE` when the label sits inside it (the default
24
+ * layout, where it lines the boxes up on the right edge).
25
+ */
26
+ export const NUMBER_WIDTH = "w-28"
27
+ export const NUMBER_WIDTH_INSIDE = "w-44 ml-auto"
28
+
29
+ /**
30
+ * One width for the whole panel: the longest label among the fields the view draws, in `ch`,
31
+ * kept between 8 and 16 so one long label doesn't squeeze every control. A longer label
32
+ * truncates, with its full text as a tooltip.
33
+ */
34
+ export function labelColumnWidth(fields: FieldDef[]): string {
35
+ const longest = fields.reduce(
36
+ (n, f) =>
37
+ f.kind === "separator" || f.kind === "label" ? n : Math.max(n, (f.label ?? f.id).length),
38
+ 0,
39
+ )
40
+ return `${Math.min(16, Math.max(8, longest))}ch`
41
+ }
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
+ }