@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 +23 -1
- package/package.json +2 -1
- package/src/field-renderers.tsx +38 -1
- package/src/panel.tsx +44 -10
- package/src/types.ts +27 -0
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
|
-
|
|
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.
|
|
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"
|
package/src/field-renderers.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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[
|
|
180
|
+
value: path === undefined ? undefined : values[path],
|
|
148
181
|
disabled,
|
|
149
|
-
onChange:
|
|
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
|
|