@weasel-js/labkit 1.4.2 → 1.4.3
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 +20 -2
- package/dist/_dts/{CanvasStackContext-kjILVnPj.d.ts → CanvasStackContext-Dde8_1my.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
- package/dist/_dts/{frac-A4R6v4ld.d.ts → frac-B5mB8LsJ.d.ts} +7 -4
- package/dist/_dts/{index-CsU9WhjM.d.ts → index-DelFfeV0.d.ts} +15 -7
- package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
- package/dist/_dts/{types-BbXpvQa8.d.ts → types-BP2OCcpg.d.ts} +21 -3
- package/dist/_dts/types-x92Kfeme.d.ts +62 -0
- package/dist/_dts/{useTrialState-DKjqpv20.d.ts → useTrialState-D6Vb3T-g.d.ts} +7 -7
- package/dist/canvas/index.d.ts +9 -8
- package/dist/canvas/index.js +1 -2
- package/dist/chrome/index.d.ts +7 -6
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-UTOEDPNU.js → chunk-2RQPGOQF.js} +7 -6
- package/dist/chunk-2RQPGOQF.js.map +1 -0
- package/dist/{chunk-ULDW42CR.js → chunk-5TROQVQ2.js} +25 -21
- package/dist/chunk-5TROQVQ2.js.map +1 -0
- package/dist/{chunk-XKENZTNE.js → chunk-ASWKLRKJ.js} +65 -49
- package/dist/chunk-ASWKLRKJ.js.map +1 -0
- package/dist/{chunk-W2FJR5FF.js → chunk-C42T6DJT.js} +74 -76
- package/dist/chunk-C42T6DJT.js.map +1 -0
- package/dist/chunk-E44SU6XS.js +50 -0
- package/dist/chunk-E44SU6XS.js.map +1 -0
- package/dist/{chunk-WS6ZRV75.js → chunk-FQMJUVHJ.js} +3 -3
- package/dist/{chunk-WS6ZRV75.js.map → chunk-FQMJUVHJ.js.map} +1 -1
- package/dist/{chunk-FJG4PHTL.js → chunk-I6JCVE24.js} +3 -3
- package/dist/chunk-I6JCVE24.js.map +1 -0
- package/dist/{chunk-NSOVI3AZ.js → chunk-ISCOFAUO.js} +6 -6
- package/dist/{chunk-NSOVI3AZ.js.map → chunk-ISCOFAUO.js.map} +1 -1
- package/dist/chunk-MHAGC6VD.js +7320 -0
- package/dist/chunk-MHAGC6VD.js.map +1 -0
- package/dist/{chunk-UDXOYZEC.js → chunk-QAGAHJKZ.js} +4 -4
- package/dist/{chunk-UDXOYZEC.js.map → chunk-QAGAHJKZ.js.map} +1 -1
- package/dist/{chunk-TN7YSJVU.js → chunk-RS3HRQWU.js} +3 -3
- package/dist/{chunk-TN7YSJVU.js.map → chunk-RS3HRQWU.js.map} +1 -1
- package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
- package/dist/{chunk-D5KQ5OY6.js.map → chunk-TJ7QY3OC.js.map} +1 -1
- package/dist/controls/index.d.ts +5 -4
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +6 -5
- package/dist/dragdrop/index.js +1 -2
- package/dist/index.d.ts +90 -23
- package/dist/index.js +199 -117
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +7 -6
- package/dist/layers/index.js +2 -3
- package/dist/loupe/index.d.ts +8 -7
- package/dist/loupe/index.js +1 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -3
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-canvas.js.map +1 -1
- package/dist/passthrough/weasel-ui.d.ts +88 -20
- package/dist/passthrough/weasel-ui.js +1 -2
- package/dist/primitives/index.js +3 -4
- package/dist/state/index.d.ts +6 -3
- package/dist/state/index.js +3 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +2 -2
- package/dist/surface/index.js +1 -2
- package/dist/ui/layers/index.js +1 -2
- package/dist/undo/index.d.ts +6 -6
- package/package.json +7 -7
- package/src/annotations/ExportMenu.test.tsx +16 -0
- package/src/annotations/ExportMenu.tsx +0 -5
- package/src/config/builder.test.ts +56 -2
- package/src/config/builder.ts +73 -13
- package/src/config/index.ts +16 -1
- package/src/config/path.test.ts +109 -0
- package/src/config/path.ts +76 -0
- package/src/config/resolve.test.ts +93 -2
- package/src/config/resolve.ts +72 -29
- package/src/config/types.ts +99 -9
- package/src/config/visible.ts +6 -4
- package/src/controls/ControlPanel.stories.tsx +38 -4
- package/src/controls/ControlPanel.test.tsx +114 -0
- package/src/controls/ControlPanel.tsx +99 -50
- package/src/index.ts +16 -1
- package/src/instrument/serializers.ts +36 -0
- package/src/instrument/types.ts +4 -2
- package/src/lab/Lab.test.tsx +29 -1
- package/src/lab/Lab.tsx +10 -1
- package/src/lab/LabShell.test.tsx +8 -0
- package/src/lab/LabShell.tsx +3 -1
- package/src/primitives/ZoomControl.less +6 -2
- package/src/primitives/ZoomControl.tsx +1 -0
- package/src/state/helpers.ts +1 -1
- package/src/state/store.test.ts +155 -1
- package/src/state/store.ts +30 -24
- package/src/state/types.ts +24 -3
- package/src/state/useTrialState.ts +1 -1
- package/src/trial/Trial.config.test.tsx +44 -0
- package/src/trial/Trial.tsx +3 -3
- package/src/trial/TrialChrome.tsx +10 -5
- package/src/trial/trialOps.ts +4 -8
- package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
- package/dist/chunk-54ZWZ5FQ.js +0 -29997
- package/dist/chunk-54ZWZ5FQ.js.map +0 -1
- package/dist/chunk-FJG4PHTL.js.map +0 -1
- package/dist/chunk-ISSVF5PT.js +0 -7263
- package/dist/chunk-ISSVF5PT.js.map +0 -1
- package/dist/chunk-ULDW42CR.js.map +0 -1
- package/dist/chunk-UTOEDPNU.js.map +0 -1
- package/dist/chunk-W2FJR5FF.js.map +0 -1
- package/dist/chunk-XKENZTNE.js.map +0 -1
package/src/config/types.ts
CHANGED
|
@@ -39,9 +39,11 @@ export type LeafPatch = Annotations & { kind?: string };
|
|
|
39
39
|
|
|
40
40
|
/** What a rule is given for the leaf it is deciding about. */
|
|
41
41
|
export interface ConfigRuleContext {
|
|
42
|
-
/** The
|
|
42
|
+
/** The leaf's own key — its last path segment, and what a label is titled
|
|
43
|
+
* from. */
|
|
43
44
|
key: string;
|
|
44
|
-
/** Dotted path within the schema
|
|
45
|
+
/** Dotted path within the schema, which is the path the value is written
|
|
46
|
+
* at. Equal to `key` for a leaf sitting at the root. */
|
|
45
47
|
path: string;
|
|
46
48
|
/** The leaf's default value. A rule may read it but never change it. */
|
|
47
49
|
default: unknown;
|
|
@@ -57,17 +59,29 @@ export interface ConfigRuleContext {
|
|
|
57
59
|
*/
|
|
58
60
|
export type ConfigRule = (ctx: ConfigRuleContext) => LeafPatch | null;
|
|
59
61
|
|
|
60
|
-
/** A presentational bucket of
|
|
62
|
+
/** A presentational bucket of nodes, rendered under one heading. Buckets
|
|
63
|
+
* siblings: a section never nests a value, which is what separates it from
|
|
64
|
+
* `f.group`. */
|
|
61
65
|
export interface SectionSpec {
|
|
66
|
+
/** Dotted path of the group whose children this buckets. `''` is the root,
|
|
67
|
+
* which is where every section of a flat schema sits. */
|
|
68
|
+
at: string;
|
|
62
69
|
label: string;
|
|
70
|
+
/** Full dotted paths, so a section under a group names its children the way
|
|
71
|
+
* everything else does. */
|
|
63
72
|
paths: readonly string[];
|
|
73
|
+
/** Whether the section opens folded. Set, the panel folds this section
|
|
74
|
+
* whether or not it was given a panel-wide `collapse`; a fold the reader
|
|
75
|
+
* has since toggled outranks it. */
|
|
76
|
+
collapsed?: boolean;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
/** A schema resolved against a set of rules: the vocabulary weasel-ui renders,
|
|
67
80
|
* plus the three things labkit keeps on the side because `PrefLeaf` has no
|
|
68
81
|
* field for them. */
|
|
69
82
|
export interface ResolvedConfig {
|
|
70
|
-
/**
|
|
83
|
+
/** The schema as a `PrefGroup` tree: an `f.group` is a nested group, so a
|
|
84
|
+
* leaf's dotted path within it is the path its value is written at. */
|
|
71
85
|
group: PrefGroup;
|
|
72
86
|
sections: readonly SectionSpec[];
|
|
73
87
|
showIf: ReadonlyMap<string, (config: Record<string, unknown>) => boolean>;
|
|
@@ -75,14 +89,34 @@ export interface ResolvedConfig {
|
|
|
75
89
|
renderers: Readonly<Record<string, ControlRenderer>>;
|
|
76
90
|
}
|
|
77
91
|
|
|
92
|
+
/** How a node names the section it belongs to: the heading, and how that
|
|
93
|
+
* section opens. Every node in one section repeats the heading; only one of
|
|
94
|
+
* them has to say `collapsed`. */
|
|
95
|
+
export interface SectionOption {
|
|
96
|
+
label: string;
|
|
97
|
+
collapsed?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
/** Per-node extras that do not belong on a `PrefLeaf`. */
|
|
79
|
-
export interface NodeOptions {
|
|
80
|
-
section?: string;
|
|
81
|
-
showIf?: (config: Record<string, unknown>) => boolean;
|
|
101
|
+
export interface NodeOptions extends BranchOptions {
|
|
82
102
|
render?: ControlRenderer;
|
|
83
103
|
validate?: (leaf: PrefLeaf) => string[];
|
|
84
104
|
}
|
|
85
105
|
|
|
106
|
+
/** What a branch can say about itself, beyond its children. */
|
|
107
|
+
export interface BranchOptions {
|
|
108
|
+
section?: SectionOption;
|
|
109
|
+
/** Show this node only while the predicate holds. On a group it hides the
|
|
110
|
+
* whole subtree; the values stay in config either way. */
|
|
111
|
+
showIf?: (config: Record<string, unknown>) => boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A group's own annotations: what it is called and, optionally, why. */
|
|
115
|
+
export interface BranchAnnotations {
|
|
116
|
+
name?: string;
|
|
117
|
+
description?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
/** The builder's leaf: a kind (or null, to be decided by rules), a default,
|
|
87
121
|
* an annotation bag, and the extras above. */
|
|
88
122
|
export interface ConfigNode<T = unknown> {
|
|
@@ -92,15 +126,71 @@ export interface ConfigNode<T = unknown> {
|
|
|
92
126
|
readonly options: Readonly<NodeOptions>;
|
|
93
127
|
}
|
|
94
128
|
|
|
129
|
+
/** The builder's branch: named children, nested as deeply as the schema
|
|
130
|
+
* wants. A branch nests the value too — `grid: f.group({ size })` puts the
|
|
131
|
+
* value at `grid.size`, where `.section('Grid')` would have left it at
|
|
132
|
+
* `size`. */
|
|
133
|
+
export interface ConfigBranch<S extends ConfigShape = ConfigShape> {
|
|
134
|
+
readonly children: S;
|
|
135
|
+
readonly annotations: Readonly<BranchAnnotations>;
|
|
136
|
+
readonly options: Readonly<BranchOptions>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Either half of a schema tree. */
|
|
140
|
+
export type ConfigEntry = ConfigNode | ConfigBranch;
|
|
141
|
+
|
|
142
|
+
/** The children of a schema or a group. */
|
|
143
|
+
export type ConfigShape = { readonly [key: string]: ConfigEntry };
|
|
144
|
+
|
|
95
145
|
/** The value type a node produces. */
|
|
96
146
|
export type NodeValue<N> = N extends ConfigNode<infer T> ? T : never;
|
|
97
147
|
|
|
148
|
+
/** The value type a schema entry produces — a leaf's own, or a branch's
|
|
149
|
+
* nested record. */
|
|
150
|
+
export type EntryValue<E> =
|
|
151
|
+
E extends ConfigBranch<infer S> ? InferConfig<S> : E extends ConfigNode<infer T> ? T : never;
|
|
152
|
+
|
|
98
153
|
/** The config type a builder shape produces. */
|
|
99
|
-
export type InferConfig<S> = { [K in keyof S]:
|
|
154
|
+
export type InferConfig<S> = { [K in keyof S]: EntryValue<S[K]> };
|
|
155
|
+
|
|
156
|
+
/** Whether a config value has children a path can descend into. Arrays and
|
|
157
|
+
* functions are values, not branches. */
|
|
158
|
+
type Branching<V> = V extends readonly unknown[]
|
|
159
|
+
? false
|
|
160
|
+
: V extends (...args: never[]) => unknown
|
|
161
|
+
? false
|
|
162
|
+
: V extends object
|
|
163
|
+
? true
|
|
164
|
+
: false;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every dotted path a config offers, a group's own path included. A config
|
|
168
|
+
* whose shape is not known — `unknown`, or a bare record — gives `string`,
|
|
169
|
+
* which is what keeps a generic instrument writable.
|
|
170
|
+
*/
|
|
171
|
+
export type ConfigPath<T> = unknown extends T
|
|
172
|
+
? string
|
|
173
|
+
: T extends object
|
|
174
|
+
? {
|
|
175
|
+
[K in keyof T & string]: Branching<T[K]> extends true ? K | `${K}.${ConfigPath<T[K]>}` : K;
|
|
176
|
+
}[keyof T & string]
|
|
177
|
+
: never;
|
|
178
|
+
|
|
179
|
+
/** The type at a dotted path, or `unknown` where the path is not one the
|
|
180
|
+
* config's type spells out. */
|
|
181
|
+
export type ValueAtPath<T, P extends string> = unknown extends T
|
|
182
|
+
? unknown
|
|
183
|
+
: P extends `${infer Head}.${infer Rest}`
|
|
184
|
+
? Head extends keyof T
|
|
185
|
+
? ValueAtPath<T[Head], Rest>
|
|
186
|
+
: unknown
|
|
187
|
+
: P extends keyof T
|
|
188
|
+
? T[P]
|
|
189
|
+
: unknown;
|
|
100
190
|
|
|
101
191
|
/** An instrument's config, declared once. */
|
|
102
192
|
export interface ConfigSchema<TC> {
|
|
103
|
-
readonly nodes:
|
|
193
|
+
readonly nodes: ConfigShape;
|
|
104
194
|
/** The starting config — what `defaultConfig()` would have returned. */
|
|
105
195
|
defaults(): TC;
|
|
106
196
|
}
|
package/src/config/visible.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { schemaNodeAtPath } from './path';
|
|
1
2
|
import type { ResolvedConfig } from './types';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Whether a
|
|
5
|
+
* Whether the row at a dotted path should be drawn right now. A group answers
|
|
6
|
+
* for its whole subtree — hiding one hides everything under it.
|
|
5
7
|
*
|
|
6
|
-
* `hidden` is the
|
|
8
|
+
* `hidden` is the node's own static flag; `showIf` is the schema's predicate
|
|
7
9
|
* over the live config. Both are presentational — a hidden leaf keeps its
|
|
8
10
|
* value and the instrument still reads it.
|
|
9
11
|
*/
|
|
@@ -13,8 +15,8 @@ export function isLeafVisible(
|
|
|
13
15
|
config: Record<string, unknown>,
|
|
14
16
|
showHidden = false,
|
|
15
17
|
): boolean {
|
|
16
|
-
const
|
|
17
|
-
if (
|
|
18
|
+
const node = schemaNodeAtPath(resolved.group, path);
|
|
19
|
+
if (node && 'hidden' in node && node.hidden === true && !showHidden) return false;
|
|
18
20
|
const predicate = resolved.showIf.get(path);
|
|
19
21
|
return predicate ? predicate(config) : true;
|
|
20
22
|
}
|
|
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
|
2
2
|
import { PropertyRow } from '@weasel-js/ui';
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { f } from '../config/builder';
|
|
5
|
+
import { withValueAtPath } from '../config/path';
|
|
5
6
|
import { resolveConfigSchema } from '../config/resolve';
|
|
6
7
|
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
7
8
|
import { ControlPanel } from './ControlPanel';
|
|
@@ -60,7 +61,7 @@ function Harness({ fields }: { fields: ConfigField[] }) {
|
|
|
60
61
|
<ControlPanel
|
|
61
62
|
fields={fields}
|
|
62
63
|
config={config}
|
|
63
|
-
setConfig={(
|
|
64
|
+
setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
|
|
64
65
|
/>
|
|
65
66
|
);
|
|
66
67
|
}
|
|
@@ -102,7 +103,7 @@ function SchemaHarness({
|
|
|
102
103
|
<ControlPanel
|
|
103
104
|
schema={schema}
|
|
104
105
|
config={config}
|
|
105
|
-
setConfig={(
|
|
106
|
+
setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
|
|
106
107
|
renderers={renderers}
|
|
107
108
|
/>
|
|
108
109
|
);
|
|
@@ -169,7 +170,7 @@ export const Described: Story = {
|
|
|
169
170
|
<ControlPanel
|
|
170
171
|
schema={schema}
|
|
171
172
|
config={config}
|
|
172
|
-
setConfig={(
|
|
173
|
+
setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
|
|
173
174
|
/>
|
|
174
175
|
);
|
|
175
176
|
},
|
|
@@ -195,8 +196,41 @@ export const Conditional: Story = {
|
|
|
195
196
|
<ControlPanel
|
|
196
197
|
schema={schema}
|
|
197
198
|
config={config}
|
|
198
|
-
setConfig={(
|
|
199
|
+
setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
|
|
199
200
|
/>
|
|
200
201
|
);
|
|
201
202
|
},
|
|
202
203
|
};
|
|
204
|
+
|
|
205
|
+
const nested = f.schema({
|
|
206
|
+
showGrid: f.boolean(true),
|
|
207
|
+
grid: f
|
|
208
|
+
.group({
|
|
209
|
+
size: f.number(20).range(5, 80).step(5).label('Cell size'),
|
|
210
|
+
color: f.color('#3a86ff'),
|
|
211
|
+
})
|
|
212
|
+
.describe('Written at config.grid.size and config.grid.color.'),
|
|
213
|
+
export: f.group({
|
|
214
|
+
format: f.enum('png', ['png', 'svg']),
|
|
215
|
+
scale: f.number(2).range(1, 4).step(1),
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** `f.group` nests the value as well as the heading: these rows write to
|
|
220
|
+
* `grid.size` and `export.scale`, not to `size` and `scale`. */
|
|
221
|
+
export const Nested: Story = {
|
|
222
|
+
render: () => {
|
|
223
|
+
const schema = resolveConfigSchema(nested, []);
|
|
224
|
+
const [config, setConfig] = useState<Record<string, unknown>>(nested.defaults());
|
|
225
|
+
return (
|
|
226
|
+
<>
|
|
227
|
+
<ControlPanel
|
|
228
|
+
schema={schema}
|
|
229
|
+
config={config}
|
|
230
|
+
setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
|
|
231
|
+
/>
|
|
232
|
+
<pre>{JSON.stringify(config, null, 2)}</pre>
|
|
233
|
+
</>
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
};
|
|
@@ -480,4 +480,118 @@ describe('<ControlPanel> collapsible sections', () => {
|
|
|
480
480
|
expect(onCollapse).toHaveBeenCalledWith('Advanced', false);
|
|
481
481
|
expect(screen.getByLabelText('Seed')).not.toBeVisible();
|
|
482
482
|
});
|
|
483
|
+
|
|
484
|
+
describe('a section the schema declares closed', () => {
|
|
485
|
+
const closedSchema = resolveConfigSchema(
|
|
486
|
+
f.schema({
|
|
487
|
+
showGrid: f.boolean(true),
|
|
488
|
+
seed: f.number(0).section('Advanced', { collapsed: true }),
|
|
489
|
+
}),
|
|
490
|
+
[],
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
it('opens folded, and is foldable, with no collapse props at all', () => {
|
|
494
|
+
render(<ControlPanel schema={closedSchema} config={config} setConfig={vi.fn()} />);
|
|
495
|
+
expect(screen.getByLabelText('Seed')).not.toBeVisible();
|
|
496
|
+
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }));
|
|
497
|
+
expect(screen.getByLabelText('Seed')).toBeVisible();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('yields to a remembered fold, so a toggle the reader made survives', () => {
|
|
501
|
+
render(
|
|
502
|
+
<ControlPanel
|
|
503
|
+
schema={closedSchema}
|
|
504
|
+
config={config}
|
|
505
|
+
setConfig={vi.fn()}
|
|
506
|
+
collapsed={{ Advanced: false }}
|
|
507
|
+
onCollapse={vi.fn()}
|
|
508
|
+
/>,
|
|
509
|
+
);
|
|
510
|
+
expect(screen.getByLabelText('Seed')).toBeVisible();
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
describe('<ControlPanel> nested groups', () => {
|
|
516
|
+
const nested = () =>
|
|
517
|
+
resolveConfigSchema(
|
|
518
|
+
f.schema({
|
|
519
|
+
showGrid: f.boolean(true),
|
|
520
|
+
grid: f.group({ size: f.number(20).label('Cell size'), color: f.color('#ffffff') }),
|
|
521
|
+
}),
|
|
522
|
+
[],
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
it('renders a nested group under its own heading', () => {
|
|
526
|
+
render(
|
|
527
|
+
<ControlPanel
|
|
528
|
+
schema={nested()}
|
|
529
|
+
config={{ showGrid: true, grid: { size: 20, color: '#ffffff' } }}
|
|
530
|
+
setConfig={vi.fn()}
|
|
531
|
+
/>,
|
|
532
|
+
);
|
|
533
|
+
const group = screen.getByRole('heading', { name: 'Grid' }).parentElement;
|
|
534
|
+
expect(group?.textContent).toContain('Cell size');
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('reads a nested leaf value out of the config tree', () => {
|
|
538
|
+
render(
|
|
539
|
+
<ControlPanel
|
|
540
|
+
schema={nested()}
|
|
541
|
+
config={{ showGrid: true, grid: { size: 44, color: '#ffffff' } }}
|
|
542
|
+
setConfig={vi.fn()}
|
|
543
|
+
/>,
|
|
544
|
+
);
|
|
545
|
+
expect((screen.getByLabelText('Cell size') as HTMLInputElement).value).toBe('44');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('falls back to the leaf default when the branch is missing', () => {
|
|
549
|
+
render(<ControlPanel schema={nested()} config={{ showGrid: true }} setConfig={vi.fn()} />);
|
|
550
|
+
expect((screen.getByLabelText('Cell size') as HTMLInputElement).value).toBe('20');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('writes back with the full dotted path', () => {
|
|
554
|
+
const setConfig = vi.fn();
|
|
555
|
+
render(
|
|
556
|
+
<ControlPanel
|
|
557
|
+
schema={nested()}
|
|
558
|
+
config={{ showGrid: true, grid: { size: 20, color: '#ffffff' } }}
|
|
559
|
+
setConfig={setConfig}
|
|
560
|
+
/>,
|
|
561
|
+
);
|
|
562
|
+
fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
|
|
563
|
+
expect(setConfig).toHaveBeenCalledWith('grid.size', 40);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('hides a whole group whose showIf is false', () => {
|
|
567
|
+
const schema = resolveConfigSchema(
|
|
568
|
+
f.schema({
|
|
569
|
+
showGrid: f.boolean(true),
|
|
570
|
+
grid: f.group({ size: f.number(20).label('Cell size') }).showIf((c) => c.showGrid === true),
|
|
571
|
+
}),
|
|
572
|
+
[],
|
|
573
|
+
);
|
|
574
|
+
render(
|
|
575
|
+
<ControlPanel
|
|
576
|
+
schema={schema}
|
|
577
|
+
config={{ showGrid: false, grid: { size: 20 } }}
|
|
578
|
+
setConfig={vi.fn()}
|
|
579
|
+
/>,
|
|
580
|
+
);
|
|
581
|
+
expect(screen.queryByLabelText('Cell size')).not.toBeInTheDocument();
|
|
582
|
+
expect(screen.queryByRole('heading', { name: 'Grid' })).not.toBeInTheDocument();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('takes a renderer keyed by the nested path', () => {
|
|
586
|
+
const schema = nested();
|
|
587
|
+
render(
|
|
588
|
+
<ControlPanel
|
|
589
|
+
schema={schema}
|
|
590
|
+
config={{ showGrid: true, grid: { size: 44, color: '#ffffff' } }}
|
|
591
|
+
setConfig={vi.fn()}
|
|
592
|
+
renderers={{ 'grid.size': (ctx) => <span>size:{String(ctx.value)}</span> }}
|
|
593
|
+
/>,
|
|
594
|
+
);
|
|
595
|
+
expect(screen.getByText('size:44')).toBeInTheDocument();
|
|
596
|
+
});
|
|
483
597
|
});
|
|
@@ -2,12 +2,15 @@ import { isBuiltinToolPref } from '@weasel-js/core';
|
|
|
2
2
|
import {
|
|
3
3
|
CheckboxRow,
|
|
4
4
|
ColorRow,
|
|
5
|
+
isPrefLeaf,
|
|
5
6
|
NumberRow,
|
|
7
|
+
type PrefGroup,
|
|
6
8
|
type PrefLeaf,
|
|
7
9
|
type PropertyAlign,
|
|
8
10
|
type PropertyDensity,
|
|
9
11
|
PropertyGroup,
|
|
10
12
|
PropertyList,
|
|
13
|
+
type PropertyListPack,
|
|
11
14
|
PropertyRow,
|
|
12
15
|
type PropertyRowLayout,
|
|
13
16
|
SelectRow,
|
|
@@ -15,9 +18,10 @@ import {
|
|
|
15
18
|
TextRow,
|
|
16
19
|
ToggleRow,
|
|
17
20
|
} from '@weasel-js/ui';
|
|
18
|
-
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
21
|
+
import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
19
22
|
import { fromConfigFields } from '../config/fromConfigField';
|
|
20
|
-
import
|
|
23
|
+
import { schemaNodeAtPath, valueAtPath } from '../config/path';
|
|
24
|
+
import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
|
|
21
25
|
import { isLeafVisible } from '../config/visible';
|
|
22
26
|
import type { ConfigField } from './types';
|
|
23
27
|
|
|
@@ -37,7 +41,9 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
|
37
41
|
/** @deprecated Pass `schema`. A field list is adapted into one internally. */
|
|
38
42
|
fields?: ConfigField[];
|
|
39
43
|
config: TC;
|
|
40
|
-
|
|
44
|
+
/** Writes one value. The path is dotted for a leaf inside an `f.group`, and
|
|
45
|
+
* the bare key for one at the root. */
|
|
46
|
+
setConfig: (path: string, value: unknown) => void;
|
|
41
47
|
/**
|
|
42
48
|
* Control overrides and app-defined kinds, PrefsForm-style. Keys are config
|
|
43
49
|
* paths (checked first) or leaf kinds. A renderer returning `null` collapses
|
|
@@ -58,16 +64,21 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
|
58
64
|
align?: PropertyAlign;
|
|
59
65
|
/**
|
|
60
66
|
* Fold each section away behind a twisty, starting `'open'` or `'closed'`.
|
|
61
|
-
* Unset draws the heading alone, as the panel always has
|
|
67
|
+
* Unset draws the heading alone, as the panel always has — unless a section
|
|
68
|
+
* in the schema declares how it opens, which makes that section foldable on
|
|
69
|
+
* its own and outranks this for that one section.
|
|
62
70
|
*/
|
|
63
71
|
collapse?: 'open' | 'closed';
|
|
64
72
|
/**
|
|
65
|
-
* Which sections are folded, keyed
|
|
66
|
-
* no state of its own: every toggle arrives at `onCollapse`
|
|
67
|
-
* is where a lab that remembers a trial's sections writes
|
|
73
|
+
* Which sections are folded, keyed as `onCollapse` reports them. Given, the
|
|
74
|
+
* panel keeps no state of its own: every toggle arrives at `onCollapse`
|
|
75
|
+
* instead, which is where a lab that remembers a trial's sections writes
|
|
76
|
+
* them.
|
|
68
77
|
*/
|
|
69
78
|
collapsed?: Readonly<Record<string, boolean>>;
|
|
70
|
-
|
|
79
|
+
/** A fold moved. The key is a section's label — prefixed by its group's
|
|
80
|
+
* dotted path when the section sits inside one — or a group's own path. */
|
|
81
|
+
onCollapse?: (key: string, collapsed: boolean) => void;
|
|
71
82
|
/** Draw leaves marked `hidden`. */
|
|
72
83
|
showHidden?: boolean;
|
|
73
84
|
className?: string;
|
|
@@ -94,27 +105,75 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
94
105
|
}: ControlPanelProps<TC>) {
|
|
95
106
|
const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
|
|
96
107
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
const gridPack: PropertyListPack = pack === 'one-up' ? 'auto-color' : 'pairs';
|
|
109
|
+
// A section that declares how it opens is foldable whether or not the lab
|
|
110
|
+
// asked for folds — there is nothing else for the declaration to mean.
|
|
111
|
+
const folds =
|
|
112
|
+
collapse !== undefined ||
|
|
113
|
+
collapsed !== undefined ||
|
|
114
|
+
onCollapse !== undefined ||
|
|
115
|
+
resolved.sections.some((s) => s.collapsed !== undefined);
|
|
116
|
+
const startsFolded = collapse === 'closed';
|
|
100
117
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
const fold = (key: string, declared?: boolean) => ({
|
|
119
|
+
pack: gridPack,
|
|
120
|
+
collapsible: folds,
|
|
121
|
+
defaultCollapsed: declared ?? startsFolded,
|
|
122
|
+
collapsed: collapsed ? (collapsed[key] ?? declared ?? startsFolded) : undefined,
|
|
123
|
+
onCollapsedChange: onCollapse ? (next: boolean) => onCollapse(key, next) : undefined,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** One node, which is either a group to recurse into or a row to draw. */
|
|
127
|
+
const node = (path: string): ReactNode => {
|
|
128
|
+
const found = schemaNodeAtPath(resolved.group, path);
|
|
129
|
+
if (!found) return null;
|
|
130
|
+
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
|
|
131
|
+
if (isPrefLeaf(found)) {
|
|
132
|
+
return (
|
|
133
|
+
<ControlRow
|
|
134
|
+
key={path}
|
|
135
|
+
path={path}
|
|
136
|
+
leaf={found}
|
|
137
|
+
resolved={resolved}
|
|
138
|
+
config={config}
|
|
139
|
+
setConfig={setConfig}
|
|
140
|
+
renderers={renderers}
|
|
141
|
+
pack={pack}
|
|
142
|
+
layout={layout}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
// A group with no name organizes without heading it — core's rule for an
|
|
147
|
+
// empty `PrefGroup.name` — so it contributes its rows and no chrome.
|
|
148
|
+
if (found.name === '') return <Fragment key={path}>{body(found, path)}</Fragment>;
|
|
149
|
+
return (
|
|
150
|
+
<PropertyGroup key={path} title={found.name} {...fold(path)}>
|
|
151
|
+
{body(found, path)}
|
|
152
|
+
</PropertyGroup>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** One group's children: its loose nodes, then its sections. */
|
|
157
|
+
const body = (group: PrefGroup, at: string): ReactNode => {
|
|
158
|
+
const sections = resolved.sections.filter((s) => s.at === at);
|
|
159
|
+
const sectioned = new Set(sections.flatMap((s) => s.paths));
|
|
160
|
+
const paths = Object.keys(group.children).map((key) => (at === '' ? key : `${at}.${key}`));
|
|
161
|
+
return (
|
|
162
|
+
<>
|
|
163
|
+
{paths.filter((p) => !sectioned.has(p)).map(node)}
|
|
164
|
+
{sections.map((section) => (
|
|
165
|
+
<PropertyGroup
|
|
166
|
+
key={sectionKey(section)}
|
|
167
|
+
title={section.label}
|
|
168
|
+
{...fold(sectionKey(section), section.collapsed)}
|
|
169
|
+
>
|
|
170
|
+
{section.paths.map(node)}
|
|
171
|
+
</PropertyGroup>
|
|
172
|
+
))}
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
114
176
|
|
|
115
|
-
const gridPack = pack === 'one-up' ? 'auto-color' : 'pairs';
|
|
116
|
-
const folds = collapse !== undefined || collapsed !== undefined || onCollapse !== undefined;
|
|
117
|
-
const startsFolded = collapse === 'closed';
|
|
118
177
|
return (
|
|
119
178
|
<PropertyList
|
|
120
179
|
pack={gridPack}
|
|
@@ -122,33 +181,27 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
122
181
|
align={align}
|
|
123
182
|
className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
|
|
124
183
|
>
|
|
125
|
-
{
|
|
126
|
-
{resolved.sections.map((section) => (
|
|
127
|
-
<PropertyGroup
|
|
128
|
-
key={section.label}
|
|
129
|
-
title={section.label}
|
|
130
|
-
pack={gridPack}
|
|
131
|
-
collapsible={folds}
|
|
132
|
-
defaultCollapsed={startsFolded}
|
|
133
|
-
collapsed={collapsed ? (collapsed[section.label] ?? startsFolded) : undefined}
|
|
134
|
-
onCollapsedChange={onCollapse ? (next) => onCollapse(section.label, next) : undefined}
|
|
135
|
-
>
|
|
136
|
-
{section.paths.map(row)}
|
|
137
|
-
</PropertyGroup>
|
|
138
|
-
))}
|
|
184
|
+
{body(resolved.group, '')}
|
|
139
185
|
</PropertyList>
|
|
140
186
|
);
|
|
141
187
|
}
|
|
142
188
|
|
|
189
|
+
/** How a section is keyed for folding: its label at the root, and its group's
|
|
190
|
+
* path in front of it anywhere else, so two groups may both have an
|
|
191
|
+
* `Advanced`. */
|
|
192
|
+
function sectionKey(section: SectionSpec): string {
|
|
193
|
+
return section.at === '' ? section.label : `${section.at}.${section.label}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
interface ControlRowProps<TC extends Record<string, unknown>> {
|
|
144
197
|
path: string;
|
|
198
|
+
leaf: PrefLeaf;
|
|
145
199
|
resolved: ResolvedConfig;
|
|
146
200
|
config: TC;
|
|
147
|
-
setConfig: (
|
|
201
|
+
setConfig: (path: string, value: unknown) => void;
|
|
148
202
|
renderers?: Record<string, ControlRenderer>;
|
|
149
203
|
pack: ControlPack;
|
|
150
204
|
layout?: PropertyRowLayout;
|
|
151
|
-
showHidden: boolean;
|
|
152
205
|
}
|
|
153
206
|
|
|
154
207
|
/** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
|
|
@@ -159,21 +212,17 @@ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
|
|
|
159
212
|
|
|
160
213
|
function ControlRow<TC extends Record<string, unknown>>({
|
|
161
214
|
path,
|
|
215
|
+
leaf,
|
|
162
216
|
resolved,
|
|
163
217
|
config,
|
|
164
218
|
setConfig,
|
|
165
219
|
renderers,
|
|
166
220
|
pack,
|
|
167
221
|
layout,
|
|
168
|
-
showHidden,
|
|
169
222
|
}: ControlRowProps<TC>) {
|
|
170
|
-
const
|
|
171
|
-
if (!leaf || !('kind' in leaf)) return null;
|
|
172
|
-
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
|
|
173
|
-
|
|
174
|
-
const write = (value: unknown): void => setConfig(path as keyof TC, value);
|
|
223
|
+
const write = (value: unknown): void => setConfig(path, value);
|
|
175
224
|
const fallback = extra<unknown>(leaf, 'default');
|
|
176
|
-
const value = config
|
|
225
|
+
const value = valueAtPath(config, path) ?? fallback;
|
|
177
226
|
|
|
178
227
|
// Most specific wins, and within a tier the lab's entry beats the
|
|
179
228
|
// instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
|
package/src/index.ts
CHANGED
|
@@ -145,25 +145,40 @@ export {
|
|
|
145
145
|
zoomAt,
|
|
146
146
|
} from './canvas';
|
|
147
147
|
export * from './chrome';
|
|
148
|
-
export { f } from './config/builder';
|
|
148
|
+
export { f, isConfigBranch } from './config/builder';
|
|
149
149
|
export { fromConfigFields } from './config/fromConfigField';
|
|
150
|
+
export {
|
|
151
|
+
fillConfigDefaults,
|
|
152
|
+
hasConfigPath,
|
|
153
|
+
schemaNodeAtPath,
|
|
154
|
+
valueAtPath,
|
|
155
|
+
withValueAtPath,
|
|
156
|
+
} from './config/path';
|
|
150
157
|
export { resolveConfigSchema } from './config/resolve';
|
|
151
158
|
export { applyRules, builtinRules, titleCase } from './config/rules';
|
|
152
159
|
export type {
|
|
153
160
|
Annotations,
|
|
161
|
+
BranchAnnotations,
|
|
162
|
+
BranchOptions,
|
|
163
|
+
ConfigBranch,
|
|
164
|
+
ConfigEntry,
|
|
154
165
|
ConfigNode,
|
|
155
166
|
ConfigOf,
|
|
156
167
|
ConfigOption,
|
|
168
|
+
ConfigPath,
|
|
157
169
|
ConfigRule,
|
|
158
170
|
ConfigRuleContext,
|
|
159
171
|
ConfigSchema,
|
|
172
|
+
ConfigShape,
|
|
160
173
|
ControlRenderer,
|
|
174
|
+
EntryValue,
|
|
161
175
|
InferConfig,
|
|
162
176
|
LeafPatch,
|
|
163
177
|
NodeOptions,
|
|
164
178
|
NodeValue,
|
|
165
179
|
ResolvedConfig,
|
|
166
180
|
SectionSpec,
|
|
181
|
+
ValueAtPath,
|
|
167
182
|
} from './config/types';
|
|
168
183
|
export { useConfigSchema } from './config/useConfigSchema';
|
|
169
184
|
export { isLeafVisible } from './config/visible';
|