@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/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
- export function PropertyPanel({
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
- <div className="flex flex-col">
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
- <div id={bodyId} hidden={collapsible && collapsed} className="flex flex-col gap-3">
120
- {group.rows.map((row, idx) => {
121
- const fields = Array.isArray(row) ? row : [row]
122
- return (
123
- <div
124
- key={idx}
125
- className={
126
- fields.length > 1 ? "grid grid-cols-2 gap-2" : undefined
127
- }
128
- >
129
- {fields.map((f) => (
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<