@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 CHANGED
@@ -47,6 +47,8 @@ the conventional shadcn path, and your bundler's `@` alias resolves it:
47
47
  | `@/components/ui/number-field` | `NumberField` |
48
48
  | `@/components/ui/slider` | `Slider` |
49
49
  | `@/components/ui/color-field` | `ColorField` |
50
+ | `@/components/ui/path-field` | `PathField` (only for `file` fields) |
51
+ | `@/components/ui/tabs` | `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` |
50
52
 
51
53
  The last three are what make a panel feel like the rest of your app: a number
52
54
  you drag, a square slider, your colour picker. They are the **minimum** facets
@@ -68,7 +70,18 @@ reads (typed in `dev/ui/` in this repo):
68
70
  `presets`, `disabled`, `onPickStart` / `onPickEnd`. No `onChange` means
69
71
  read-only.
70
72
 
71
- So you need an `@/*` alias pointing at your `src/`, those eight modules present,
73
+ - `PathField`: `value: string | null`, `onCommit(path | null)` (Return or blur;
74
+ no `onCommit` means read-only), `recent: string[]`, `onBrowse(): Promise<string | null>`,
75
+ `status` (`"ok"`, `"missing"`, `"invalid"`), `message`, `id`, `placeholder`,
76
+ `disabled`, and the props of a `div` (facets passes `onContextMenu` and
77
+ `onMouseDownCapture`). With ux-kit: `export { PathField } from "@preset.nz/ux-kit"`.
78
+
79
+ `Tabs` draws a schema's pages. facets uses the shadcn API (`value`,
80
+ `onValueChange` on `Tabs`; `value` on `TabsTrigger` and `TabsContent`) and the
81
+ list's default variant, the segmented control. With ux-kit it is a one-liner:
82
+ `export { Tabs, TabsContent, TabsList, TabsTrigger } from "@preset.nz/ux-kit"`.
83
+
84
+ So you need an `@/*` alias pointing at your `src/`, those ten modules present,
72
85
  and Tailwind with shadcn's theme tokens (`muted-foreground`, `border` and
73
86
  friends are used throughout).
74
87
 
@@ -206,7 +219,9 @@ Built-ins register through the same call you would use, with no special casing.
206
219
  **`PropertyPanel`** — looks up the scope, calls `read`, walks the groups, hands
207
220
  each field to its renderer. Props: `scopeKey`, `selection`, `ctx`, plus optional
