@preset.nz/facets 0.1.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/LICENSE +21 -0
- package/README.md +225 -0
- package/package.json +28 -0
- package/src/field-renderers.tsx +470 -0
- package/src/index.ts +10 -0
- package/src/panel.tsx +152 -0
- package/src/registry.ts +32 -0
- package/src/types.ts +167 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 preset.nz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# @preset.nz/facets
|
|
2
|
+
|
|
3
|
+
Inspector panels for React, defined as data.
|
|
4
|
+
|
|
5
|
+
You describe a panel as a plain object — groups of fields, each with a kind and a
|
|
6
|
+
path. `facets` looks up a renderer per kind, reads the values, and draws the
|
|
7
|
+
panel. The panel itself knows nothing about your domain, so it never grows a
|
|
8
|
+
switch statement over your types.
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
<PropertyPanel scopeKey={selection.kind} selection={selection} ctx={ctx} />
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Built for image and map editors, where the same panel has to render a layer, a
|
|
15
|
+
filter, a document or a tool depending on what is selected.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installing
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm add @preset.nz/facets
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
React 19 is a peer dependency.
|
|
26
|
+
|
|
27
|
+
**This package ships unbuilt TypeScript.** There is no compiled output and no
|
|
28
|
+
build step — your bundler and `tsc` compile it along with your own source. That
|
|
29
|
+
is deliberate, and it is what makes the next section possible.
|
|
30
|
+
|
|
31
|
+
### You supply the primitives
|
|
32
|
+
|
|
33
|
+
The built-in renderers do not implement form controls. They import yours, from
|
|
34
|
+
the conventional shadcn path, and your bundler's `@` alias resolves it:
|
|
35
|
+
|
|
36
|
+
| Import | Must export |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `@/components/ui/input` | `Input` |
|
|
39
|
+
| `@/components/ui/label` | `Label` |
|
|
40
|
+
| `@/components/ui/checkbox` | `Checkbox` |
|
|
41
|
+
| `@/components/ui/separator` | `Separator` |
|
|
42
|
+
| `@/components/ui/select` | `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem` |
|
|
43
|
+
|
|
44
|
+
So you need an `@/*` alias pointing at your `src/`, those five modules present,
|
|
45
|
+
and Tailwind with shadcn's theme tokens (`muted-foreground`, `border` and
|
|
46
|
+
friends are used throughout).
|
|
47
|
+
|
|
48
|
+
The upside is that `facets` inherits whatever primitive library you already
|
|
49
|
+
chose — Base UI, Radix, or your own — instead of dragging in a second one and
|
|
50
|
+
making your panels look foreign inside your own app.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## A worked example
|
|
55
|
+
|
|
56
|
+
Register the built-in renderers once at startup, then register a scope per kind
|
|
57
|
+
of thing you can select.
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import {
|
|
61
|
+
PropertyPanel,
|
|
62
|
+
registerBuiltinRenderers,
|
|
63
|
+
registerScope,
|
|
64
|
+
type PropertySchema,
|
|
65
|
+
} from "@preset.nz/facets";
|
|
66
|
+
|
|
67
|
+
registerBuiltinRenderers();
|
|
68
|
+
|
|
69
|
+
const BLUR: PropertySchema = {
|
|
70
|
+
version: 1,
|
|
71
|
+
groups: [
|
|
72
|
+
{
|
|
73
|
+
id: "blur",
|
|
74
|
+
title: "Gaussian blur",
|
|
75
|
+
rows: [
|
|
76
|
+
{ kind: "slider", id: "radius", label: "Radius", path: "radius", min: 0, max: 64, step: 1 },
|
|
77
|
+
{ kind: "checkbox", id: "clampEdges", label: "Clamp edges", path: "clampEdges" },
|
|
78
|
+
[
|
|
79
|
+
{ kind: "color", id: "tint", label: "Tint", path: "tint" },
|
|
80
|
+
{ kind: "number", id: "amount", label: "Amount", path: "amount", min: 0, max: 1, step: 0.05 },
|
|
81
|
+
],
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
registerScope("blur", {
|
|
88
|
+
schema: BLUR,
|
|
89
|
+
read: (sel: { id: string }, ctx) => ctx.getFilter(sel.id).params,
|
|
90
|
+
write: (path, value, sel, ctx) => ctx.setFilterParam(sel.id, path, value),
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Then mount one panel for everything:
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
<PropertyPanel
|
|
98
|
+
scopeKey={selection.kind}
|
|
99
|
+
selection={selection}
|
|
100
|
+
ctx={appApi}
|
|
101
|
+
emptyState={<p>Nothing selected</p>}
|
|
102
|
+
/>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Select a blur and the blur panel appears. Add a new filter type later by
|
|
106
|
+
registering another scope — the panel does not change.
|
|
107
|
+
|
|
108
|
+
A row holding an array renders as a two-column grid. Everything else is full
|
|
109
|
+
width. That is the whole layout system, on purpose.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## The four pieces
|
|
114
|
+
|
|
115
|
+
**`FieldDef`** — one field, as data. `kind` picks the renderer, `path` picks the
|
|
116
|
+
value, `label` defaults to `id`.
|
|
117
|
+
|
|
118
|
+
**`Scope`** — a schema bundled with its own `read` and, optionally, `write`.
|
|
119
|
+
This is the important one. The panel never learns how to fetch or store your
|
|
120
|
+
values; each scope brings its own. Omit `write` and the scope is read-only.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
interface Scope<S, V> {
|
|
124
|
+
schema: PropertySchema;
|
|
125
|
+
read: (selection: S, ctx: ScopeContext) => V;
|
|
126
|
+
write?: (path: string, value: unknown, selection: S, ctx: ScopeContext) => void;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**The renderer registry** — `kind` to React component, open for extension.
|
|
131
|
+
Built-ins register through the same call you would use, with no special casing.
|
|
132
|
+
|
|
133
|
+
**`PropertyPanel`** — looks up the scope, calls `read`, walks the groups, hands
|
|
134
|
+
each field to its renderer. Props: `scopeKey`, `selection`, `ctx`, plus optional
|
|
135
|
+
`readOnly` and `emptyState`.
|
|
136
|
+
|
|
137
|
+
`ctx` is yours. The package treats it as opaque and passes it through to every
|
|
138
|
+
`read`, `write`, renderer and options provider. Put your app's API in it.
|
|
139
|
+
|
|
140
|
+
### How `path` resolves
|
|
141
|
+
|
|
142
|
+
`read()` returns a record, and `path` is a **key into that record** — a flat
|
|
143
|
+
lookup, not a nested traversal. Return a flat object shaped for the panel and
|
|
144
|
+
keep the mapping inside `read`, where your domain types are still in scope.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Field kinds
|
|
149
|
+
|
|
150
|
+
Nine ship built in.
|
|
151
|
+
|
|
152
|
+
| Kind | Value | Notes |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `text` | string | `placeholder` |
|
|
155
|
+
| `textarea` | string | `placeholder`, `rows` |
|
|
156
|
+
| `number` | number | `min`, `max`, `step` |
|
|
157
|
+
| `slider` | number | same, rendered as a slider |
|
|
158
|
+
| `checkbox` | boolean | |
|
|
159
|
+
| `select` | string | `options`, or `optionsProvider(ctx)` for dynamic lists |
|
|
160
|
+
| `color` | hex string | `presets`; shows a swatch |
|
|
161
|
+
| `vector` | number array | N scalars in one row — Houdini's `float3` / `int2`. `components` sets arity and per-slot label, suffix and range |
|
|
162
|
+
| `file` | `{ name }` | display only |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Read-only
|
|
167
|
+
|
|
168
|
+
Omit `write`, or pass `readOnly`, and no change handler reaches the renderers.
|
|
169
|
+
They then render the **formatted value as text**, not a disabled input. A
|
|
170
|
+
disabled `<input value="1024">` reads as broken; a plain `1024` reads as
|
|
171
|
+
information. Empty values render as an em dash.
|
|
172
|
+
|
|
173
|
+
Disabled controls are reserved for `disabledWhen`, which is a condition inside
|
|
174
|
+
an otherwise editable panel:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
{ kind: "number", id: "radius", path: "radius",
|
|
178
|
+
disabledWhen: { path: "mode", equals: "auto" } }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The condition reads one other field's value. Set one operator:
|
|
182
|
+
|
|
183
|
+
- `equals` / `notEquals` — compare against a single value.
|
|
184
|
+
- `in` / `notIn` — compare against a list, e.g. a param only some enum choices
|
|
185
|
+
use: `disabledWhen: { path: "pattern", notIn: ["dashes", "stipple"] }`.
|
|
186
|
+
|
|
187
|
+
If several are set, the first in that order wins.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Custom field kinds
|
|
192
|
+
|
|
193
|
+
Register any `kind` string and the panel will route to it. This is how a panel
|
|
194
|
+
grows a palette strip, a histogram or a segmentation preview without the package
|
|
195
|
+
knowing those exist.
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
registerFieldRenderer("palette-swatches", ({ field, value, onChange, ctx }) => (
|
|
199
|
+
<SwatchStrip colours={value as string[]} onPick={onChange} />
|
|
200
|
+
));
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A field whose kind is not built in may carry any extra props it likes; the
|
|
204
|
+
renderer reads what it needs. **The type system will not check those props**, so
|
|
205
|
+
document them next to the renderer.
|
|
206
|
+
|
|
207
|
+
An unregistered kind logs a warning and renders nothing. It does not throw, and
|
|
208
|
+
it does not take the rest of the panel down with it.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Notes
|
|
213
|
+
|
|
214
|
+
- **Registration is explicit.** `registerBuiltinRenderers()` is a call, not an
|
|
215
|
+
import side effect, and the package declares `sideEffects: false`. Call it
|
|
216
|
+
before rendering a panel.
|
|
217
|
+
- **Failures stay local.** A throwing `read` is caught and logged; an unknown
|
|
218
|
+
scope renders `emptyState`; an unknown kind skips that field.
|
|
219
|
+
- **Schemas are serialisable.** Keep them that way — no closures in a
|
|
220
|
+
`FieldDef` beyond `optionsProvider`. It keeps schemas versionable and lets them
|
|
221
|
+
cross a plugin boundary later.
|
|
222
|
+
|
|
223
|
+
## Licence
|
|
224
|
+
|
|
225
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@preset.nz/facets",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/preset-nz/facets.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "^19"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^19",
|
|
26
|
+
"typescript": "^5.8.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/* eslint-disable react-refresh/only-export-components --
|
|
2
|
+
* This module ships the built-in field renderers as small co-located
|
|
3
|
+
* components plus a single `registerBuiltinRenderers` entry point. Splitting
|
|
4
|
+
* each renderer into its own file would be over-organisation for code that
|
|
5
|
+
* never hot-reloads in isolation. */
|
|
6
|
+
import { useMemo } from "react"
|
|
7
|
+
import { Input } from "@/components/ui/input"
|
|
8
|
+
import { Label } from "@/components/ui/label"
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from "@/components/ui/select"
|
|
16
|
+
import { Checkbox } from "@/components/ui/checkbox"
|
|
17
|
+
import { registerFieldRenderer } from "./registry"
|
|
18
|
+
import type {
|
|
19
|
+
CheckboxFieldDef,
|
|
20
|
+
ColorFieldDef,
|
|
21
|
+
FieldRenderer,
|
|
22
|
+
FieldRendererProps,
|
|
23
|
+
NumberFieldDef,
|
|
24
|
+
SelectFieldDef,
|
|
25
|
+
SliderFieldDef,
|
|
26
|
+
TextFieldDef,
|
|
27
|
+
TextareaFieldDef,
|
|
28
|
+
FileFieldDef,
|
|
29
|
+
VectorFieldDef,
|
|
30
|
+
} from "./types"
|
|
31
|
+
|
|
32
|
+
function ReadOnlyText({ children }: { children: React.ReactNode }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="min-h-8 px-0 py-1 text-xs text-foreground tabular-nums break-all">
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Empty() {
|
|
41
|
+
return <span className="text-muted-foreground">—</span>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function FieldShell({
|
|
45
|
+
label,
|
|
46
|
+
children,
|
|
47
|
+
}: {
|
|
48
|
+
label?: string
|
|
49
|
+
children: React.ReactNode
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-col gap-1">
|
|
53
|
+
{label && (
|
|
54
|
+
<Label className="text-[11px] font-medium text-muted-foreground tracking-wide">
|
|
55
|
+
{label}
|
|
56
|
+
</Label>
|
|
57
|
+
)}
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatPrimitive(value: unknown): string {
|
|
64
|
+
if (value == null || value === "") return ""
|
|
65
|
+
if (typeof value === "boolean") return value ? "Yes" : "No"
|
|
66
|
+
if (value instanceof Date) return value.toISOString()
|
|
67
|
+
if (typeof value === "object") return JSON.stringify(value)
|
|
68
|
+
return String(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TextRenderer: FieldRenderer<TextFieldDef> = ({
|
|
72
|
+
field,
|
|
73
|
+
value,
|
|
74
|
+
disabled,
|
|
75
|
+
onChange,
|
|
76
|
+
}) => {
|
|
77
|
+
const display = formatPrimitive(value)
|
|
78
|
+
return (
|
|
79
|
+
<FieldShell label={field.label ?? field.id}>
|
|
80
|
+
{onChange ? (
|
|
81
|
+
<Input
|
|
82
|
+
aria-label={field.label ?? field.id}
|
|
83
|
+
value={display}
|
|
84
|
+
placeholder={field.placeholder}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
onChange={(e) => onChange(e.target.value)}
|
|
87
|
+
/>
|
|
88
|
+
) : display ? (
|
|
89
|
+
<ReadOnlyText>{display}</ReadOnlyText>
|
|
90
|
+
) : (
|
|
91
|
+
<ReadOnlyText>
|
|
92
|
+
<Empty />
|
|
93
|
+
</ReadOnlyText>
|
|
94
|
+
)}
|
|
95
|
+
</FieldShell>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const TextareaRenderer: FieldRenderer<TextareaFieldDef> = ({
|
|
100
|
+
field,
|
|
101
|
+
value,
|
|
102
|
+
disabled,
|
|
103
|
+
onChange,
|
|
104
|
+
}) => {
|
|
105
|
+
const display = formatPrimitive(value)
|
|
106
|
+
return (
|
|
107
|
+
<FieldShell label={field.label ?? field.id}>
|
|
108
|
+
{onChange ? (
|
|
109
|
+
<textarea
|
|
110
|
+
aria-label={field.label ?? field.id}
|
|
111
|
+
value={display}
|
|
112
|
+
rows={field.rows ?? 3}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
placeholder={field.placeholder}
|
|
115
|
+
onChange={(e) => onChange(e.target.value)}
|
|
116
|
+
className="w-full rounded-none border border-input bg-transparent px-2.5 py-1 text-xs outline-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:opacity-50"
|
|
117
|
+
/>
|
|
118
|
+
) : display ? (
|
|
119
|
+
<ReadOnlyText>
|
|
120
|
+
<span className="whitespace-pre-wrap">{display}</span>
|
|
121
|
+
</ReadOnlyText>
|
|
122
|
+
) : (
|
|
123
|
+
<ReadOnlyText>
|
|
124
|
+
<Empty />
|
|
125
|
+
</ReadOnlyText>
|
|
126
|
+
)}
|
|
127
|
+
</FieldShell>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const NumberRenderer: FieldRenderer<NumberFieldDef> = ({
|
|
132
|
+
field,
|
|
133
|
+
value,
|
|
134
|
+
disabled,
|
|
135
|
+
onChange,
|
|
136
|
+
}) => {
|
|
137
|
+
const num =
|
|
138
|
+
typeof value === "number"
|
|
139
|
+
? value
|
|
140
|
+
: value != null && value !== ""
|
|
141
|
+
? Number(value)
|
|
142
|
+
: null
|
|
143
|
+
const display =
|
|
144
|
+
num != null && Number.isFinite(num) ? num.toLocaleString() : ""
|
|
145
|
+
return (
|
|
146
|
+
<FieldShell label={field.label ?? field.id}>
|
|
147
|
+
{onChange ? (
|
|
148
|
+
<Input
|
|
149
|
+
type="number"
|
|
150
|
+
aria-label={field.label ?? field.id}
|
|
151
|
+
value={num != null && Number.isFinite(num) ? String(num) : ""}
|
|
152
|
+
min={field.min}
|
|
153
|
+
max={field.max}
|
|
154
|
+
step={field.step ?? 1}
|
|
155
|
+
disabled={disabled}
|
|
156
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
157
|
+
/>
|
|
158
|
+
) : display ? (
|
|
159
|
+
<ReadOnlyText>{display}</ReadOnlyText>
|
|
160
|
+
) : (
|
|
161
|
+
<ReadOnlyText>
|
|
162
|
+
<Empty />
|
|
163
|
+
</ReadOnlyText>
|
|
164
|
+
)}
|
|
165
|
+
</FieldShell>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const SliderRenderer: FieldRenderer<SliderFieldDef> = ({
|
|
170
|
+
field,
|
|
171
|
+
value,
|
|
172
|
+
disabled,
|
|
173
|
+
onChange,
|
|
174
|
+
}) => {
|
|
175
|
+
const num = typeof value === "number" ? value : Number(value ?? 0)
|
|
176
|
+
return (
|
|
177
|
+
<FieldShell label={field.label ?? field.id}>
|
|
178
|
+
{onChange ? (
|
|
179
|
+
<input
|
|
180
|
+
type="range"
|
|
181
|
+
aria-label={field.label ?? field.id}
|
|
182
|
+
value={Number.isFinite(num) ? num : (field.min ?? 0)}
|
|
183
|
+
min={field.min}
|
|
184
|
+
max={field.max}
|
|
185
|
+
step={field.step ?? 1}
|
|
186
|
+
disabled={disabled}
|
|
187
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
188
|
+
className="w-full"
|
|
189
|
+
/>
|
|
190
|
+
) : (
|
|
191
|
+
<ReadOnlyText>
|
|
192
|
+
{Number.isFinite(num) ? num.toLocaleString() : <Empty />}
|
|
193
|
+
</ReadOnlyText>
|
|
194
|
+
)}
|
|
195
|
+
</FieldShell>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const SelectRenderer: FieldRenderer<SelectFieldDef> = ({
|
|
200
|
+
field,
|
|
201
|
+
value,
|
|
202
|
+
disabled,
|
|
203
|
+
onChange,
|
|
204
|
+
ctx,
|
|
205
|
+
}) => {
|
|
206
|
+
const options = useMemo(
|
|
207
|
+
() => (field.optionsProvider ? field.optionsProvider(ctx) : (field.options ?? [])),
|
|
208
|
+
[field, ctx],
|
|
209
|
+
)
|
|
210
|
+
const current = value == null ? "" : String(value)
|
|
211
|
+
const matched = options.find((o) => o.value === current)
|
|
212
|
+
if (!onChange) {
|
|
213
|
+
return (
|
|
214
|
+
<FieldShell label={field.label ?? field.id}>
|
|
215
|
+
<ReadOnlyText>
|
|
216
|
+
{matched ? matched.label : current ? current : <Empty />}
|
|
217
|
+
</ReadOnlyText>
|
|
218
|
+
</FieldShell>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
return (
|
|
222
|
+
<FieldShell label={field.label ?? field.id}>
|
|
223
|
+
<Select
|
|
224
|
+
value={current}
|
|
225
|
+
onValueChange={(v) => onChange(v)}
|
|
226
|
+
disabled={disabled}
|
|
227
|
+
>
|
|
228
|
+
<SelectTrigger>
|
|
229
|
+
<SelectValue />
|
|
230
|
+
</SelectTrigger>
|
|
231
|
+
<SelectContent>
|
|
232
|
+
{options.map((o) => (
|
|
233
|
+
<SelectItem key={o.value} value={o.value}>
|
|
234
|
+
{o.label}
|
|
235
|
+
</SelectItem>
|
|
236
|
+
))}
|
|
237
|
+
</SelectContent>
|
|
238
|
+
</Select>
|
|
239
|
+
</FieldShell>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const ColorRenderer: FieldRenderer<ColorFieldDef> = ({
|
|
244
|
+
field,
|
|
245
|
+
value,
|
|
246
|
+
disabled,
|
|
247
|
+
onChange,
|
|
248
|
+
}) => {
|
|
249
|
+
const hex = typeof value === "string" && value.length > 0 ? value : null
|
|
250
|
+
return (
|
|
251
|
+
<FieldShell label={field.label ?? field.id}>
|
|
252
|
+
<div className="flex items-center gap-2">
|
|
253
|
+
{hex ? (
|
|
254
|
+
<span
|
|
255
|
+
aria-hidden
|
|
256
|
+
className="inline-block size-4 shrink-0 border border-border"
|
|
257
|
+
style={{ backgroundColor: hex }}
|
|
258
|
+
/>
|
|
259
|
+
) : (
|
|
260
|
+
<span className="inline-block size-4 shrink-0 border border-dashed border-border" />
|
|
261
|
+
)}
|
|
262
|
+
{onChange ? (
|
|
263
|
+
<Input
|
|
264
|
+
type="text"
|
|
265
|
+
aria-label={field.label ?? field.id}
|
|
266
|
+
value={hex ?? ""}
|
|
267
|
+
disabled={disabled}
|
|
268
|
+
onChange={(e) => onChange(e.target.value)}
|
|
269
|
+
className="font-mono"
|
|
270
|
+
/>
|
|
271
|
+
) : (
|
|
272
|
+
<ReadOnlyText>
|
|
273
|
+
<span className="font-mono">{hex ?? <Empty />}</span>
|
|
274
|
+
</ReadOnlyText>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
</FieldShell>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const CheckboxRenderer: FieldRenderer<CheckboxFieldDef> = ({
|
|
282
|
+
field,
|
|
283
|
+
value,
|
|
284
|
+
disabled,
|
|
285
|
+
onChange,
|
|
286
|
+
}) => {
|
|
287
|
+
const checked = Boolean(value)
|
|
288
|
+
if (!onChange) {
|
|
289
|
+
return (
|
|
290
|
+
<FieldShell label={field.label ?? field.id}>
|
|
291
|
+
<ReadOnlyText>{checked ? "Yes" : "No"}</ReadOnlyText>
|
|
292
|
+
</FieldShell>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
return (
|
|
296
|
+
<div className="flex items-center gap-2">
|
|
297
|
+
<Checkbox
|
|
298
|
+
checked={checked}
|
|
299
|
+
disabled={disabled}
|
|
300
|
+
onCheckedChange={(next) => onChange(Boolean(next))}
|
|
301
|
+
/>
|
|
302
|
+
{field.label && (
|
|
303
|
+
<Label className="text-xs text-foreground">{field.label}</Label>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const FileRenderer: FieldRenderer<FileFieldDef> = ({
|
|
310
|
+
field,
|
|
311
|
+
value,
|
|
312
|
+
}) => {
|
|
313
|
+
const asset = value as { name?: string } | null | undefined
|
|
314
|
+
return (
|
|
315
|
+
<FieldShell label={field.label ?? field.id}>
|
|
316
|
+
<ReadOnlyText>
|
|
317
|
+
{asset?.name ? asset.name : <Empty />}
|
|
318
|
+
</ReadOnlyText>
|
|
319
|
+
</FieldShell>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function toNumberArray(value: unknown, arity: number): Array<number | null> {
|
|
324
|
+
if (!Array.isArray(value)) return Array.from({ length: arity }, () => null)
|
|
325
|
+
return Array.from({ length: arity }, (_, i) => {
|
|
326
|
+
const v = value[i]
|
|
327
|
+
if (v == null || v === "") return null
|
|
328
|
+
const n = Number(v)
|
|
329
|
+
return Number.isFinite(n) ? n : null
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function formatComponent(
|
|
334
|
+
n: number | null,
|
|
335
|
+
integer: boolean,
|
|
336
|
+
precision?: number,
|
|
337
|
+
): string {
|
|
338
|
+
if (n == null) return ""
|
|
339
|
+
if (integer) return Math.trunc(n).toLocaleString()
|
|
340
|
+
if (precision != null) return n.toFixed(precision)
|
|
341
|
+
return n.toLocaleString()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
|
|
345
|
+
field,
|
|
346
|
+
value,
|
|
347
|
+
disabled,
|
|
348
|
+
onChange,
|
|
349
|
+
}) => {
|
|
350
|
+
const arity = field.components.length
|
|
351
|
+
const values = toNumberArray(value, arity)
|
|
352
|
+
const isInt = Boolean(field.integer)
|
|
353
|
+
|
|
354
|
+
if (!onChange) {
|
|
355
|
+
return (
|
|
356
|
+
<FieldShell label={field.label ?? field.id}>
|
|
357
|
+
<div
|
|
358
|
+
className="grid gap-x-3 gap-y-1"
|
|
359
|
+
style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
|
|
360
|
+
>
|
|
361
|
+
{field.components.map((c, i) => (
|
|
362
|
+
<div key={i} className="flex flex-col gap-0.5 min-w-0">
|
|
363
|
+
{c.label && (
|
|
364
|
+
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
365
|
+
{c.label}
|
|
366
|
+
</span>
|
|
367
|
+
)}
|
|
368
|
+
<div className="text-xs text-foreground tabular-nums truncate">
|
|
369
|
+
{values[i] == null ? (
|
|
370
|
+
<Empty />
|
|
371
|
+
) : (
|
|
372
|
+
<>
|
|
373
|
+
{formatComponent(values[i], isInt, field.precision)}
|
|
374
|
+
{c.suffix && (
|
|
375
|
+
<span className="text-muted-foreground">{c.suffix}</span>
|
|
376
|
+
)}
|
|
377
|
+
</>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
))}
|
|
382
|
+
</div>
|
|
383
|
+
</FieldShell>
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<FieldShell label={field.label ?? field.id}>
|
|
389
|
+
<div
|
|
390
|
+
className="grid gap-x-2"
|
|
391
|
+
style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
|
|
392
|
+
>
|
|
393
|
+
{field.components.map((c, i) => (
|
|
394
|
+
<div key={i} className="flex flex-col gap-0.5 min-w-0">
|
|
395
|
+
{c.label && (
|
|
396
|
+
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
397
|
+
{c.label}
|
|
398
|
+
</span>
|
|
399
|
+
)}
|
|
400
|
+
<Input
|
|
401
|
+
type="number"
|
|
402
|
+
aria-label={c.label ?? `${field.id}[${i}]`}
|
|
403
|
+
value={values[i] != null ? String(values[i]) : ""}
|
|
404
|
+
min={c.min}
|
|
405
|
+
max={c.max}
|
|
406
|
+
step={c.step ?? (isInt ? 1 : 0.01)}
|
|
407
|
+
disabled={disabled}
|
|
408
|
+
onChange={(e) => {
|
|
409
|
+
const raw = e.target.value
|
|
410
|
+
const parsed = raw === "" ? null : Number(raw)
|
|
411
|
+
const next = values.slice()
|
|
412
|
+
next[i] =
|
|
413
|
+
parsed != null && Number.isFinite(parsed)
|
|
414
|
+
? isInt
|
|
415
|
+
? Math.trunc(parsed)
|
|
416
|
+
: parsed
|
|
417
|
+
: null
|
|
418
|
+
onChange(next)
|
|
419
|
+
}}
|
|
420
|
+
/>
|
|
421
|
+
</div>
|
|
422
|
+
))}
|
|
423
|
+
</div>
|
|
424
|
+
</FieldShell>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function registerBuiltinRenderers(): void {
|
|
429
|
+
registerFieldRenderer(
|
|
430
|
+
"text",
|
|
431
|
+
TextRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
432
|
+
)
|
|
433
|
+
registerFieldRenderer(
|
|
434
|
+
"textarea",
|
|
435
|
+
TextareaRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
436
|
+
)
|
|
437
|
+
registerFieldRenderer(
|
|
438
|
+
"number",
|
|
439
|
+
NumberRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
440
|
+
)
|
|
441
|
+
registerFieldRenderer(
|
|
442
|
+
"slider",
|
|
443
|
+
SliderRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
444
|
+
)
|
|
445
|
+
registerFieldRenderer(
|
|
446
|
+
"select",
|
|
447
|
+
SelectRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
448
|
+
)
|
|
449
|
+
registerFieldRenderer(
|
|
450
|
+
"color",
|
|
451
|
+
ColorRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
452
|
+
)
|
|
453
|
+
registerFieldRenderer(
|
|
454
|
+
"checkbox",
|
|
455
|
+
CheckboxRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
456
|
+
)
|
|
457
|
+
registerFieldRenderer(
|
|
458
|
+
"file",
|
|
459
|
+
FileRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
460
|
+
)
|
|
461
|
+
registerFieldRenderer(
|
|
462
|
+
"vector",
|
|
463
|
+
VectorRenderer as FieldRenderer<import("./types").FieldDef>,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export type {
|
|
468
|
+
FieldRenderer,
|
|
469
|
+
FieldRendererProps,
|
|
470
|
+
}
|
package/src/index.ts
ADDED
package/src/panel.tsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
import { Separator } from "@/components/ui/separator"
|
|
3
|
+
import { getFieldRenderer, getScope } from "./registry"
|
|
4
|
+
import type {
|
|
5
|
+
FieldDef,
|
|
6
|
+
PropertyGroupDef,
|
|
7
|
+
ScopeContext,
|
|
8
|
+
} from "./types"
|
|
9
|
+
|
|
10
|
+
interface PropertyPanelProps {
|
|
11
|
+
scopeKey: string
|
|
12
|
+
selection: unknown
|
|
13
|
+
ctx: ScopeContext
|
|
14
|
+
readOnly?: boolean
|
|
15
|
+
emptyState?: React.ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PropertyPanel({
|
|
19
|
+
scopeKey,
|
|
20
|
+
selection,
|
|
21
|
+
ctx,
|
|
22
|
+
readOnly = false,
|
|
23
|
+
emptyState,
|
|
24
|
+
}: PropertyPanelProps) {
|
|
25
|
+
const scope = getScope(scopeKey)
|
|
26
|
+
|
|
27
|
+
const values = useMemo(() => {
|
|
28
|
+
if (!scope) return {} as Record<string, unknown>
|
|
29
|
+
try {
|
|
30
|
+
return scope.read(selection, ctx) as Record<string, unknown>
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.error("[properties] scope read failed:", scopeKey, e)
|
|
33
|
+
return {} as Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
}, [scope, scopeKey, selection, ctx])
|
|
36
|
+
|
|
37
|
+
if (!scope) return <>{emptyState ?? null}</>
|
|
38
|
+
|
|
39
|
+
const onChange = readOnly || !scope.write
|
|
40
|
+
? undefined
|
|
41
|
+
: (path: string, val: unknown) => scope.write!(path, val, selection, ctx)
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex flex-col">
|
|
45
|
+
{scope.schema.groups.map((group, idx) => (
|
|
46
|
+
<PropertyGroup
|
|
47
|
+
key={group.id}
|
|
48
|
+
group={group}
|
|
49
|
+
values={values}
|
|
50
|
+
ctx={ctx}
|
|
51
|
+
onChange={onChange}
|
|
52
|
+
isLast={idx === scope.schema.groups.length - 1}
|
|
53
|
+
/>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function PropertyGroup({
|
|
60
|
+
group,
|
|
61
|
+
values,
|
|
62
|
+
ctx,
|
|
63
|
+
onChange,
|
|
64
|
+
isLast,
|
|
65
|
+
}: {
|
|
66
|
+
group: PropertyGroupDef
|
|
67
|
+
values: Record<string, unknown>
|
|
68
|
+
ctx: ScopeContext
|
|
69
|
+
onChange?: (path: string, val: unknown) => void
|
|
70
|
+
isLast: boolean
|
|
71
|
+
}) {
|
|
72
|
+
return (
|
|
73
|
+
<section className="flex flex-col gap-3 px-3 py-3">
|
|
74
|
+
{(group.title || group.description) && (
|
|
75
|
+
<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 && (
|
|
82
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
83
|
+
{group.description}
|
|
84
|
+
</p>
|
|
85
|
+
)}
|
|
86
|
+
</header>
|
|
87
|
+
)}
|
|
88
|
+
<div className="flex flex-col gap-3">
|
|
89
|
+
{group.rows.map((row, idx) => {
|
|
90
|
+
const fields = Array.isArray(row) ? row : [row]
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
key={idx}
|
|
94
|
+
className={
|
|
95
|
+
fields.length > 1 ? "grid grid-cols-2 gap-2" : undefined
|
|
96
|
+
}
|
|
97
|
+
>
|
|
98
|
+
{fields.map((f) => (
|
|
99
|
+
<FieldSlot
|
|
100
|
+
key={f.id}
|
|
101
|
+
field={f}
|
|
102
|
+
values={values}
|
|
103
|
+
ctx={ctx}
|
|
104
|
+
onChange={onChange}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
{!isLast && <Separator className="mt-1" />}
|
|
112
|
+
</section>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function FieldSlot({
|
|
117
|
+
field,
|
|
118
|
+
values,
|
|
119
|
+
ctx,
|
|
120
|
+
onChange,
|
|
121
|
+
}: {
|
|
122
|
+
field: FieldDef
|
|
123
|
+
values: Record<string, unknown>
|
|
124
|
+
ctx: ScopeContext
|
|
125
|
+
onChange?: (path: string, val: unknown) => void
|
|
126
|
+
}) {
|
|
127
|
+
const renderer = getFieldRenderer(field.kind)
|
|
128
|
+
if (!renderer) {
|
|
129
|
+
console.warn("[properties] no renderer for kind:", field.kind)
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
const disabled = (() => {
|
|
133
|
+
const cond = field.disabledWhen
|
|
134
|
+
if (!cond) return false
|
|
135
|
+
const v = values[cond.path]
|
|
136
|
+
if (Object.prototype.hasOwnProperty.call(cond, "equals"))
|
|
137
|
+
return v === cond.equals
|
|
138
|
+
if (Object.prototype.hasOwnProperty.call(cond, "notEquals"))
|
|
139
|
+
return v !== cond.notEquals
|
|
140
|
+
if (cond.in) return cond.in.includes(v)
|
|
141
|
+
if (cond.notIn) return !cond.notIn.includes(v)
|
|
142
|
+
return false
|
|
143
|
+
})()
|
|
144
|
+
|
|
145
|
+
return renderer({
|
|
146
|
+
field,
|
|
147
|
+
value: values[field.path],
|
|
148
|
+
disabled,
|
|
149
|
+
onChange: onChange ? (val) => onChange(field.path, val) : undefined,
|
|
150
|
+
ctx,
|
|
151
|
+
})
|
|
152
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FieldRenderer, Scope } from "./types"
|
|
2
|
+
|
|
3
|
+
const scopes = new Map<string, Scope<unknown, Record<string, unknown>>>()
|
|
4
|
+
const fieldRenderers = new Map<string, FieldRenderer>()
|
|
5
|
+
|
|
6
|
+
export function registerScope<S, V extends Record<string, unknown>>(
|
|
7
|
+
key: string,
|
|
8
|
+
scope: Scope<S, V>,
|
|
9
|
+
): void {
|
|
10
|
+
scopes.set(key, scope as Scope<unknown, Record<string, unknown>>)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function unregisterScope(key: string): void {
|
|
14
|
+
scopes.delete(key)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getScope(
|
|
18
|
+
key: string,
|
|
19
|
+
): Scope<unknown, Record<string, unknown>> | undefined {
|
|
20
|
+
return scopes.get(key)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerFieldRenderer(
|
|
24
|
+
kind: string,
|
|
25
|
+
Component: FieldRenderer,
|
|
26
|
+
): void {
|
|
27
|
+
fieldRenderers.set(kind, Component)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getFieldRenderer(kind: string): FieldRenderer | undefined {
|
|
31
|
+
return fieldRenderers.get(kind)
|
|
32
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Built-in kinds shipped with the package. Apps may register additional kinds
|
|
2
|
+
// via `registerFieldRenderer(kind, Component)` — the panel looks up by string.
|
|
3
|
+
export type BuiltinFieldKind =
|
|
4
|
+
| "text"
|
|
5
|
+
| "textarea"
|
|
6
|
+
| "number"
|
|
7
|
+
| "slider"
|
|
8
|
+
| "select"
|
|
9
|
+
| "color"
|
|
10
|
+
| "checkbox"
|
|
11
|
+
| "file"
|
|
12
|
+
|
|
13
|
+
export interface BaseField {
|
|
14
|
+
kind: string
|
|
15
|
+
id: string
|
|
16
|
+
label?: string
|
|
17
|
+
path: string
|
|
18
|
+
disabledWhen?: DisabledWhen
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Disables a field based on one other field's value. Exactly one operator is
|
|
23
|
+
* meant to be set; if several are, the first present in the order
|
|
24
|
+
* `equals`, `notEquals`, `in`, `notIn` wins. `in`/`notIn` match a list of
|
|
25
|
+
* values (e.g. "only these enum choices use this param") and are still a
|
|
26
|
+
* single-path condition — composable AND/OR belongs in a later DSL.
|
|
27
|
+
*/
|
|
28
|
+
export interface DisabledWhen {
|
|
29
|
+
path: string
|
|
30
|
+
equals?: unknown
|
|
31
|
+
notEquals?: unknown
|
|
32
|
+
in?: readonly unknown[]
|
|
33
|
+
notIn?: readonly unknown[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A custom field is any BaseField whose `kind` is not one of the built-ins.
|
|
37
|
+
// The host's registered renderer for that kind decides what extra props it reads.
|
|
38
|
+
export type CustomFieldDef = BaseField & {
|
|
39
|
+
[extra: string]: unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SelectOption {
|
|
43
|
+
value: string
|
|
44
|
+
label: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SelectFieldDef extends BaseField {
|
|
48
|
+
kind: "select"
|
|
49
|
+
options?: SelectOption[]
|
|
50
|
+
optionsProvider?: (ctx: ScopeContext) => SelectOption[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ColorFieldDef extends BaseField {
|
|
54
|
+
kind: "color"
|
|
55
|
+
presets?: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TextFieldDef extends BaseField {
|
|
59
|
+
kind: "text"
|
|
60
|
+
placeholder?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TextareaFieldDef extends BaseField {
|
|
64
|
+
kind: "textarea"
|
|
65
|
+
placeholder?: string
|
|
66
|
+
rows?: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface NumberFieldDef extends BaseField {
|
|
70
|
+
kind: "number"
|
|
71
|
+
min?: number
|
|
72
|
+
max?: number
|
|
73
|
+
step?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SliderFieldDef extends BaseField {
|
|
77
|
+
kind: "slider"
|
|
78
|
+
min?: number
|
|
79
|
+
max?: number
|
|
80
|
+
step?: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CheckboxFieldDef extends BaseField {
|
|
84
|
+
kind: "checkbox"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A tuple of related scalars sharing one row. Houdini-style float3 / int2 /
|
|
88
|
+
// stringN — variable arity, components share semantic context (LCh, RGB,
|
|
89
|
+
// position, dimensions, …). The path resolves to an array; each component
|
|
90
|
+
// describes one slot.
|
|
91
|
+
export interface VectorComponentDef {
|
|
92
|
+
label?: string
|
|
93
|
+
suffix?: string
|
|
94
|
+
min?: number
|
|
95
|
+
max?: number
|
|
96
|
+
step?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface VectorFieldDef extends BaseField {
|
|
100
|
+
kind: "vector"
|
|
101
|
+
components: VectorComponentDef[]
|
|
102
|
+
integer?: boolean
|
|
103
|
+
precision?: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface FileFieldDef extends BaseField {
|
|
107
|
+
kind: "file"
|
|
108
|
+
accept?: string
|
|
109
|
+
helperText?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type BuiltinFieldDef =
|
|
113
|
+
| SelectFieldDef
|
|
114
|
+
| ColorFieldDef
|
|
115
|
+
| TextFieldDef
|
|
116
|
+
| TextareaFieldDef
|
|
117
|
+
| NumberFieldDef
|
|
118
|
+
| SliderFieldDef
|
|
119
|
+
| CheckboxFieldDef
|
|
120
|
+
| FileFieldDef
|
|
121
|
+
| VectorFieldDef
|
|
122
|
+
|
|
123
|
+
export type FieldDef = BuiltinFieldDef | CustomFieldDef
|
|
124
|
+
|
|
125
|
+
export interface PropertyGroupDef {
|
|
126
|
+
id: string
|
|
127
|
+
/** Omit to render the group without a header (e.g. when the host already labels it). */
|
|
128
|
+
title?: string
|
|
129
|
+
rows: Array<FieldDef | FieldDef[]>
|
|
130
|
+
description?: string
|
|
131
|
+
collapsible?: boolean
|
|
132
|
+
defaultCollapsed?: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface PropertySchema {
|
|
136
|
+
version: number
|
|
137
|
+
groups: PropertyGroupDef[]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Opaque to the package. Each host types its own context.
|
|
141
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
142
|
+
export type ScopeContext = any
|
|
143
|
+
|
|
144
|
+
export interface Scope<S = unknown, V = Record<string, unknown>> {
|
|
145
|
+
schema: PropertySchema
|
|
146
|
+
read: (selection: S, ctx: ScopeContext) => V
|
|
147
|
+
write?: (
|
|
148
|
+
path: string,
|
|
149
|
+
value: unknown,
|
|
150
|
+
selection: S,
|
|
151
|
+
ctx: ScopeContext,
|
|
152
|
+
) => void
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface FieldRendererProps<F extends FieldDef = FieldDef> {
|
|
156
|
+
field: F
|
|
157
|
+
value: unknown
|
|
158
|
+
disabled: boolean
|
|
159
|
+
onChange?: (next: unknown) => void
|
|
160
|
+
ctx: ScopeContext
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type FieldRenderer<F extends FieldDef = FieldDef> = React.FC<
|
|
164
|
+
FieldRendererProps<F>
|
|
165
|
+
>
|
|
166
|
+
|
|
167
|
+
import type React from "react"
|