@preset.nz/facets 0.1.0 → 0.1.2

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
@@ -14,6 +14,10 @@ switch statement over your types.
14
14
  Built for image and map editors, where the same panel has to render a layer, a
15
15
  filter, a document or a tool depending on what is selected.
16
16
 
17
+ The playground at [facets.preset.nz](https://facets.preset.nz) shows the field
18
+ kinds, the schema as JSON, and the panel it draws, side by side. Its source is
19
+ in `playground/`.
20
+
17
21
  ---
18
22
 
19
23
  ## Installing
@@ -45,6 +49,18 @@ So you need an `@/*` alias pointing at your `src/`, those five modules present,
45
49
  and Tailwind with shadcn's theme tokens (`muted-foreground`, `border` and
46
50
  friends are used throughout).
47
51
 
52
+ The panel's own layout (group headers, rows, help text) is Tailwind classes in
53
+ this package's source. Tailwind 4 does not scan `node_modules`, so name the
54
+ package in your CSS. From `src/index.css`:
55
+
56
+ ```css
57
+ @import "tailwindcss";
58
+ @source "../node_modules/@preset.nz/facets/src";
59
+ ```
60
+
61
+ Nothing fails when this line is missing. The build succeeds and the panel
62
+ renders unstyled.
63
+
48
64
  The upside is that `facets` inherits whatever primitive library you already
49
65
  chose — Base UI, Radix, or your own — instead of dragging in a second one and
50
66
  making your panels look foreign inside your own app.
@@ -147,7 +163,7 @@ keep the mapping inside `read`, where your domain types are still in scope.
147
163
 
148
164
  ## Field kinds
149
165
 
150
- Nine ship built in.
166
+ Eleven ship built in: nine that hold a value, and two that only show something.
151
167
 
152
168
  | Kind | Value | Notes |
153
169
  |---|---|---|
@@ -160,6 +176,12 @@ Nine ship built in.
160
176
  | `color` | hex string | `presets`; shows a swatch |
161
177
  | `vector` | number array | N scalars in one row — Houdini's `float3` / `int2`. `components` sets arity and per-slot label, suffix and range |
162
178
  | `file` | `{ name }` | display only |
179
+ | `separator` | none | a horizontal rule, with `label` as an optional caption. No `path` |
180
+ | `label` | none | static text: a hint or a sub-heading. The text is `label`. No `path` |
181
+
182
+ Groups take `collapsible: true` to fold under their title, and
183
+ `defaultCollapsed: true` to start folded. The open or closed state belongs to
184
+ the group, so it resets when the panel remounts.
163
185
 
164
186
  ---
165
187
 
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@preset.nz/facets",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
+ "homepage": "https://facets.preset.nz",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/preset-nz/facets.git"
@@ -14,6 +14,7 @@ import {
14
14
  SelectValue,
15
15
  } from "@/components/ui/select"
16
16
  import { Checkbox } from "@/components/ui/checkbox"
17
+ import { Separator } from "@/components/ui/separator"
17
18
  import { registerFieldRenderer } from "./registry"
