@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/registry.ts CHANGED
@@ -1,12 +1,18 @@
1
+ import { schemaProblems } from "./pages"
1
2
  import type { FieldRenderer, Scope } from "./types"
2
3
 
3
4
  const scopes = new Map<string, Scope<unknown, Record<string, unknown>>>()
4
5
  const fieldRenderers = new Map<string, FieldRenderer>()
5
6
 
7
+ /** Throws when the schema's pages don't add up: see `schemaProblems`. */
6
8
  export function registerScope<S, V extends Record<string, unknown>>(
7
9
  key: string,
8
10
  scope: Scope<S, V>,
9
11
  ): void {
12
+ const problems = schemaProblems(scope.schema)
13
+ if (problems.length > 0) {
14
+ throw new Error(`[properties] scope "${key}": ${problems.join("; ")}`)
15
+ }
10
16
  scopes.set(key, scope as Scope<unknown, Record<string, unknown>>)
11
17
  }
12
18
 
@@ -14,16 +20,11 @@ export function unregisterScope(key: string): void {
14
20
  scopes.delete(key)
15
21
  }
16
22
 
17
- export function getScope(
18
- key: string,
19
- ): Scope<unknown, Record<string, unknown>> | undefined {
23
+ export function getScope(key: string): Scope<unknown, Record<string, unknown>> | undefined {
20
24
  return scopes.get(key)
21
25
  }
22
26
 
23
- export function registerFieldRenderer(
24
- kind: string,
25
- Component: FieldRenderer,
26
- ): void {
27
+ export function registerFieldRenderer(kind: string, Component: FieldRenderer): void {
27
28
  fieldRenderers.set(kind, Component)
28
29
  }
29
30
 
package/src/types.ts CHANGED
@@ -18,7 +18,12 @@ export interface BaseField {
18
18
  id: string
19
19
  label?: string
20
20
  path: string
21
- disabledWhen?: DisabledWhen
21
+ /**
22
+ * One or two sentences on what the field does, drawn under the control in the
23
+ * inspector view. The card and collapsed views leave it out.
24
+ */
25
+ help?: string
26
+ disabledWhen?: DisabledWhenAny
22
27
  promote?: PromoteView[]
23
28
  }
24
29
 
@@ -49,10 +54,10 @@ export type LabelLayout = "auto" | "column" | "stacked"
49
54
 
50
55
  /**
51
56
  * Disables a field based on one other field's value. Exactly one operator is
52
- * meant to be set; if several are, the first present in the order
57
+ * meant to be set; if several are set, the first present in the order
53
58
  * `equals`, `notEquals`, `in`, `notIn` wins. `in`/`notIn` match a list of
54
- * values (e.g. "only these enum choices use this param") and are still a
55
- * single-path condition — composable AND/OR belongs in a later DSL.
59
+ * values (e.g. "only these enum choices use this param"). A field takes a list
60
+ * of these to be disabled when any of them holds (`DisabledWhenAny`).
56
61
  */
57
62
  export interface DisabledWhen {
58
63
  path: string
@@ -62,9 +67,15 @@ export interface DisabledWhen {
62
67
  notIn?: readonly unknown[]
63
68
  }
64
69
 
70
+ /** One condition, or a list: disabled when any of them holds. */
71
+ export type DisabledWhenAny = DisabledWhen | readonly DisabledWhen[]
72
+
65
73
  // A custom field is any BaseField whose `kind` is not one of the built-ins.
66
74
  // The host's registered renderer for that kind decides what extra props it reads.
67
- export type CustomFieldDef = BaseField & {
75
+ // `path` is optional here: a custom field may hold no value (a host section the
76
+ // renderer draws and owns), like the display-only kinds.
77
+ export type CustomFieldDef = Omit<BaseField, "path"> & {
78
+ path?: string
68
79
  [extra: string]: unknown
69
80
  }
70
81
 
@@ -136,9 +147,36 @@ export interface VectorFieldDef extends BaseField {
136
147
  precision?: number
137
148
  }
138
149
 
150
+ /** One file type in the host's Open dialog. Extensions have no dot: `["png", "jpg"]`. */
151
+ export interface FileFilter {
152
+ name: string
153
+ extensions: string[]
154
+ }
155
+
156
+ /**
157
+ * A path to a file, edited on ux-kit's `PathField`. The value is the path string as written,
158
+ * relative or absolute, or `null`/`""` when empty. Typing commits on Return or blur through the
159
+ * scope's `write`; the host validates there (see `WriteResult`). The ▾ list, the browse icon, the
160
+ * Missing check and the context menu need the host's `registerFileHandlers`; without them the
161
+ * field is a plain editable text field.
162
+ *
163
+ * Before 0.3 the value was a read-only `{ name }`. That value still draws, read-only.
164
+ */
139
165
  export interface FileFieldDef extends BaseField {
140
166
  kind: "file"
167
+ /** Keys the recent paths: fields with the same key share one ▾ list, across documents (`"texture"`). */
168
+ recentKey?: string
169
+ /** File types in the Open dialog. */
170
+ filters?: FileFilter[]
171
+ /** The Open dialog's title. */
172
+ title?: string
173
+ placeholder?: string
174
+ /**
175
+ * @deprecated Use `filters`. File types as on `<input accept>`; the extensions in it (`.png,.jpg`)
176
+ * become one "Files" filter when `filters` is absent.
177
+ */
141
178
  accept?: string
179
+ /** @deprecated Use `help`, which every field has. Drawn as `help` when that's absent. */
142
180
  helperText?: string
143
181
  }
144
182
 
@@ -153,7 +191,7 @@ export interface SeparatorFieldDef {
153
191
  kind: "separator"
154
192
  id: string
155
193
  label?: string
156
- disabledWhen?: DisabledWhen
194
+ disabledWhen?: DisabledWhenAny
157
195
  promote?: PromoteView[]
158
196
  }
159
197
 
@@ -162,7 +200,7 @@ export interface LabelFieldDef {
162
200
  kind: "label"
163
201
  id: string
164
202
  label: string
165
- disabledWhen?: DisabledWhen
203
+ disabledWhen?: DisabledWhenAny
166
204
  promote?: PromoteView[]
167
205
  }
168
206
 
@@ -189,12 +227,31 @@ export interface PropertyGroupDef {
189
227
  description?: string
190
228
  collapsible?: boolean
191
229
  defaultCollapsed?: boolean
230
+ /** The id of the page the group sits on. Required when the schema declares `pages`, absent otherwise. */
231
+ page?: string
232
+ /** Sits behind the page's one closed Advanced row, at the foot of the page. */
233
+ advanced?: boolean
234
+ }
235
+
236
+ /**
237
+ * A tab in the inspector. Pages are presentation only: a field's `path` never
238
+ * names one, so moving a group to another page changes no values.
239
+ */
240
+ export interface PropertyPageDef {
241
+ id: string
242
+ label: string
192
243
  }
193
244
 
194
245
  export interface PropertySchema {
195
246
  version: number
196
247
  /** What the scope is called, e.g. "Filter". Heads the card; `PropertyPanel`'s `title` overrides it. */
197
248
  title?: string
249
+ /**
250
+ * Splits the inspector into tabs, in this order. Omit it for one page and no
251
+ * tab row. Each page needs at least one group that isn't `advanced`; see
252
+ * `schemaProblems`.
253
+ */
254
+ pages?: PropertyPageDef[]
198
255
  groups: PropertyGroupDef[]
199
256
  }
200
257
 
@@ -202,15 +259,20 @@ export interface PropertySchema {
202
259
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
260
  export type ScopeContext = any
204
261
 
262
+ /**
263
+ * What a scope's `write` may return. Nothing is the usual case: the write was taken. `{ error }`,
264
+ * or a promise that resolves to it or rejects, refuses the value: the panel hands the message to
265
+ * the field as `error` for as long as the field's value stays as it was, and a field that can
266
+ * show it (the `file` field marks itself invalid) does. An accepted write needs no signal: the
267
+ * host's `read` returns the new value.
268
+ */
269
+ // biome-ignore lint/suspicious/noConfusingVoidType: an async write that returns nothing is a Promise<void>
270
+ export type WriteResult = void | { error: string } | Promise<void | { error: string }>
271
+
205
272
  export interface Scope<S = unknown, V = Record<string, unknown>> {
206
273
  schema: PropertySchema
207
274
  read: (selection: S, ctx: ScopeContext) => V
208
- write?: (
209
- path: string,
210
- value: unknown,
211
- selection: S,
212
- ctx: ScopeContext,
213
- ) => void
275
+ write?: (path: string, value: unknown, selection: S, ctx: ScopeContext) => WriteResult
214
276
  /**
215
277
  * One drag as one undo step. A drag is a scrubbed number, a dragged slider
216
278
  * thumb or an open colour picker; `begin` fires when it starts, every
@@ -219,6 +281,14 @@ export interface Scope<S = unknown, V = Record<string, unknown>> {
219
281
  * the value back. Omit `gesture` and drags are plain repeated `write`s.
220
282
  */
221
283
  gesture?: ScopeGesture<S>
284
+ /**
285
+ * The paths whose values differ from their defaults, or whatever "changed"
286
+ * means to the host. The panel marks a tab, a closed Advanced row or a
287
+ * closed group that hides one of them, so a change is never out of sight
288
+ * without a trace. It doesn't mark the field itself: a renderer or label
289
+ * does that. Called like `read`, when the selection or ctx changes.
290
+ */
291
+ changed?: (selection: S, ctx: ScopeContext) => readonly string[]
222
292
  }
223
293
 
224
294
  export interface ScopeGesture<S = unknown> {
@@ -241,6 +311,8 @@ export interface FieldRendererProps<F extends FieldDef = FieldDef> {
241
311
  onGestureBegin?: () => void
242
312
  onGestureEnd?: () => void
243
313
  onGestureCancel?: () => void
314
+ /** Why the host refused the field's last written value (see `WriteResult`). Absent once the value changes. */
315
+ error?: string
244
316
  ctx: ScopeContext
245
317
  /**
246
318
  * The view the field is drawn in. In `"collapsed"` it shares a line with
@@ -249,8 +321,6 @@ export interface FieldRendererProps<F extends FieldDef = FieldDef> {
249
321
  view?: PanelView
250
322
  }
251
323
 
252
- export type FieldRenderer<F extends FieldDef = FieldDef> = React.FC<
253
- FieldRendererProps<F>
254
- >
324
+ export type FieldRenderer<F extends FieldDef = FieldDef> = React.FC<FieldRendererProps<F>>
255
325
 
256
326
  import type React from "react"