@weasel-js/labkit 1.4.2 → 1.4.4
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-kTEZvEgE.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-z7ker2Vx.d.ts} +11 -7
- package/dist/_dts/{index-CsU9WhjM.d.ts → index-Blv9uQzu.d.ts} +42 -9
- 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-ULDW42CR.js → chunk-2P6PP5N4.js} +33 -23
- package/dist/chunk-2P6PP5N4.js.map +1 -0
- package/dist/{chunk-W2FJR5FF.js → chunk-64ZCN3DA.js} +74 -76
- package/dist/chunk-64ZCN3DA.js.map +1 -0
- package/dist/{chunk-NSOVI3AZ.js → chunk-BKFVHKJH.js} +6 -6
- package/dist/{chunk-NSOVI3AZ.js.map → chunk-BKFVHKJH.js.map} +1 -1
- 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-UDXOYZEC.js → chunk-MOM3GOVY.js} +4 -4
- package/dist/{chunk-UDXOYZEC.js.map → chunk-MOM3GOVY.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-UTOEDPNU.js → chunk-SMHP6XZ4.js} +7 -6
- package/dist/chunk-SMHP6XZ4.js.map +1 -0
- package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
- package/dist/{chunk-D5KQ5OY6.js.map → chunk-TJ7QY3OC.js.map} +1 -1
- package/dist/{chunk-XKENZTNE.js → chunk-U3IYHIAE.js} +71 -55
- package/dist/chunk-U3IYHIAE.js.map +1 -0
- package/dist/chunk-W3ECWC2K.js +7335 -0
- package/dist/chunk-W3ECWC2K.js.map +1 -0
- 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 +139 -26
- package/dist/index.js +309 -144
- 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 +98 -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 +82 -15
- 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/annotations/frac.test.ts +4 -4
- package/src/annotations/frac.ts +3 -2
- package/src/annotations/index.ts +1 -1
- package/src/annotations/store.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -2
- 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 +17 -2
- 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 +17 -3
- package/src/lab/LabSwitcher.less +85 -0
- package/src/lab/LabSwitcher.test.tsx +96 -0
- package/src/lab/LabSwitcher.tsx +112 -0
- package/src/lab/index.ts +2 -0
- package/src/primitives/ZoomControl.less +6 -2
- package/src/primitives/ZoomControl.tsx +1 -0
- package/src/state/helpers.test.ts +20 -0
- package/src/state/helpers.ts +17 -1
- package/src/state/store.test.ts +155 -1
- package/src/state/store.ts +32 -26
- package/src/state/types.ts +24 -3
- package/src/state/useTrialState.ts +1 -1
- package/src/styles.less +1 -0
- package/src/theme/base.less +4 -4
- 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 +7 -10
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/labkit",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "React widgets for building self-contained interactive lab pages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "orochi235",
|
|
@@ -110,15 +110,15 @@
|
|
|
110
110
|
"dev:annotate": "LABKIT_EXAMPLE=annotate-lab vite"
|
|
111
111
|
},
|
|
112
112
|
"peerDependencies": {
|
|
113
|
+
"@weasel-js/core": "1.4.4",
|
|
113
114
|
"react": "^19.0.0",
|
|
114
115
|
"react-dom": "^19.0.0"
|
|
115
116
|
},
|
|
116
117
|
"dependencies": {
|
|
117
|
-
"@weasel-js/
|
|
118
|
-
"@weasel-js/
|
|
119
|
-
"@weasel-js/
|
|
120
|
-
"@weasel-js/
|
|
121
|
-
"@weasel-js/ui": "1.4.2",
|
|
118
|
+
"@weasel-js/loupe": "1.4.4",
|
|
119
|
+
"@weasel-js/svg": "1.4.4",
|
|
120
|
+
"@weasel-js/theme": "1.4.4",
|
|
121
|
+
"@weasel-js/ui": "1.4.4",
|
|
122
122
|
"earcut": "2.2.4",
|
|
123
123
|
"polygon-clipping": "^0.15.7",
|
|
124
124
|
"react-aria-components": "^1.5.0",
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
"@biomejs/biome": "^2.4.12",
|
|
130
130
|
"@rollup/plugin-alias": "^6.0.0",
|
|
131
131
|
"@testing-library/jest-dom": "^6.6.0",
|
|
132
|
-
"@weasel-js/modes": "1.4.
|
|
132
|
+
"@weasel-js/modes": "1.4.4",
|
|
133
133
|
"less": "^4.2.0",
|
|
134
134
|
"rollup-plugin-dts": "^6.4.1",
|
|
135
135
|
"tsx": "^4.19.0",
|
|
@@ -75,6 +75,22 @@ describe('<ExportMenu>', () => {
|
|
|
75
75
|
);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
// jsdom resolves neither var() nor color-mix(), so where the panel mounted
|
|
79
|
+
// is the proxy for whether it reads the lab's tokens at all.
|
|
80
|
+
it('opens inside the lab root, not on the body', () => {
|
|
81
|
+
const { store } = spied();
|
|
82
|
+
render(
|
|
83
|
+
<div className="lk-root" data-wzl-portal-host="" data-testid="lk-root">
|
|
84
|
+
<AnnotationsContext.Provider value={store}>
|
|
85
|
+
<ExportMenu />
|
|
86
|
+
</AnnotationsContext.Provider>
|
|
87
|
+
</div>,
|
|
88
|
+
);
|
|
89
|
+
open();
|
|
90
|
+
const panel = document.querySelector('.lk-export__panel');
|
|
91
|
+
expect(panel?.closest('[data-testid="lk-root"]')).toBe(screen.getByTestId('lk-root'));
|
|
92
|
+
});
|
|
93
|
+
|
|
78
94
|
it('says so when a capture fails rather than doing nothing visible', async () => {
|
|
79
95
|
const { store, capture } = spied();
|
|
80
96
|
capture.mockRejectedValueOnce(new Error('the capture canvas is tainted'));
|
|
@@ -51,10 +51,6 @@ async function copy(result: CaptureResult): Promise<void> {
|
|
|
51
51
|
*/
|
|
52
52
|
export function ExportMenu() {
|
|
53
53
|
const marks = useAnnotationsOptional();
|
|
54
|
-
// The anchor doubles as the portal target lookup: a React Aria popover
|
|
55
|
-
// portals to `document.body` by default, which is outside the element
|
|
56
|
-
// labkit paints its theme tokens onto — `--wzl-surface` does not exist
|
|
57
|
-
// there and the panel renders unthemed.
|
|
58
54
|
const [anchor, setAnchor] = useState<HTMLSpanElement | null>(null);
|
|
59
55
|
const [open, setOpen] = useState(false);
|
|
60
56
|
const [target, setTarget] = useState<string | null>(null);
|
|
@@ -99,7 +95,6 @@ export function ExportMenu() {
|
|
|
99
95
|
onOpenChange={setOpen}
|
|
100
96
|
onDismiss={() => setOpen(false)}
|
|
101
97
|
placement="bottom end"
|
|
102
|
-
UNSTABLE_portalContainer={anchor?.closest('.lk-root') ?? undefined}
|
|
103
98
|
aria-labelledby={titleId}
|
|
104
99
|
>
|
|
105
100
|
<div className="lk-export__panel">
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { fracContains,
|
|
2
|
+
import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
|
|
3
3
|
import type { FracRect } from './types';
|
|
4
4
|
|
|
5
5
|
const CONTENT = { w: 256, h: 170 };
|
|
@@ -51,9 +51,9 @@ describe('fraction hit geometry', () => {
|
|
|
51
51
|
expect(fracContains(box, { x: 0.18, y: 0.3 }, 0.05)).toBe(true);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it('
|
|
55
|
-
expect(
|
|
54
|
+
it('encloses only a box that lies wholly inside', () => {
|
|
55
|
+
expect(fracEncloses({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
|
|
56
56
|
// Overlapping is not containing: a marquee takes what it encloses.
|
|
57
|
-
expect(
|
|
57
|
+
expect(fracEncloses({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
|
|
58
58
|
});
|
|
59
59
|
});
|
package/src/annotations/frac.ts
CHANGED
|
@@ -55,8 +55,9 @@ export function fracContains(box: FracRect, pt: FracPoint, tol = 0): boolean {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Whether `outer` wholly encloses `inner`. A marquee takes what it encloses,
|
|
58
|
-
* not what it grazes — brushing selection is a different gesture
|
|
59
|
-
|
|
58
|
+
* not what it grazes — brushing selection is a different gesture, and this
|
|
59
|
+
* answers false for two rects that merely overlap. */
|
|
60
|
+
export function fracEncloses(outer: FracRect, inner: FracRect): boolean {
|
|
60
61
|
return (
|
|
61
62
|
inner.x >= outer.x &&
|
|
62
63
|
inner.y >= outer.y &&
|
package/src/annotations/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type { MarkDrawOptions } from './drawOne';
|
|
|
4
4
|
export { createMarkDrawOne, resolveMarkStyle } from './drawOne';
|
|
5
5
|
export { ExportMenu } from './ExportMenu';
|
|
6
6
|
export type { WorldRect } from './frac';
|
|
7
|
-
export { fracContains,
|
|
7
|
+
export { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
|
|
8
8
|
export type { HistoryScene } from './history';
|
|
9
9
|
export { MarkHistory } from './history';
|
|
10
10
|
export type { MarkListProps } from './MarkList';
|
package/src/annotations/store.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from '@weasel-js/core';
|
|
8
8
|
import { captureTarget } from './capture';
|
|
9
9
|
import type { WorldRect } from './frac';
|
|
10
|
-
import { fracContains,
|
|
10
|
+
import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
|
|
11
11
|
import { MarkHistory } from './history';
|
|
12
12
|
import { isStale as isStaleAgainst, seenFrom } from './staleness';
|
|
13
13
|
import type {
|
|
@@ -197,7 +197,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
|
|
|
197
197
|
},
|
|
198
198
|
|
|
199
199
|
within(target, box: FracRect) {
|
|
200
|
-
return marksOn(target).filter((a) =>
|
|
200
|
+
return marksOn(target).filter((a) => fracEncloses(box, a.frac));
|
|
201
201
|
},
|
|
202
202
|
|
|
203
203
|
isStale(a, config) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { DrawCommand, FillStyle, Stroke } from '@weasel-js/core';
|
|
2
|
-
import { resolveStrokeWidth } from '@weasel-js/core';
|
|
3
2
|
import type { SvgNode, SvgPaint, SvgStroke } from '@weasel-js/svg';
|
|
4
3
|
import { type MarkStyle, markCommands, type PaintableMark } from './paint';
|
|
5
4
|
|
|
@@ -23,7 +22,7 @@ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
|
|
|
23
22
|
function toSvgStroke(stroke: Stroke): SvgStroke {
|
|
24
23
|
return {
|
|
25
24
|
paint: toSvgPaint(stroke.paint),
|
|
26
|
-
width:
|
|
25
|
+
width: stroke.width ?? 1,
|
|
27
26
|
...(stroke.cap ? { cap: stroke.cap } : {}),
|
|
28
27
|
...(stroke.join ? { join: stroke.join } : {}),
|
|
29
28
|
...(stroke.dash ? { dash: [...stroke.dash] } : {}),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { f } from './builder';
|
|
3
|
+
import type { ConfigOf, ConfigPath, ValueAtPath } from './types';
|
|
3
4
|
|
|
4
5
|
describe('builder', () => {
|
|
5
6
|
it('carries kind and default', () => {
|
|
@@ -63,7 +64,7 @@ describe('builder', () => {
|
|
|
63
64
|
.section('Advanced')
|
|
64
65
|
.showIf((c) => c.showGrid === true)
|
|
65
66
|
.render(() => null);
|
|
66
|
-
expect(n.options.section).
|
|
67
|
+
expect(n.options.section).toEqual({ label: 'Advanced' });
|
|
67
68
|
expect(n.options.showIf?.({ showGrid: true })).toBe(true);
|
|
68
69
|
expect(n.options.render).toBeTypeOf('function');
|
|
69
70
|
expect(n.annotations).toEqual({});
|
|
@@ -91,8 +92,61 @@ describe('builder / node option collisions', () => {
|
|
|
91
92
|
];
|
|
92
93
|
for (const node of nodes) {
|
|
93
94
|
expect(typeof node.options).toBe('object');
|
|
94
|
-
expect(node.section('S').options.section).
|
|
95
|
+
expect(node.section('S').options.section).toEqual({ label: 'S' });
|
|
95
96
|
expect(node.render(() => null).options.render).toBeTypeOf('function');
|
|
96
97
|
}
|
|
97
98
|
});
|
|
98
99
|
});
|
|
100
|
+
|
|
101
|
+
describe('builder / groups', () => {
|
|
102
|
+
it('f.group nests its children under the key', () => {
|
|
103
|
+
const s = f.schema({
|
|
104
|
+
showGrid: f.boolean(true),
|
|
105
|
+
grid: f.group({ size: f.number(20), color: f.color('#fff') }),
|
|
106
|
+
});
|
|
107
|
+
expect(s.defaults()).toEqual({
|
|
108
|
+
showGrid: true,
|
|
109
|
+
grid: { size: 20, color: '#fff' },
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('nests groups within groups', () => {
|
|
114
|
+
const s = f.schema({ a: f.group({ b: f.group({ c: f.number(1) }) }) });
|
|
115
|
+
expect(s.defaults()).toEqual({ a: { b: { c: 1 } } });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('chains a group label and description immutably', () => {
|
|
119
|
+
const base = f.group({ size: f.number(20) });
|
|
120
|
+
const a = base.label('Grid').describe('How the grid is drawn');
|
|
121
|
+
expect(a.annotations.name).toBe('Grid');
|
|
122
|
+
expect(a.annotations.description).toBe('How the grid is drawn');
|
|
123
|
+
expect(base.annotations.name).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('holds a group section and showIf off the annotation bag', () => {
|
|
127
|
+
const g = f
|
|
128
|
+
.group({ size: f.number(20) })
|
|
129
|
+
.section('Advanced')
|
|
130
|
+
.showIf((c) => c.showGrid === true);
|
|
131
|
+
expect(g.options.section).toEqual({ label: 'Advanced' });
|
|
132
|
+
expect(g.options.showIf?.({ showGrid: true })).toBe(true);
|
|
133
|
+
expect(g.annotations).toEqual({});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('builder / nested types', () => {
|
|
138
|
+
// Compile-level: these annotations are the assertion, and tsc is what runs
|
|
139
|
+
// it. A flat `InferConfig` would not give `c.grid.size` a type at all.
|
|
140
|
+
it('infers a nested config type and the paths into it', () => {
|
|
141
|
+
const schema = f.schema({
|
|
142
|
+
showGrid: f.boolean(true),
|
|
143
|
+
grid: f.group({ size: f.number(20), color: f.color('#fff') }),
|
|
144
|
+
});
|
|
145
|
+
type Config = ConfigOf<typeof schema>;
|
|
146
|
+
const c: Config = schema.defaults();
|
|
147
|
+
const size: number = c.grid.size;
|
|
148
|
+
const path: ConfigPath<Config> = 'grid.size';
|
|
149
|
+
const value: ValueAtPath<Config, 'grid.size'> = 40;
|
|
150
|
+
expect([size, path, value]).toEqual([20, 'grid.size', 40]);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/config/builder.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { PrefLeaf } from '@weasel-js/ui';
|
|
2
2
|
import type {
|
|
3
3
|
Annotations,
|
|
4
|
+
BranchAnnotations,
|
|
5
|
+
BranchOptions,
|
|
6
|
+
ConfigBranch,
|
|
7
|
+
ConfigEntry,
|
|
4
8
|
ConfigNode,
|
|
5
9
|
ConfigOption,
|
|
6
10
|
ConfigSchema,
|
|
11
|
+
ConfigShape,
|
|
7
12
|
ControlRenderer,
|
|
8
13
|
InferConfig,
|
|
9
14
|
NodeOptions,
|
|
10
15
|
} from './types';
|
|
11
16
|
|
|
17
|
+
/** Whether a schema entry is a branch rather than a leaf. */
|
|
18
|
+
export function isConfigBranch(entry: ConfigEntry): entry is ConfigBranch {
|
|
19
|
+
return 'children' in entry;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/** Shared chaining surface. Every method clones, so a node can be reused as a
|
|
13
23
|
* base for several leaves without one bleeding into the next. */
|
|
14
24
|
abstract class BaseNode<T> implements ConfigNode<T> {
|
|
@@ -61,9 +71,10 @@ abstract class BaseNode<T> implements ConfigNode<T> {
|
|
|
61
71
|
return this.ann({ pair });
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
/** Render under a named section heading.
|
|
65
|
-
section
|
|
66
|
-
|
|
74
|
+
/** Render under a named section heading. `collapsed` opens the section
|
|
75
|
+
* folded — say it on any one of the section's leaves. */
|
|
76
|
+
section(label: string, opts: { collapsed?: boolean } = {}): this {
|
|
77
|
+
return this.opt({ section: { label, ...opts } });
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
/** Show this row only while the predicate holds. Presentational — the value
|
|
@@ -174,6 +185,55 @@ class CustomNode<T> extends BaseNode<T> {
|
|
|
174
185
|
}
|
|
175
186
|
}
|
|
176
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A branch of the schema: its children nest under its key, so a leaf inside
|
|
190
|
+
* one is read and written at a dotted path. Distinct from `.section()`, which
|
|
191
|
+
* puts a heading over sibling leaves and leaves their paths alone.
|
|
192
|
+
*/
|
|
193
|
+
class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
|
|
194
|
+
constructor(
|
|
195
|
+
readonly children: S,
|
|
196
|
+
readonly annotations: Readonly<BranchAnnotations> = {},
|
|
197
|
+
readonly options: Readonly<BranchOptions> = {},
|
|
198
|
+
) {}
|
|
199
|
+
|
|
200
|
+
private with(annotations: BranchAnnotations, options: BranchOptions): GroupNode<S> {
|
|
201
|
+
return new GroupNode(this.children, annotations, options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Heading for the group's rows. Defaults to the key, title-cased. */
|
|
205
|
+
label(name: string): GroupNode<S> {
|
|
206
|
+
return this.with({ ...this.annotations, name }, this.options);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Longer help text. Carried on the resolved `PrefGroup`, where weasel-ui's
|
|
210
|
+
* `PrefsForm` draws it under the heading; `ControlPanel` has no place for
|
|
211
|
+
* it yet and shows the heading alone. */
|
|
212
|
+
describe(description: string): GroupNode<S> {
|
|
213
|
+
return this.with({ ...this.annotations, description }, this.options);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Render this whole group under a named section heading. */
|
|
217
|
+
section(label: string, opts: { collapsed?: boolean } = {}): GroupNode<S> {
|
|
218
|
+
return this.with(this.annotations, { ...this.options, section: { label, ...opts } });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Show this group only while the predicate holds. Presentational — every
|
|
222
|
+
* value beneath it stays in config and the instrument still reads it. */
|
|
223
|
+
showIf(predicate: (config: Record<string, unknown>) => boolean): GroupNode<S> {
|
|
224
|
+
return this.with(this.annotations, { ...this.options, showIf: predicate });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Every default in a shape, nested the way the shape is. */
|
|
229
|
+
function shapeDefaults(shape: ConfigShape): Record<string, unknown> {
|
|
230
|
+
const out: Record<string, unknown> = {};
|
|
231
|
+
for (const [key, entry] of Object.entries(shape)) {
|
|
232
|
+
out[key] = isConfigBranch(entry) ? shapeDefaults(entry.children) : entry.default;
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
177
237
|
const expand = <T extends string>(
|
|
178
238
|
options: readonly T[] | readonly ConfigOption[],
|
|
179
239
|
): readonly ConfigOption[] =>
|
|
@@ -187,6 +247,8 @@ const expand = <T extends string>(
|
|
|
187
247
|
* const config = f.schema({
|
|
188
248
|
* showGrid: f.boolean(true),
|
|
189
249
|
* cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
|
|
250
|
+
* // Nested: read and written at `grid.color`.
|
|
251
|
+
* grid: f.group({ color: f.color('#ffffff') }),
|
|
190
252
|
* })
|
|
191
253
|
* ```
|
|
192
254
|
*/
|
|
@@ -208,19 +270,17 @@ export const f = {
|
|
|
208
270
|
* states a convention like "every `*Color` key is a color picker". */
|
|
209
271
|
value: <T>(def: T): ValueNode<T> => new ValueNode(def),
|
|
210
272
|
|
|
273
|
+
/** A branch: its children nest under this key, so `grid: f.group({ size })`
|
|
274
|
+
* puts the value at `config.grid.size` and addresses it as `'grid.size'`.
|
|
275
|
+
* Groups nest as deeply as the schema wants. */
|
|
276
|
+
group: <const S extends ConfigShape>(children: S): GroupNode<S> => new GroupNode(children),
|
|
277
|
+
|
|
211
278
|
/** A leaf of a kind a lab supplies the control for, through `controls`. */
|
|
212
279
|
custom: <T>(kind: string, def: T, validate?: (leaf: PrefLeaf) => string[]): CustomNode<T> =>
|
|
213
280
|
new CustomNode(kind, def, {}, validate ? { validate } : {}),
|
|
214
281
|
|
|
215
|
-
/** Collect leaves into an instrument's config. */
|
|
216
|
-
schema<S extends
|
|
217
|
-
return {
|
|
218
|
-
nodes,
|
|
219
|
-
defaults: () => {
|
|
220
|
-
const out: Record<string, unknown> = {};
|
|
221
|
-
for (const [key, node] of Object.entries(nodes)) out[key] = node.default;
|
|
222
|
-
return out as InferConfig<S>;
|
|
223
|
-
},
|
|
224
|
-
};
|
|
282
|
+
/** Collect leaves and groups into an instrument's config. */
|
|
283
|
+
schema<S extends ConfigShape>(nodes: S): ConfigSchema<InferConfig<S>> {
|
|
284
|
+
return { nodes, defaults: () => shapeDefaults(nodes) as InferConfig<S> };
|
|
225
285
|
},
|
|
226
286
|
};
|
package/src/config/index.ts
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
|
-
export { f } from './builder';
|
|
1
|
+
export { f, isConfigBranch } from './builder';
|
|
2
2
|
export { fromConfigFields } from './fromConfigField';
|
|
3
|
+
export {
|
|
4
|
+
fillConfigDefaults,
|
|
5
|
+
hasConfigPath,
|
|
6
|
+
schemaNodeAtPath,
|
|
7
|
+
valueAtPath,
|
|
8
|
+
withValueAtPath,
|
|
9
|
+
} from './path';
|
|
3
10
|
export { resolveConfigSchema } from './resolve';
|
|
4
11
|
export { applyRules, builtinRules, titleCase } from './rules';
|
|
5
12
|
export type {
|
|
6
13
|
Annotations,
|
|
14
|
+
BranchAnnotations,
|
|
15
|
+
BranchOptions,
|
|
16
|
+
ConfigBranch,
|
|
17
|
+
ConfigEntry,
|
|
7
18
|
ConfigNode,
|
|
8
19
|
ConfigOf,
|
|
9
20
|
ConfigOption,
|
|
21
|
+
ConfigPath,
|
|
10
22
|
ConfigRule,
|
|
11
23
|
ConfigRuleContext,
|
|
12
24
|
ConfigSchema,
|
|
25
|
+
ConfigShape,
|
|
13
26
|
ControlRenderer,
|
|
27
|
+
EntryValue,
|
|
14
28
|
InferConfig,
|
|
15
29
|
LeafPatch,
|
|
16
30
|
NodeOptions,
|
|
17
31
|
NodeValue,
|
|
18
32
|
ResolvedConfig,
|
|
19
33
|
SectionSpec,
|
|
34
|
+
ValueAtPath,
|
|
20
35
|
} from './types';
|
|
21
36
|
export { useConfigSchema } from './useConfigSchema';
|
|
22
37
|
export { isLeafVisible } from './visible';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { fillConfigDefaults, hasConfigPath, valueAtPath, withValueAtPath } from './path';
|
|
3
|
+
|
|
4
|
+
describe('valueAtPath', () => {
|
|
5
|
+
it('reads a single segment', () => {
|
|
6
|
+
expect(valueAtPath({ a: 1 }, 'a')).toBe(1);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('reads down a nested path', () => {
|
|
10
|
+
expect(valueAtPath({ grid: { size: 20 } }, 'grid.size')).toBe(20);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns undefined for a missing segment', () => {
|
|
14
|
+
expect(valueAtPath({ grid: {} }, 'grid.size')).toBeUndefined();
|
|
15
|
+
expect(valueAtPath({}, 'grid.size')).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns undefined when the walk hits a non-object', () => {
|
|
19
|
+
expect(valueAtPath({ grid: 3 }, 'grid.size')).toBeUndefined();
|
|
20
|
+
expect(valueAtPath(null, 'a')).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('hasConfigPath', () => {
|
|
25
|
+
it('tells a missing key from one holding undefined', () => {
|
|
26
|
+
expect(hasConfigPath({ a: undefined }, 'a')).toBe(true);
|
|
27
|
+
expect(hasConfigPath({}, 'a')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('walks a nested path', () => {
|
|
31
|
+
expect(hasConfigPath({ grid: { size: 20 } }, 'grid.size')).toBe(true);
|
|
32
|
+
expect(hasConfigPath({ grid: { size: 20 } }, 'grid.color')).toBe(false);
|
|
33
|
+
expect(hasConfigPath({ grid: 3 }, 'grid.size')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('withValueAtPath', () => {
|
|
38
|
+
it('writes a single segment without touching the input', () => {
|
|
39
|
+
const before = { a: 1, b: 2 };
|
|
40
|
+
const after = withValueAtPath(before, 'a', 99);
|
|
41
|
+
expect(after).toEqual({ a: 99, b: 2 });
|
|
42
|
+
expect(before).toEqual({ a: 1, b: 2 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('copies every object on the way down', () => {
|
|
46
|
+
const before = { grid: { size: 20, color: '#fff' }, other: { keep: true } };
|
|
47
|
+
const after = withValueAtPath(before, 'grid.size', 40);
|
|
48
|
+
expect(after.grid).toEqual({ size: 40, color: '#fff' });
|
|
49
|
+
expect(before.grid.size).toBe(20);
|
|
50
|
+
expect(after.grid).not.toBe(before.grid);
|
|
51
|
+
// Untouched branches keep their identity, so a memo keyed on one holds.
|
|
52
|
+
expect(after.other).toBe(before.other);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('creates a missing intermediate branch', () => {
|
|
56
|
+
expect(withValueAtPath({} as Record<string, unknown>, 'grid.size', 40)).toEqual({
|
|
57
|
+
grid: { size: 40 },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('replaces a non-object standing where a branch belongs', () => {
|
|
62
|
+
expect(withValueAtPath({ grid: 3 } as Record<string, unknown>, 'grid.size', 40)).toEqual({
|
|
63
|
+
grid: { size: 40 },
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('fillConfigDefaults', () => {
|
|
69
|
+
it('takes the stored value wherever it has one', () => {
|
|
70
|
+
expect(fillConfigDefaults({ a: 1 }, { a: 0, b: 2 })).toEqual({ a: 1, b: 2 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('fills a whole branch a stored config never had', () => {
|
|
74
|
+
expect(fillConfigDefaults({ showGrid: false }, { showGrid: true, grid: { size: 20 } })).toEqual(
|
|
75
|
+
{
|
|
76
|
+
showGrid: false,
|
|
77
|
+
grid: { size: 20 },
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('fills a gap inside a branch the stored config half-holds', () => {
|
|
83
|
+
expect(
|
|
84
|
+
fillConfigDefaults({ grid: { size: 40 } }, { grid: { size: 20, color: '#fff' } }),
|
|
85
|
+
).toEqual({ grid: { size: 40, color: '#fff' } });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('keeps a key the defaults no longer mention', () => {
|
|
89
|
+
expect(fillConfigDefaults({ gridSize: 40 }, { grid: { size: 20 } })).toEqual({
|
|
90
|
+
gridSize: 40,
|
|
91
|
+
grid: { size: 20 },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('leaves the stored config untouched', () => {
|
|
96
|
+
const stored = { grid: { size: 40 } };
|
|
97
|
+
fillConfigDefaults(stored, { grid: { size: 20, color: '#fff' } });
|
|
98
|
+
expect(stored).toEqual({ grid: { size: 40 } });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('takes a stored array whole rather than merging it element-wise', () => {
|
|
102
|
+
expect(fillConfigDefaults({ tags: ['a'] }, { tags: ['x', 'y'] })).toEqual({ tags: ['a'] });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('fills from defaults when the stored config is not a record', () => {
|
|
106
|
+
expect(fillConfigDefaults(undefined, { a: 1 })).toEqual({ a: 1 });
|
|
107
|
+
expect(fillConfigDefaults(7, { a: 1 })).toEqual({ a: 1 });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reading and writing a config tree by dotted path. A schema's leaves address
|
|
5
|
+
* their values this way, so a leaf nested under `f.group` and a flat one are
|
|
6
|
+
* reached by exactly the same call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The value at a dotted path. `undefined` when a segment is missing or the
|
|
14
|
+
* walk hits something that is not a record. */
|
|
15
|
+
export function valueAtPath(config: unknown, path: string): unknown {
|
|
16
|
+
let cur = config;
|
|
17
|
+
for (const segment of path.split('.')) {
|
|
18
|
+
if (!isRecord(cur)) return undefined;
|
|
19
|
+
cur = cur[segment];
|
|
20
|
+
}
|
|
21
|
+
return cur;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a dotted path is actually present, which a `undefined` value is
|
|
25
|
+
* not enough to tell you. */
|
|
26
|
+
export function hasConfigPath(config: unknown, path: string): boolean {
|
|
27
|
+
let cur = config;
|
|
28
|
+
for (const segment of path.split('.')) {
|
|
29
|
+
if (!isRecord(cur) || !(segment in cur)) return false;
|
|
30
|
+
cur = cur[segment];
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeIn(node: unknown, segments: readonly string[], value: unknown): unknown {
|
|
36
|
+
const [head, ...rest] = segments;
|
|
37
|
+
if (head === undefined) return value;
|
|
38
|
+
const base = isRecord(node) ? node : {};
|
|
39
|
+
return { ...base, [head]: rest.length === 0 ? value : writeIn(base[head], rest, value) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A copy of `config` with `value` written at a dotted path. Every record on
|
|
43
|
+
* the way down is copied and the input is left alone; a missing — or
|
|
44
|
+
* non-record — intermediate is replaced with the branch the path implies. */
|
|
45
|
+
export function withValueAtPath<T>(config: T, path: string, value: unknown): T {
|
|
46
|
+
return writeIn(config, path.split('.'), value) as T;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `stored` with every gap filled from `defaults`, down the whole tree.
|
|
51
|
+
*
|
|
52
|
+
* This is how a config written before its schema grew a branch still loads:
|
|
53
|
+
* the branch arrives at its defaults instead of `undefined`, and a key the
|
|
54
|
+
* defaults no longer mention is kept rather than dropped, so nothing is lost
|
|
55
|
+
* to a schema change the author has not finished making.
|
|
56
|
+
*/
|
|
57
|
+
export function fillConfigDefaults<T>(stored: unknown, defaults: T): T {
|
|
58
|
+
if (!isRecord(defaults)) return (stored === undefined ? defaults : stored) as T;
|
|
59
|
+
const base = isRecord(stored) ? stored : {};
|
|
60
|
+
const out: Record<string, unknown> = { ...base };
|
|
61
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
62
|
+
out[key] = fillConfigDefaults(base[key], value);
|
|
63
|
+
}
|
|
64
|
+
return out as T;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A node in a resolved schema tree, by dotted path. Structural rather than
|
|
68
|
+
* `isPrefLeaf` so this stays free of a runtime import. */
|
|
69
|
+
export function schemaNodeAtPath(group: PrefGroup, path: string): PrefLeaf | PrefGroup | undefined {
|
|
70
|
+
let node: PrefLeaf | PrefGroup | undefined = group;
|
|
71
|
+
for (const segment of path.split('.')) {
|
|
72
|
+
if (node === undefined || !('children' in node)) return undefined;
|
|
73
|
+
node = node.children[segment];
|
|
74
|
+
}
|
|
75
|
+
return node;
|
|
76
|
+
}
|