@preset.nz/facets 0.1.3 → 0.2.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 +159 -7
- package/package.json +1 -1
- package/schema/v0.1.json +59 -0
- package/src/field-renderers.tsx +232 -111
- package/src/index.ts +3 -2
- package/src/layout.ts +37 -0
- package/src/panel.tsx +206 -25
- package/src/types.ts +62 -0
package/src/panel.tsx
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { useId, useMemo, useState } from "react"
|
|
1
|
+
import { createContext, useContext, useId, useMemo, useState } from "react"
|
|
2
2
|
import { Separator } from "@/components/ui/separator"
|
|
3
3
|
import { getFieldRenderer, getScope } from "./registry"
|
|
4
|
+
import { LABEL_WIDTH_VAR, LabelLayoutContext, labelColumnWidth, useLabelLayout } from "./layout"
|
|
4
5
|
import type {
|
|
5
6
|
FieldDef,
|
|
7
|
+
LabelLayout,
|
|
8
|
+
PanelView,
|
|
6
9
|
PropertyGroupDef,
|
|
10
|
+
PropertySchema,
|
|
7
11
|
ScopeContext,
|
|
8
12
|
} from "./types"
|
|
9
13
|
|
|
@@ -13,14 +17,81 @@ interface PropertyPanelProps {
|
|
|
13
17
|
ctx: ScopeContext
|
|
14
18
|
readOnly?: boolean
|
|
15
19
|
emptyState?: React.ReactNode
|
|
20
|
+
/** Defaults to `"inspector"`, every field. See `PanelView`. */
|
|
21
|
+
view?: PanelView
|
|
22
|
+
/** The card's heading. Overrides the schema's `title`; neither means no heading. */
|
|
23
|
+
title?: React.ReactNode
|
|
24
|
+
/**
|
|
25
|
+
* Where labels sit in the inspector and card views: `"column"` (default since 0.2.0: one shared label column), `"auto"` (the 0.1 layout)
|
|
26
|
+
* (one shared label column) or `"stacked"` (label above). See `LabelLayout`. The collapsed view
|
|
27
|
+
* ignores it.
|
|
28
|
+
*/
|
|
29
|
+
labelLayout?: LabelLayout
|
|
30
|
+
/** `"column"` only: the label column's width as a CSS length. Default: fits the longest label, 8ch to 16ch. */
|
|
31
|
+
labelWidth?: string
|
|
16
32
|
}
|
|
17
33
|
|
|
18
|
-
|
|
34
|
+
/** The scope's gesture, bound to its selection and ctx, for the field slots below. */
|
|
35
|
+
interface GestureHandlers {
|
|
36
|
+
begin: (path: string) => void
|
|
37
|
+
end: (path: string) => void
|
|
38
|
+
cancel: (path: string) => void
|
|
39
|
+
}
|
|
40
|
+
const GestureContext = createContext<GestureHandlers | null>(null)
|
|
41
|
+
|
|
42
|
+
type Row = Array<FieldDef | FieldDef[]>[number]
|
|
43
|
+
|
|
44
|
+
/** Whether a field shows in a view: every field in the inspector, else only those promoted to it. */
|
|
45
|
+
export function showsIn(field: FieldDef, view: PanelView): boolean {
|
|
46
|
+
return view === "inspector" || (field.promote?.includes(view) ?? false)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The fields a view draws, in schema order, with paired rows flattened. */
|
|
50
|
+
export function promotedFields(schema: PropertySchema, view: PanelView): FieldDef[] {
|
|
51
|
+
return schema.groups
|
|
52
|
+
.flatMap((g) => g.rows)
|
|
53
|
+
.flatMap((row) => (Array.isArray(row) ? row : [row]))
|
|
54
|
+
.filter((f) => showsIn(f, view))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A group's rows kept to one view: fields filtered, empty rows dropped. */
|
|
58
|
+
function rowsIn(rows: Row[], view: PanelView): FieldDef[][] {
|
|
59
|
+
return rows
|
|
60
|
+
.map((row) => (Array.isArray(row) ? row : [row]).filter((f) => showsIn(f, view)))
|
|
61
|
+
.filter((fields) => fields.length > 0)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function PropertyPanel(props: PropertyPanelProps) {
|
|
65
|
+
const { scopeKey, selection, ctx, readOnly = false } = props
|
|
66
|
+
const gesture = getScope(scopeKey)?.gesture
|
|
67
|
+
const handlers = useMemo<GestureHandlers | null>(
|
|
68
|
+
() =>
|
|
69
|
+
readOnly || !gesture
|
|
70
|
+
? null
|
|
71
|
+
: {
|
|
72
|
+
begin: (path) => gesture.begin(path, selection, ctx),
|
|
73
|
+
end: (path) => gesture.end(path, selection, ctx),
|
|
74
|
+
cancel: (path) => gesture.cancel?.(path, selection, ctx),
|
|
75
|
+
},
|
|
76
|
+
[readOnly, gesture, selection, ctx],
|
|
77
|
+
)
|
|
78
|
+
return (
|
|
79
|
+
<GestureContext.Provider value={handlers}>
|
|
80
|
+
<PanelBody {...props} />
|
|
81
|
+
</GestureContext.Provider>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function PanelBody({
|
|
19
86
|
scopeKey,
|
|
20
87
|
selection,
|
|
21
88
|
ctx,
|
|
22
89
|
readOnly = false,
|
|
23
90
|
emptyState,
|
|
91
|
+
view = "inspector",
|
|
92
|
+
title,
|
|
93
|
+
labelLayout = "column",
|
|
94
|
+
labelWidth,
|
|
24
95
|
}: PropertyPanelProps) {
|
|
25
96
|
const scope = getScope(scopeKey)
|
|
26
97
|
|
|
@@ -40,8 +111,47 @@ export function PropertyPanel({
|
|
|
40
111
|
? undefined
|
|
41
112
|
: (path: string, val: unknown) => scope.write!(path, val, selection, ctx)
|
|
42
113
|
|
|
114
|
+
// Collapsed: what a folded scope still shows, one compact row per field.
|
|
115
|
+
// The host draws the title bar that folds it. Nothing when nothing is
|
|
116
|
+
// promoted.
|
|
117
|
+
if (view === "collapsed") {
|
|
118
|
+
const fields = promotedFields(scope.schema, "collapsed")
|
|
119
|
+
if (fields.length === 0) return null
|
|
120
|
+
return (
|
|
121
|
+
<div className="px-3 py-2">
|
|
122
|
+
<CompactRows fields={fields} values={values} ctx={ctx} onChange={onChange} />
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Card: an optional heading, then the fields promoted to it, with no group
|
|
128
|
+
// chrome.
|
|
129
|
+
if (view === "card") {
|
|
130
|
+
const rows = scope.schema.groups.flatMap((g) => rowsIn(g.rows, "card"))
|
|
131
|
+
const heading = title ?? scope.schema.title
|
|
132
|
+
if (rows.length === 0 && !heading) return null
|
|
133
|
+
return (
|
|
134
|
+
<LabelLayoutContext.Provider value={labelLayout}>
|
|
135
|
+
<div className="flex flex-col" style={columnStyle(labelLayout, labelWidth, rows.flat())}>
|
|
136
|
+
{heading && (
|
|
137
|
+
<header className="border-b border-border px-3 py-2 text-xs font-semibold">
|
|
138
|
+
{heading}
|
|
139
|
+
</header>
|
|
140
|
+
)}
|
|
141
|
+
{rows.length > 0 && (
|
|
142
|
+
<div className={`flex flex-col px-3 py-3 ${rowGap(labelLayout)}`}>
|
|
143
|
+
<Rows rows={rows} values={values} ctx={ctx} onChange={onChange} view="card" />
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
</LabelLayoutContext.Provider>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const all = scope.schema.groups.flatMap((g) => rowsIn(g.rows, "inspector")).flat()
|
|
43
152
|
return (
|
|
44
|
-
<
|
|
153
|
+
<LabelLayoutContext.Provider value={labelLayout}>
|
|
154
|
+
<div className="flex flex-col" style={columnStyle(labelLayout, labelWidth, all)}>
|
|
45
155
|
{scope.schema.groups.map((group, idx) => (
|
|
46
156
|
<PropertyGroup
|
|
47
157
|
key={group.id}
|
|
@@ -53,9 +163,20 @@ export function PropertyPanel({
|
|
|
53
163
|
/>
|
|
54
164
|
))}
|
|
55
165
|
</div>
|
|
166
|
+
</LabelLayoutContext.Provider>
|
|
56
167
|
)
|
|
57
168
|
}
|
|
58
169
|
|
|
170
|
+
/** The shared label width, as a style on the panel root; nothing outside `"column"`. */
|
|
171
|
+
function columnStyle(layout: LabelLayout, width: string | undefined, fields: FieldDef[]) {
|
|
172
|
+
return layout === "column"
|
|
173
|
+
? ({ [LABEL_WIDTH_VAR]: width ?? labelColumnWidth(fields) } as React.CSSProperties)
|
|
174
|
+
: undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Space between rows: tighter in a label column, where the rows read as a table. */
|
|
178
|
+
const rowGap = (layout: LabelLayout) => (layout === "column" ? "gap-1.5" : "gap-3")
|
|
179
|
+
|
|
59
180
|
function PropertyGroup({
|
|
60
181
|
group,
|
|
61
182
|
values,
|
|
@@ -76,6 +197,9 @@ function PropertyGroup({
|
|
|
76
197
|
collapsible && Boolean(group.defaultCollapsed),
|
|
77
198
|
)
|
|
78
199
|
const bodyId = useId()
|
|
200
|
+
const layout = useLabelLayout()
|
|
201
|
+
// A closed group keeps its "collapsed" fields, as compact rows under its title.
|
|
202
|
+
const headerFields = collapsible && collapsed ? rowsIn(group.rows, "collapsed").flat() : []
|
|
79
203
|
const titleClass =
|
|
80
204
|
"text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
81
205
|
|
|
@@ -116,45 +240,98 @@ function PropertyGroup({
|
|
|
116
240
|
)}
|
|
117
241
|
</header>
|
|
118
242
|
)}
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
<FieldSlot
|
|
131
|
-
key={f.id}
|
|
132
|
-
field={f}
|
|
133
|
-
values={values}
|
|
134
|
-
ctx={ctx}
|
|
135
|
-
onChange={onChange}
|
|
136
|
-
/>
|
|
137
|
-
))}
|
|
138
|
-
</div>
|
|
139
|
-
)
|
|
140
|
-
})}
|
|
243
|
+
{headerFields.length > 0 && (
|
|
244
|
+
<CompactRows fields={headerFields} values={values} ctx={ctx} onChange={onChange} />
|
|
245
|
+
)}
|
|
246
|
+
<div id={bodyId} hidden={collapsible && collapsed} className={`flex flex-col ${rowGap(layout)}`}>
|
|
247
|
+
<Rows
|
|
248
|
+
rows={rowsIn(group.rows, "inspector")}
|
|
249
|
+
values={values}
|
|
250
|
+
ctx={ctx}
|
|
251
|
+
onChange={onChange}
|
|
252
|
+
view="inspector"
|
|
253
|
+
/>
|
|
141
254
|
</div>
|
|
142
255
|
{!isLast && <Separator className="mt-1" />}
|
|
143
256
|
</section>
|
|
144
257
|
)
|
|
145
258
|
}
|
|
146
259
|
|
|
260
|
+
/** One field per row, label and control side by side: the collapsed views. */
|
|
261
|
+
function CompactRows({
|
|
262
|
+
fields,
|
|
263
|
+
values,
|
|
264
|
+
ctx,
|
|
265
|
+
onChange,
|
|
266
|
+
}: {
|
|
267
|
+
fields: FieldDef[]
|
|
268
|
+
values: Record<string, unknown>
|
|
269
|
+
ctx: ScopeContext
|
|
270
|
+
onChange?: (path: string, val: unknown) => void
|
|
271
|
+
}) {
|
|
272
|
+
return (
|
|
273
|
+
<div className="flex flex-col gap-1.5">
|
|
274
|
+
{fields.map((f) => (
|
|
275
|
+
<FieldSlot key={f.id} field={f} values={values} ctx={ctx} onChange={onChange} view="collapsed" />
|
|
276
|
+
))}
|
|
277
|
+
</div>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function Rows({
|
|
282
|
+
rows,
|
|
283
|
+
values,
|
|
284
|
+
ctx,
|
|
285
|
+
onChange,
|
|
286
|
+
view,
|
|
287
|
+
}: {
|
|
288
|
+
rows: FieldDef[][]
|
|
289
|
+
values: Record<string, unknown>
|
|
290
|
+
ctx: ScopeContext
|
|
291
|
+
onChange?: (path: string, val: unknown) => void
|
|
292
|
+
view: PanelView
|
|
293
|
+
}) {
|
|
294
|
+
// In a label column a pair has no room for two labels: its fields take a row each.
|
|
295
|
+
const layout = useLabelLayout()
|
|
296
|
+
return rows.map((fields, idx) => (
|
|
297
|
+
<div
|
|
298
|
+
key={idx}
|
|
299
|
+
className={
|
|
300
|
+
fields.length > 1
|
|
301
|
+
? layout === "column"
|
|
302
|
+
? "contents"
|
|
303
|
+
: "grid grid-cols-2 gap-2"
|
|
304
|
+
: undefined
|
|
305
|
+
}
|
|
306
|
+
>
|
|
307
|
+
{fields.map((f) => (
|
|
308
|
+
<FieldSlot
|
|
309
|
+
key={f.id}
|
|
310
|
+
field={f}
|
|
311
|
+
values={values}
|
|
312
|
+
ctx={ctx}
|
|
313
|
+
onChange={onChange}
|
|
314
|
+
view={view}
|
|
315
|
+
/>
|
|
316
|
+
))}
|
|
317
|
+
</div>
|
|
318
|
+
))
|
|
319
|
+
}
|
|
320
|
+
|
|
147
321
|
function FieldSlot({
|
|
148
322
|
field,
|
|
149
323
|
values,
|
|
150
324
|
ctx,
|
|
151
325
|
onChange,
|
|
326
|
+
view,
|
|
152
327
|
}: {
|
|
153
328
|
field: FieldDef
|
|
154
329
|
values: Record<string, unknown>
|
|
155
330
|
ctx: ScopeContext
|
|
156
331
|
onChange?: (path: string, val: unknown) => void
|
|
332
|
+
view: PanelView
|
|
157
333
|
}) {
|
|
334
|
+
const gesture = useContext(GestureContext)
|
|
158
335
|
const renderer = getFieldRenderer(field.kind)
|
|
159
336
|
if (!renderer) {
|
|
160
337
|
console.warn("[properties] no renderer for kind:", field.kind)
|
|
@@ -181,6 +358,10 @@ function FieldSlot({
|
|
|
181
358
|
disabled,
|
|
182
359
|
onChange:
|
|
183
360
|
onChange && path !== undefined ? (val) => onChange(path, val) : undefined,
|
|
361
|
+
onGestureBegin: gesture && path !== undefined ? () => gesture.begin(path) : undefined,
|
|
362
|
+
onGestureEnd: gesture && path !== undefined ? () => gesture.end(path) : undefined,
|
|
363
|
+
onGestureCancel: gesture && path !== undefined ? () => gesture.cancel(path) : undefined,
|
|
184
364
|
ctx,
|
|
365
|
+
view,
|
|
185
366
|
})
|
|
186
367
|
}
|
package/src/types.ts
CHANGED
|
@@ -19,8 +19,34 @@ export interface BaseField {
|
|
|
19
19
|
label?: string
|
|
20
20
|
path: string
|
|
21
21
|
disabledWhen?: DisabledWhen
|
|
22
|
+
promote?: PromoteView[]
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A view a field can be promoted to, like a parm promoted to a Houdini
|
|
27
|
+
* asset's interface. `promote` lists them, each chosen on its own:
|
|
28
|
+
* `"card"` shows the field on the card; `"collapsed"` in a closed group's
|
|
29
|
+
* header and in a folded scope. Leave `promote` out and the field is
|
|
30
|
+
* inspector-only.
|
|
31
|
+
*/
|
|
32
|
+
export type PromoteView = "card" | "collapsed"
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How `PropertyPanel` draws a scope. `"inspector"` is every field in its
|
|
36
|
+
* groups; `"card"` the fields promoted to it, flat; `"collapsed"` the
|
|
37
|
+
* fields promoted to it, on one line.
|
|
38
|
+
*/
|
|
39
|
+
export type PanelView = "inspector" | "card" | "collapsed"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where a field's label sits, for the inspector and card views. `"auto"` (the default) is the
|
|
43
|
+
* original layout: number and vector fields carry their label inside the box as the scrub
|
|
44
|
+
* handle, other fields stack it above. `"column"` puts every label in one shared column to the
|
|
45
|
+
* left, so the controls start on one edge. `"stacked"` puts every label above its control.
|
|
46
|
+
* Paired rows (`[a, b]`) draw one field per row in `"column"`.
|
|
47
|
+
*/
|
|
48
|
+
export type LabelLayout = "auto" | "column" | "stacked"
|
|
49
|
+
|
|
24
50
|
/**
|
|
25
51
|
* Disables a field based on one other field's value. Exactly one operator is
|
|
26
52
|
* meant to be set; if several are, the first present in the order
|
|
@@ -74,6 +100,10 @@ export interface NumberFieldDef extends BaseField {
|
|
|
74
100
|
min?: number
|
|
75
101
|
max?: number
|
|
76
102
|
step?: number
|
|
103
|
+
/** Whole numbers only: no fractional display, and a fine-step modifier never goes finer than `step`. */
|
|
104
|
+
integer?: boolean
|
|
105
|
+
/** A unit after the number, e.g. `px` or `°`. Shown when the panel's `labelLayout` is `"column"` or `"stacked"`. */
|
|
106
|
+
suffix?: string
|
|
77
107
|
}
|
|
78
108
|
|
|
79
109
|
export interface SliderFieldDef extends BaseField {
|
|
@@ -124,6 +154,7 @@ export interface SeparatorFieldDef {
|
|
|
124
154
|
id: string
|
|
125
155
|
label?: string
|
|
126
156
|
disabledWhen?: DisabledWhen
|
|
157
|
+
promote?: PromoteView[]
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
/** Static text: a hint, a note, a sub-heading. The text is `label`. */
|
|
@@ -132,6 +163,7 @@ export interface LabelFieldDef {
|
|
|
132
163
|
id: string
|
|
133
164
|
label: string
|
|
134
165
|
disabledWhen?: DisabledWhen
|
|
166
|
+
promote?: PromoteView[]
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
export type BuiltinFieldDef =
|
|
@@ -161,6 +193,8 @@ export interface PropertyGroupDef {
|
|
|
161
193
|
|
|
162
194
|
export interface PropertySchema {
|
|
163
195
|
version: number
|
|
196
|
+
/** What the scope is called, e.g. "Filter". Heads the card; `PropertyPanel`'s `title` overrides it. */
|
|
197
|
+
title?: string
|
|
164
198
|
groups: PropertyGroupDef[]
|
|
165
199
|
}
|
|
166
200
|
|
|
@@ -177,6 +211,20 @@ export interface Scope<S = unknown, V = Record<string, unknown>> {
|
|
|
177
211
|
selection: S,
|
|
178
212
|
ctx: ScopeContext,
|
|
179
213
|
) => void
|
|
214
|
+
/**
|
|
215
|
+
* One drag as one undo step. A drag is a scrubbed number, a dragged slider
|
|
216
|
+
* thumb or an open colour picker; `begin` fires when it starts, every
|
|
217
|
+
* `write` until `end` is part of it, and `end` closes it. `cancel` fires
|
|
218
|
+
* instead of `end` when the user abandons it (Escape during a scrub): put
|
|
219
|
+
* the value back. Omit `gesture` and drags are plain repeated `write`s.
|
|
220
|
+
*/
|
|
221
|
+
gesture?: ScopeGesture<S>
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface ScopeGesture<S = unknown> {
|
|
225
|
+
begin: (path: string, selection: S, ctx: ScopeContext) => void
|
|
226
|
+
end: (path: string, selection: S, ctx: ScopeContext) => void
|
|
227
|
+
cancel?: (path: string, selection: S, ctx: ScopeContext) => void
|
|
180
228
|
}
|
|
181
229
|
|
|
182
230
|
export interface FieldRendererProps<F extends FieldDef = FieldDef> {
|
|
@@ -184,7 +232,21 @@ export interface FieldRendererProps<F extends FieldDef = FieldDef> {
|
|
|
184
232
|
value: unknown
|
|
185
233
|
disabled: boolean
|
|
186
234
|
onChange?: (next: unknown) => void
|
|
235
|
+
/**
|
|
236
|
+
* A drag began, ended, or was abandoned: see `Scope.gesture`. Set only when
|
|
237
|
+
* the scope declares `gesture` and the panel is editable. The built-in
|
|
238
|
+
* number, vector, slider and colour renderers call them around their drags;
|
|
239
|
+
* a custom renderer with a continuous control may too.
|
|
240
|
+
*/
|
|
241
|
+
onGestureBegin?: () => void
|
|
242
|
+
onGestureEnd?: () => void
|
|
243
|
+
onGestureCancel?: () => void
|
|
187
244
|
ctx: ScopeContext
|
|
245
|
+
/**
|
|
246
|
+
* The view the field is drawn in. In `"collapsed"` it shares a line with
|
|
247
|
+
* others, so built-ins draw compact. Optional: a renderer may ignore it.
|
|
248
|
+
*/
|
|
249
|
+
view?: PanelView
|
|
188
250
|
}
|
|
189
251
|
|
|
190
252
|
export type FieldRenderer<F extends FieldDef = FieldDef> = React.FC<
|