208
221
  `readOnly`, `emptyState`, `view`, `title` (see [Views](#views-inspector-card-collapsed)),
209
- `labelLayout` and `labelWidth` (see [Label layout](#label-layout)).
222
+ `labelLayout` and `labelWidth` (see [Label layout](#label-layout)), and `page`,
223
+ `onPageChange`, `advancedOpen` and `onAdvancedOpenChange` (see
224
+ [Pages and advanced groups](#pages-and-advanced-groups)).
210
225
 
211
226
  `ctx` is yours. The package treats it as opaque and passes it through to every
212
227
  `read`, `write`, renderer and options provider. Put your app's API in it.
@@ -259,13 +274,136 @@ Eleven ship built in: nine that hold a value, and two that only show something.
259
274
  | `select` | string | `options`, or `optionsProvider(ctx)` for dynamic lists |
260
275
  | `color` | hex string | `presets`; the host's `ColorField` |
261
276
  | `vector` | number array | N scalars in one row — Houdini's `float3` / `int2`. `components` sets arity and per-slot label, suffix and range |
262
- | `file` | `{ name }` | display only |
277
+ | `file` | path string, or `null` | `recentKey`, `filters`, `title`, `placeholder`; editable on the host's `PathField`, see [The `file` field](#the-file-field) |
263
278
  | `separator` | none | a horizontal rule, with `label` as an optional caption. No `path` |
264
279
  | `label` | none | static text: a hint or a sub-heading. The text is `label`. No `path` |
265
280
 
281
+ ### The `file` field
282
+
283
+ `file` is a path to a file, on ux-kit's `PathField`: a text field, a ▾ list of
284
+ recent paths and a browse icon. The value is the path **as written**, relative
285
+ or absolute (facets never rewrites it), or `null`. Typing commits on Return or
286
+ blur through the scope's `write`, never per keystroke; Esc reverts.
287
+
288
+ ```ts
289
+ { kind: "file", id: "texture", path: "texture", label: "Texture Path",
290
+ recentKey: "texture", filters: [{ name: "Images", extensions: ["png", "jpg", "webp"] }] }
291
+ ```
292
+
293
+ `recentKey` names the field kind whose last paths the ▾ lists, across documents.
294
+ `filters` and `title` go to the Open dialog. `accept` (as on `<input>`) is
295
+ deprecated: its `.ext` entries become one "Files" filter when `filters` is absent.
296
+ Before 0.3 the value was a read-only `{ name }`; that still draws, read-only.
297
+
298
+ **Refusing a value.** The host's `write` is the validator. Return `{ error }`
299
+ (or a promise that resolves to it or rejects with a message) and the field is
300
+ marked invalid with the message under it, keeping the typed text. The message
301
+ shows while the field's value stays as it was and clears on the next write. Any
302
+ `write` can refuse this way; fields other than `file` receive the message as
303
+ the `error` prop and ignore it.
304
+
305
+ **The native parts come from the host.** facets depends on neither Tauri nor
306
+ app-kit. Register the handlers once at startup; every `file` field then works
307
+ the same way. Without them it is a plain editable text field.
308
+
309
+ ```ts
310
+ import { registerFileHandlers } from "@preset.nz/facets"
311
+ import { pathFieldMenu, pathsExist, pickFile, recentPaths, rememberPath, revealPath } from "@preset.nz/app-kit"
312
+
313
+ registerFileHandlers({
314
+ recent: recentPaths, // (key) => Promise<string[]>: the ▾ list
315
+ remember: rememberPath, // (key, path): after the host accepted a commit
316
+ pick: pickFile, // ({ filters, title }) => Promise<string | null>: the browse icon
317
+ exists: pathsExist, // (paths, base?) => Promise<boolean[]>: Missing, and gone recents left out
318
+ reveal: revealPath, // (path, base?)
319
+ contextMenu: pathFieldMenu, // ({ missing, canClear }) => "reveal" | "clear" | null: a native menu
320
+ base: () => currentDocumentFolder(), // relative paths resolve against it
321
+ })
322
+ ```
323
+
324
+ Every member is optional. A path is remembered when the field's value becomes
325
+ the path that was committed, which is the first moment a host that validates
326
+ asynchronously has accepted it; a refused commit is never remembered. Missing
327
+ and the ▾ list are re-checked when the window regains focus. Right-click opens
328
+ the native menu (Reveal in Finder, Clear) unless the text is being edited, when
329
+ the native text menu stays.
330
+
331
+ Every field that holds a value takes `help` (from 0.3.0): a sentence or two
332
+ drawn under the control in the inspector, and left out of the card and the
333
+ collapsed view. `file`'s `helperText` is its deprecated name.
334
+
266
335
  Groups take `collapsible: true` to fold under their title, and
267
336
  `defaultCollapsed: true` to start folded. The open or closed state belongs to
268
- the group, so it resets when the panel remounts.
337
+ the panel, so it resets when the panel remounts.
338
+
339
+ ---
340
+
341
+ ## Pages and advanced groups
342
+
343
+ A long panel splits into pages, drawn as tabs. Declare them at the root, in
344
+ order, and name one on every group:
345
+
346
+ ```ts
347
+ const SETTINGS: PropertySchema = {
348
+ version: 1,
349
+ pages: [
350
+ { id: "general", label: "General" },
351
+ { id: "export", label: "Export" },
352
+ ],
353
+ groups: [
354
+ { id: "appearance", title: "Appearance", page: "general", rows: [/* … */] },
355
+ { id: "performance", title: "Performance", page: "general", advanced: true, rows: [/* … */] },
356
+ { id: "format", title: "Format", page: "export", rows: [/* … */] },
357
+ ],
358
+ };
359
+ ```
360
+
361
+ - **No `pages` is one page and no tab row**, exactly as before. One declared
362
+ page draws no tab row either.
363
+ - **Pages are presentation only.** A `path` never names a page, so moving a
364
+ group to another page changes no values.
365
+ - **`advanced: true`** puts a group behind one closed *Advanced* row at the foot
366
+ of its page, with or without pages. The row folds like a collapsible group.
367
+ - **Every page needs a group that isn't advanced.** With pages, every group
368
+ names a declared page; without, none does. `schemaProblems(schema)` lists
369
+ what breaks these rules, and `registerScope` throws on any of them, so a bad
370
+ schema fails where it is registered, not as an empty tab.
371
+ - **The card and collapsed views ignore pages and `advanced`.** They draw
372
+ promoted fields, wherever they sit.
373
+
374
+ The open page and each page's Advanced row are the panel's own state: the first
375
+ page, every row closed. A host that remembers them, as a Settings window does,
376
+ passes them in:
377
+
378
+ ```tsx
379
+ <PropertyPanel
380
+ scopeKey="settings" selection={prefs} ctx={ctx}
381
+ page={tab} onPageChange={setTab}
382
+ advancedOpen={openRows} onAdvancedOpenChange={setOpenRows}
383
+ />
384
+ ```
385
+
386
+ `advancedOpen` lists the pages whose Advanced row is open. A schema without
387
+ `pages` has one page, whose id is `""`. A `page` the schema doesn't declare
388
+ shows the first page. Group folds and Advanced rows survive switching tabs.
389
+
390
+ ### Changed marks
391
+
392
+ A scope may say which of its values count as changed (from their defaults,
393
+ say). The panel then marks anything that hides one: a tab other than the open
394
+ one, a closed Advanced row, a closed group. The mark is a dot, read by screen
395
+ readers as "changed".
396
+
397
+ ```ts
398
+ registerScope("settings", {
399
+ schema: SETTINGS,
400
+ read: (sel, ctx) => ctx.prefs(),
401
+ write: (path, value, sel, ctx) => ctx.setPref(path, value),
402
+ changed: (sel, ctx) => ctx.changedPrefs(), // paths, e.g. ["theme", "cacheSize"]
403
+ });
404
+ ```
405
+
406
+ The field itself carries no mark from facets; a label or renderer does that.
269
407
 
270
408
  ---
271
409
 
@@ -351,8 +489,8 @@ plugin with a skill for writing schemas, scopes and custom renderers lives in
351
489
  this repo:
352
490
 
353
491
  ```sh
354
- claude plugin marketplace add preset-nz/facets
355
- claude plugin install facets@preset-nz-facets
492
+ claude plugin marketplace add preset-nz/packages
493
+ claude plugin install facets@preset-nz-packages
356
494
  ```
357
495
 
358
496
  ---
@@ -380,6 +518,16 @@ The condition reads one other field's value. Set one operator:
380
518
 
381
519
  If several are set, the first in that order wins.
382
520
 
521
+ A list of conditions disables the field when any of them holds, e.g. a width
522
+ that a hairline or a non-solid line both make moot:
523
+
524
+ ```ts
525
+ disabledWhen: [
526
+ { path: "hairline", equals: true },
527
+ { path: "lineStyle", notEquals: "solid" },
528
+ ]
529
+ ```
530
+
383
531
  ---
384
532
 
385
533
  ## Custom field kinds
@@ -412,8 +560,11 @@ it does not take the rest of the panel down with it.
412
560
  - **Registration is explicit.** `registerBuiltinRenderers()` is a call, not an
413
561
  import side effect, and the package declares `sideEffects: false`. Call it
414
562
  before rendering a panel.
415
- - **Failures stay local.** A throwing `read` is caught and logged; an unknown
416
- scope renders `emptyState`; an unknown kind skips that field.
563
+ - **Failures stay local, except a bad page layout.** A throwing `read` is
564
+ caught and logged; an unknown scope renders `emptyState`; an unknown kind
565
+ skips that field. A schema whose pages don't add up throws from
566
+ `registerScope` (see [Pages](#pages-and-advanced-groups)), so the mistake
567
+ shows where the schema is registered rather than as an empty tab later.
417
568
  - **Schemas are serialisable.** Keep them that way — no closures in a
418
569
  `FieldDef` beyond `optionsProvider`. It keeps schemas versionable and lets them
419
570
  cross a plugin boundary later.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@preset.nz/facets",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Schema-as-data property/inspector panels for React: FieldDef schemas, a scope registry, an open field-renderer registry, and a generic PropertyPanel.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://facets.preset.nz",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/preset-nz/facets.git"
9
+ "url": "git+https://github.com/preset-nz/packages.git",
10
+ "directory": "facets"
10
11
  },
11
12
  "type": "module",
12
13
  "sideEffects": false,
@@ -25,8 +26,10 @@
25
26
  "react": "^19"
26
27
  },
27
28
  "devDependencies": {
29
+ "@biomejs/biome": "^2.5.15",
28
30
  "@types/react": "^19",
29
31
  "ajv": "^8.20.0",
30
- "typescript": "^5.8.3"
32
+ "ts5": "npm:typescript@5.9.3",
33
+ "typescript": "^7.0.2"
31
34
  }
32
35
  }