18
19
  import type {
19
20
  CheckboxFieldDef,
@@ -26,6 +27,8 @@ import type {
26
27
  TextFieldDef,
27
28
  TextareaFieldDef,
28
29
  FileFieldDef,
30
+ LabelFieldDef,
31
+ SeparatorFieldDef,
29
32
  VectorFieldDef,
30
33
  } from "./types"
31
34
 
@@ -226,7 +229,9 @@ const SelectRenderer: FieldRenderer<SelectFieldDef> = ({
226
229
  disabled={disabled}
227
230
  >
228
231
  <SelectTrigger>
229
- <SelectValue />
232
+ {/* Children, not the primitive's own lookup: Base UI's Value shows
233
+ the raw value unless it's told the label. */}
234
+ <SelectValue>{matched ? matched.label : current}</SelectValue>
230
235
  </SelectTrigger>
231
236
  <SelectContent>
232
237
  {options.map((o) => (
@@ -425,7 +430,39 @@ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
425
430
  )
426
431
  }
427
432
 
433
+ const SeparatorRenderer: FieldRenderer<SeparatorFieldDef> = ({ field }) =>
434
+ field.label ? (
435
+ <div className="my-1 flex items-center gap-2">
436
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
437
+ {field.label}
438
+ </span>
439
+ <Separator className="flex-1" />
440
+ </div>
441
+ ) : (
442
+ <Separator className="my-1" />
443
+ )
444
+
445
+ // The same in every mode: there is nothing to edit. Greys out under
446
+ // disabledWhen so a hint can follow the field it describes.
447
+ const LabelRenderer: FieldRenderer<LabelFieldDef> = ({ field, disabled }) => (
448
+ <p
449
+ className={
450
+ "text-xs text-muted-foreground" + (disabled ? " opacity-50" : "")
451
+ }
452
+ >
453
+ {field.label}
454
+ </p>
455
+ )
456
+
428
457
  export function registerBuiltinRenderers(): void {
458
+ registerFieldRenderer(
459
+ "separator",
460
+ SeparatorRenderer as FieldRenderer<import("./types").FieldDef>,
461
+ )
462
+ registerFieldRenderer(
463
+ "label",
464
+ LabelRenderer as FieldRenderer<import("./types").FieldDef>,
465
+ )
429
466
  registerFieldRenderer(
430
467
  "text",
431
468
  TextRenderer as FieldRenderer<import("./types").FieldDef>,
package/src/panel.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useMemo } from "react"
1
+ import { useId, useMemo, useState } from "react"
2
2
  import { Separator } from "@/components/ui/separator"
3
3
  import { getFieldRenderer, getScope } from "./registry"
4
4
  import type {
@@ -69,23 +69,54 @@ function PropertyGroup({
69
69
  onChange?: (path: string, val: unknown) => void
70
70
  isLast: boolean
71
71
  }) {
72
+ // Collapsing needs a title to click. Open or closed is the group's own
73
+ // state; a host that wants it remembered across selections keys the panel.
74
+ const collapsible = Boolean(group.collapsible && group.title)
75
+ const [collapsed, setCollapsed] = useState(
76
+ collapsible && Boolean(group.defaultCollapsed),
77
+ )
78
+ const bodyId = useId()
79
+ const titleClass =
80
+ "text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
81
+
72
82
  return (
73
83
  <section className="flex flex-col gap-3 px-3 py-3">
74
84
  {(group.title || group.description) && (
75
85
  <header>
76
- {group.title && (
77
- <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
78
- {group.title}
79
- </h3>
80
- )}
81
- {group.description && (
86
+ {group.title &&
87
+ (collapsible ? (
88
+ <h3 className={titleClass}>
89
+ <button
90
+ type="button"
91
+ aria-expanded={!collapsed}
92
+ aria-controls={bodyId}
93
+ onClick={() => setCollapsed((c) => !c)}
94
+ className="-mx-1 flex w-[calc(100%+0.5rem)] items-center gap-1 px-1 text-left uppercase hover:text-foreground"
95
+ >
96
+ <svg
97
+ aria-hidden
98
+ viewBox="0 0 16 16"
99
+ className={
100
+ "size-3 shrink-0 transition-transform " +
101
+ (collapsed ? "-rotate-90" : "")
102
+ }
103
+ >
104
+ <path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
105
+ </svg>
106
+ {group.title}
107
+ </button>
108
+ </h3>
109
+ ) : (
110
+ <h3 className={titleClass}>{group.title}</h3>
111
+ ))}
112
+ {group.description && !(collapsible && collapsed) && (
82
113
  <p className="mt-1 text-xs text-muted-foreground">
83
114
  {group.description}
84
115
  </p>
85
116
  )}
86
117
  </header>
87
118
  )}
88
- <div className="flex flex-col gap-3">
119
+ <div id={bodyId} hidden={collapsible && collapsed} className="flex flex-col gap-3">
89
120
  {group.rows.map((row, idx) => {
90
121
  const fields = Array.isArray(row) ? row : [row]
91
122
  return (
@@ -142,11 +173,14 @@ function FieldSlot({
142
173
  return false
143
174
  })()
144
175
 
176
+ // Display-only kinds (separator, label) have no path: no value, no writes.
177
+ const path = "path" in field ? field.path : undefined
145
178
  return renderer({
146
179
  field,
147
- value: values[field.path],
180
+ value: path === undefined ? undefined : values[path],
148
181
  disabled,
149
- onChange: onChange ? (val) => onChange(field.path, val) : undefined,
182
+ onChange:
183
+ onChange && path !== undefined ? (val) => onChange(path, val) : undefined,
150
184
  ctx,
151
185
  })
152
186
  }
package/src/types.ts CHANGED
@@ -9,6 +9,9 @@ export type BuiltinFieldKind =
9
9
  | "color"
10
10
  | "checkbox"
11
11
  | "file"
12
+ | "vector"
13
+ | "separator"
14
+ | "label"
12
15
 
13
16
  export interface BaseField {
14
17
  kind: string
@@ -109,6 +112,28 @@ export interface FileFieldDef extends BaseField {
109
112
  helperText?: string
110
113
  }
111
114
 
115
+ // Display-only rows: they show something but hold no value, so they have no
116
+ // `path`. Houdini's Separator and Label parameter types.
117
+
118
+ /**
119
+ * A horizontal rule between rows, with an optional caption. Every kind keeps
120
+ * `label` so hosts can read `field.label` on any FieldDef without narrowing.
121
+ */
122
+ export interface SeparatorFieldDef {
123
+ kind: "separator"
124
+ id: string
125
+ label?: string
126
+ disabledWhen?: DisabledWhen
127
+ }
128
+
129
+ /** Static text: a hint, a note, a sub-heading. The text is `label`. */
130
+ export interface LabelFieldDef {
131
+ kind: "label"
132
+ id: string
133
+ label: string
134
+ disabledWhen?: DisabledWhen
135
+ }
136
+
112
137
  export type BuiltinFieldDef =
113
138
  | SelectFieldDef
114
139
  | ColorFieldDef
@@ -119,6 +144,8 @@ export type BuiltinFieldDef =
119
144
  | CheckboxFieldDef
120
145
  | FileFieldDef
121
146
  | VectorFieldDef
147
+ | SeparatorFieldDef
148
+ | LabelFieldDef
122
149
 
123
150
  export type FieldDef = BuiltinFieldDef | CustomFieldDef
124
151