@weasel-js/labkit 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-D7EQnnwJ.d.ts → useTrialState-DYe2vUwN.d.ts} +60 -36
- package/dist/canvas/index.d.ts +1 -1
- package/dist/chunk-3TYUJR7Z.js +488 -0
- package/dist/chunk-3TYUJR7Z.js.map +1 -0
- package/dist/{chunk-BAPZPDDA.js → chunk-C6GJKPUI.js} +365 -340
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-DJLDIRFN.js} +28 -12
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-NRKWVTVT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-NRKWVTVT.js.map} +1 -1
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +149 -149
- package/dist/index.js +164 -159
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +9 -2
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +12 -12
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +21 -13
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/canvas/AGENTS.md +6 -2
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +52 -0
- package/src/index.ts +11 -7
- package/src/instrument/SineWave.smoke.test.tsx +1 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +18 -6
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +5 -5
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +50 -50
- package/src/lab/LabContext.ts +12 -12
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
- package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +18 -18
- package/src/lab/index.ts +2 -2
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +21 -29
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -41
- package/src/state/store.ts +159 -102
- package/src/state/types.ts +29 -12
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/styles.less +2 -2
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
- package/src/{workspace → trial}/index.ts +13 -13
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/chunk-BAPZPDDA.js.map +0 -1
- package/dist/chunk-DWV7SFKR.js.map +0 -1
- package/dist/chunk-KSTEW2AF.js +0 -367
- package/dist/chunk-KSTEW2AF.js.map +0 -1
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/workspaceOps.ts +0 -102
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -41,50 +41,50 @@ function mountLab(props: Partial<Parameters<typeof Lab>[0]> = {}) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe('<Lab>', () => {
|
|
44
|
-
it('renders one
|
|
44
|
+
it('renders one trial by default', () => {
|
|
45
45
|
mountLab();
|
|
46
|
-
expect(screen.getAllByRole('region', { name: /
|
|
46
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
it('
|
|
49
|
+
it('addTrial adds a second trial', () => {
|
|
50
50
|
mountLab();
|
|
51
|
-
act(() => labRef?.
|
|
52
|
-
expect(screen.getAllByRole('region', { name: /
|
|
51
|
+
act(() => labRef?.addTrial('Stub'));
|
|
52
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it('
|
|
55
|
+
it('closeTrial removes one when more than one exists', () => {
|
|
56
56
|
mountLab();
|
|
57
|
-
act(() => labRef?.
|
|
58
|
-
const first = labRef?.
|
|
59
|
-
act(() => labRef?.
|
|
60
|
-
expect(screen.getAllByRole('region', { name: /
|
|
57
|
+
act(() => labRef?.addTrial('Stub'));
|
|
58
|
+
const first = labRef?.trials[0];
|
|
59
|
+
act(() => labRef?.closeTrial(first?.id ?? ''));
|
|
60
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it('
|
|
63
|
+
it('closeTrial is a no-op on the last trial', () => {
|
|
64
64
|
mountLab();
|
|
65
|
-
const only = labRef?.
|
|
66
|
-
act(() => labRef?.
|
|
67
|
-
expect(screen.getAllByRole('region', { name: /
|
|
65
|
+
const only = labRef?.trials[0];
|
|
66
|
+
act(() => labRef?.closeTrial(only?.id ?? ''));
|
|
67
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('
|
|
70
|
+
it('cloneTrial inserts immediately after source', () => {
|
|
71
71
|
mountLab();
|
|
72
|
-
act(() => labRef?.
|
|
73
|
-
const ws0 = labRef?.
|
|
74
|
-
const ws1 = labRef?.
|
|
75
|
-
act(() => labRef?.
|
|
76
|
-
const ids = labRef?.
|
|
72
|
+
act(() => labRef?.addTrial('StubB'));
|
|
73
|
+
const ws0 = labRef?.trials[0];
|
|
74
|
+
const ws1 = labRef?.trials[1];
|
|
75
|
+
act(() => labRef?.cloneTrial(ws0?.id ?? ''));
|
|
76
|
+
const ids = labRef?.trials.map((w) => w.id) ?? [];
|
|
77
77
|
expect(ids).toHaveLength(3);
|
|
78
78
|
expect(ids[0]).toBe(ws0?.id);
|
|
79
79
|
expect(ids[2]).toBe(ws1?.id);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('
|
|
82
|
+
it('resetTrial restores defaults', () => {
|
|
83
83
|
mountLab();
|
|
84
|
-
const ws = labRef?.
|
|
85
|
-
if (!ws) throw new Error('no
|
|
86
|
-
act(() => labRef?.
|
|
87
|
-
const reset = labRef?.
|
|
84
|
+
const ws = labRef?.trials[0];
|
|
85
|
+
if (!ws) throw new Error('no trial');
|
|
86
|
+
act(() => labRef?.resetTrial(ws.id));
|
|
87
|
+
const reset = labRef?.trials[0];
|
|
88
88
|
expect(reset?.config).toEqual({ count: 0 });
|
|
89
89
|
expect(reset?.state).toEqual({ value: 0 });
|
|
90
90
|
});
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
import { ThemeProvider } from '@weasel-js/theme/react';
|
|
2
2
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { useStore } from 'zustand/react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { InstrumentList } from '../instrument/types';
|
|
5
5
|
import { noneAdapter } from '../state/adapters';
|
|
6
6
|
import { LabStoreContext } from '../state/context';
|
|
7
7
|
import { createLabStore, type LabStore } from '../state/store';
|
|
8
|
-
import type { LabMode, StorageAdapter,
|
|
8
|
+
import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
|
|
9
9
|
import { interstellarTheme } from '../theme/interstellar';
|
|
10
|
-
import {
|
|
10
|
+
import { Trial } from '../trial/Trial';
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from '../
|
|
12
|
+
addTrial as addTrialOp,
|
|
13
|
+
cloneTrial as cloneTrialOp,
|
|
14
|
+
closeTrial as closeTrialOp,
|
|
15
|
+
reorderTrials as reorderTrialsOp,
|
|
16
|
+
resetTrial as resetTrialOp,
|
|
17
|
+
} from '../trial/trialOps';
|
|
18
18
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
19
19
|
import { LabShell } from './LabShell';
|
|
20
20
|
import { useResolvedMode } from './useSystemMode';
|
|
21
|
-
import {
|
|
21
|
+
import { type TrialLayout, Workspace } from './Workspace';
|
|
22
22
|
|
|
23
23
|
/** Props for `<Lab>`. */
|
|
24
24
|
export interface LabProps {
|
|
25
|
-
instruments:
|
|
25
|
+
instruments: InstrumentList;
|
|
26
26
|
defaultInstrument: string;
|
|
27
27
|
storage?: StorageAdapter | null;
|
|
28
28
|
storageKey?: string;
|
|
@@ -39,19 +39,19 @@ export interface LabProps {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function buildStore(
|
|
42
|
-
instruments:
|
|
42
|
+
instruments: InstrumentList,
|
|
43
43
|
defaultInstrument: string,
|
|
44
44
|
storage: StorageAdapter,
|
|
45
45
|
storageKey: string,
|
|
46
46
|
initialMode: LabMode,
|
|
47
47
|
): LabStore {
|
|
48
48
|
const store = createLabStore({ storageKey, storage, initialMode });
|
|
49
|
-
if (store.getState().
|
|
50
|
-
const seeded =
|
|
49
|
+
if (store.getState().trials.length === 0) {
|
|
50
|
+
const seeded = addTrialOp([], instruments, defaultInstrument);
|
|
51
51
|
const record = seeded[0];
|
|
52
52
|
if (record) {
|
|
53
53
|
const { undoStack: _undoStack, ...rest } = record;
|
|
54
|
-
store.getState().
|
|
54
|
+
store.getState().addTrial(rest);
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
return store;
|
|
@@ -71,15 +71,15 @@ const NEBULA_SLOTS = [
|
|
|
71
71
|
|
|
72
72
|
function buildNebula(colors: readonly string[]): string {
|
|
73
73
|
const blobs = colors.map((c, i) => {
|
|
74
|
-
const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length]
|
|
74
|
+
const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
|
|
75
75
|
return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
|
|
76
76
|
});
|
|
77
77
|
blobs.push('radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%)');
|
|
78
78
|
return blobs.join(', ');
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** The lab runtime: creates the store, provides it, and renders one
|
|
82
|
-
* per record in a grid. Each
|
|
81
|
+
/** The lab runtime: creates the store, provides it, and renders one trial
|
|
82
|
+
* per record in a grid. Each trial runs one of `instruments`. */
|
|
83
83
|
export function Lab({
|
|
84
84
|
instruments,
|
|
85
85
|
defaultInstrument,
|
|
@@ -106,7 +106,7 @@ export function Lab({
|
|
|
106
106
|
}
|
|
107
107
|
const store = storeRef.current;
|
|
108
108
|
|
|
109
|
-
const
|
|
109
|
+
const trials = useStore(store, (s) => s.trials);
|
|
110
110
|
const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
|
|
111
111
|
const modeValue = useStore(store, (s) => s.mode);
|
|
112
112
|
const layout = useStore(store, (s) => s.layout);
|
|
@@ -119,51 +119,51 @@ export function Lab({
|
|
|
119
119
|
}, [mode, store]);
|
|
120
120
|
|
|
121
121
|
const contextValue = useMemo<LabContextValue>(() => {
|
|
122
|
-
const
|
|
123
|
-
const currentById = new Map(store.getState().
|
|
122
|
+
const replaceTrials = (next: TrialRecord[]): void => {
|
|
123
|
+
const currentById = new Map(store.getState().trials.map((w) => [w.id, w]));
|
|
124
124
|
const merged = next.map((w) => currentById.get(w.id) ?? w);
|
|
125
|
-
store.setState({
|
|
125
|
+
store.setState({ trials: merged });
|
|
126
126
|
// Trigger persistence flush via a tracked action.
|
|
127
127
|
store.getState().setMode(store.getState().mode);
|
|
128
128
|
};
|
|
129
129
|
|
|
130
130
|
return {
|
|
131
131
|
instruments,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const next =
|
|
135
|
-
|
|
132
|
+
trials,
|
|
133
|
+
addTrial: (instrumentName) => {
|
|
134
|
+
const next = addTrialOp(store.getState().trials, instruments, instrumentName);
|
|
135
|
+
replaceTrials(next);
|
|
136
136
|
},
|
|
137
|
-
|
|
138
|
-
const next =
|
|
139
|
-
|
|
137
|
+
cloneTrial: (id) => {
|
|
138
|
+
const next = cloneTrialOp(store.getState().trials, id);
|
|
139
|
+
replaceTrials(next);
|
|
140
140
|
},
|
|
141
|
-
|
|
142
|
-
const next =
|
|
143
|
-
|
|
141
|
+
closeTrial: (id) => {
|
|
142
|
+
const next = closeTrialOp(store.getState().trials, id);
|
|
143
|
+
replaceTrials(next);
|
|
144
144
|
},
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
reorderTrials: (ids) => {
|
|
146
|
+
replaceTrials(reorderTrialsOp(store.getState().trials, ids));
|
|
147
147
|
},
|
|
148
|
-
|
|
149
|
-
const next =
|
|
148
|
+
resetTrial: (id) => {
|
|
149
|
+
const next = resetTrialOp(store.getState().trials, id, instruments);
|
|
150
150
|
const record = next.find((w) => w.id === id);
|
|
151
151
|
if (!record) return;
|
|
152
152
|
store.setState((s) => ({
|
|
153
|
-
|
|
153
|
+
trials: s.trials.map((w) =>
|
|
154
154
|
w.id === id
|
|
155
155
|
? { ...w, config: record.config, state: record.state, view: record.view }
|
|
156
156
|
: w,
|
|
157
157
|
),
|
|
158
158
|
}));
|
|
159
|
-
store.getState().
|
|
159
|
+
store.getState().updateTrialView(id, record.view);
|
|
160
160
|
},
|
|
161
161
|
savedSnapshots,
|
|
162
|
-
saveSnapshot: (
|
|
163
|
-
store.getState().saveSnapshot(
|
|
162
|
+
saveSnapshot: (trialId, name) => {
|
|
163
|
+
store.getState().saveSnapshot(trialId, name ?? `Save ${new Date().toLocaleString()}`);
|
|
164
164
|
},
|
|
165
|
-
loadSnapshot: (
|
|
166
|
-
store.getState().loadSnapshot(snapshotId,
|
|
165
|
+
loadSnapshot: (trialId, snapshotId) => {
|
|
166
|
+
store.getState().loadSnapshot(snapshotId, trialId);
|
|
167
167
|
},
|
|
168
168
|
deleteSnapshot: (snapshotId) => {
|
|
169
169
|
store.getState().deleteSnapshot(snapshotId);
|
|
@@ -173,7 +173,7 @@ export function Lab({
|
|
|
173
173
|
store.getState().setMode(m);
|
|
174
174
|
},
|
|
175
175
|
};
|
|
176
|
-
}, [instruments,
|
|
176
|
+
}, [instruments, trials, savedSnapshots, modeValue, store]);
|
|
177
177
|
|
|
178
178
|
// Only override the backdrop in dark, where there is one to override.
|
|
179
179
|
// Setting a CSS custom property is the sanctioned use of inline style.
|
|
@@ -192,18 +192,18 @@ export function Lab({
|
|
|
192
192
|
style={backdropStyle}
|
|
193
193
|
>
|
|
194
194
|
<LabShell title={title ?? 'Labkit'} mode={modeValue} header={children}>
|
|
195
|
-
<
|
|
196
|
-
ids={
|
|
195
|
+
<Workspace
|
|
196
|
+
ids={trials.map((w) => w.id)}
|
|
197
197
|
resizable
|
|
198
198
|
reorderable
|
|
199
|
-
onReorder={(ids) => contextValue.
|
|
200
|
-
layout={layout as
|
|
199
|
+
onReorder={(ids) => contextValue.reorderTrials(ids)}
|
|
200
|
+
layout={layout as TrialLayout}
|
|
201
201
|
onLayoutChange={(next) => store.getState().setLayout(next)}
|
|
202
202
|
>
|
|
203
|
-
{
|
|
204
|
-
<
|
|
203
|
+
{trials.map((w) => (
|
|
204
|
+
<Trial key={w.id} id={w.id} />
|
|
205
205
|
))}
|
|
206
|
-
</
|
|
206
|
+
</Workspace>
|
|
207
207
|
</LabShell>
|
|
208
208
|
</ThemeProvider>
|
|
209
209
|
</LabContext.Provider>
|
package/src/lab/LabContext.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
|
-
import type {
|
|
3
|
-
import type { LabMode, SavedSnapshot,
|
|
2
|
+
import type { InstrumentList } from '../instrument/types';
|
|
3
|
+
import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
|
|
4
4
|
|
|
5
5
|
/** Lab-wide state and commands: the available instruments, the open
|
|
6
|
-
*
|
|
6
|
+
* trials and the operations over them, saved snapshots, and the color
|
|
7
7
|
* mode. */
|
|
8
8
|
export interface LabContextValue {
|
|
9
|
-
instruments:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
instruments: InstrumentList;
|
|
10
|
+
trials: TrialRecord[];
|
|
11
|
+
addTrial: (instrumentName: string) => void;
|
|
12
|
+
cloneTrial: (id: string) => void;
|
|
13
|
+
closeTrial: (id: string) => void;
|
|
14
|
+
resetTrial: (id: string) => void;
|
|
15
|
+
reorderTrials: (ids: readonly string[]) => void;
|
|
16
16
|
savedSnapshots: SavedSnapshot[];
|
|
17
|
-
saveSnapshot: (
|
|
18
|
-
loadSnapshot: (
|
|
17
|
+
saveSnapshot: (trialId: string, name?: string) => void;
|
|
18
|
+
loadSnapshot: (trialId: string, snapshotId: string) => void;
|
|
19
19
|
deleteSnapshot: (snapshotId: string) => void;
|
|
20
20
|
mode: LabMode;
|
|
21
21
|
setMode: (m: LabMode) => void;
|
package/src/lab/LabShell.tsx
CHANGED
|
@@ -18,7 +18,7 @@ export interface LabShellProps {
|
|
|
18
18
|
|
|
19
19
|
/** Page frame for a lab: a titled header, a body, and an optional footer,
|
|
20
20
|
* themed for the resolved color mode. Presentational only — use `<Lab>` when
|
|
21
|
-
* the
|
|
21
|
+
* the trial runtime is wanted too. */
|
|
22
22
|
export function LabShell({ title, children, header, footer, mode = 'auto' }: LabShellProps) {
|
|
23
23
|
const resolved = useResolvedMode(mode);
|
|
24
24
|
const outer = useThemeOptional();
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Tiles are positioned by windease at strategy-computed rects; `.windease-zone`
|
|
2
2
|
// (from windease/styles.css) supplies the containing block and the 100% box.
|
|
3
3
|
// This class exists for consumers to target.
|
|
4
|
-
.lk-workspace
|
|
4
|
+
.lk-workspace {
|
|
5
5
|
min-height: 0;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
// Only rendered when `reorderable` — a tile is full of controls, so the drag
|
|
9
9
|
// target has to be its own strip rather than the whole pane.
|
|
10
|
-
.lk-
|
|
10
|
+
.lk-trial-tile {
|
|
11
11
|
display: flex;
|
|
12
12
|
flex-direction: column;
|
|
13
13
|
height: 100%;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
-
import {
|
|
2
|
+
import { Workspace } from './Workspace';
|
|
3
3
|
|
|
4
|
-
const meta: Meta<typeof
|
|
5
|
-
title: 'labkit/Lab/
|
|
6
|
-
component:
|
|
4
|
+
const meta: Meta<typeof Workspace> = {
|
|
5
|
+
title: 'labkit/Lab/Workspace',
|
|
6
|
+
component: Workspace,
|
|
7
7
|
};
|
|
8
8
|
export default meta;
|
|
9
9
|
|
|
10
|
-
type Story = StoryObj<typeof
|
|
10
|
+
type Story = StoryObj<typeof Workspace>;
|
|
11
11
|
|
|
12
12
|
const Tile = ({ children }: { children: React.ReactNode }) => (
|
|
13
13
|
<div
|
|
@@ -28,9 +28,9 @@ const Tile = ({ children }: { children: React.ReactNode }) => (
|
|
|
28
28
|
export const OneTile: Story = {
|
|
29
29
|
render: () => (
|
|
30
30
|
<div style={{ height: 400 }}>
|
|
31
|
-
<
|
|
31
|
+
<Workspace>
|
|
32
32
|
<Tile>1</Tile>
|
|
33
|
-
</
|
|
33
|
+
</Workspace>
|
|
34
34
|
</div>
|
|
35
35
|
),
|
|
36
36
|
};
|
|
@@ -38,11 +38,11 @@ export const OneTile: Story = {
|
|
|
38
38
|
export const ThreeTiles: Story = {
|
|
39
39
|
render: () => (
|
|
40
40
|
<div style={{ height: 400 }}>
|
|
41
|
-
<
|
|
41
|
+
<Workspace>
|
|
42
42
|
<Tile>1</Tile>
|
|
43
43
|
<Tile>2</Tile>
|
|
44
44
|
<Tile>3</Tile>
|
|
45
|
-
</
|
|
45
|
+
</Workspace>
|
|
46
46
|
</div>
|
|
47
47
|
),
|
|
48
48
|
};
|
|
@@ -50,12 +50,12 @@ export const ThreeTiles: Story = {
|
|
|
50
50
|
export const SevenTiles: Story = {
|
|
51
51
|
render: () => (
|
|
52
52
|
<div style={{ height: 600 }}>
|
|
53
|
-
<
|
|
53
|
+
<Workspace>
|
|
54
54
|
{Array.from({ length: 7 }).map((_, i) => (
|
|
55
55
|
// biome-ignore lint/suspicious/noArrayIndexKey: static demo list
|
|
56
56
|
<Tile key={i}>{i + 1}</Tile>
|
|
57
57
|
))}
|
|
58
|
-
</
|
|
58
|
+
</Workspace>
|
|
59
59
|
</div>
|
|
60
60
|
),
|
|
61
61
|
};
|
|
@@ -65,13 +65,13 @@ export const SevenTiles: Story = {
|
|
|
65
65
|
export const Resizable: Story = {
|
|
66
66
|
render: () => (
|
|
67
67
|
<div style={{ height: 500 }}>
|
|
68
|
-
<
|
|
68
|
+
<Workspace ids={['a', 'b', 'c', 'd', 'e']} resizable>
|
|
69
69
|
<Tile>1</Tile>
|
|
70
70
|
<Tile>2</Tile>
|
|
71
71
|
<Tile>3</Tile>
|
|
72
72
|
<Tile>4</Tile>
|
|
73
73
|
<Tile>5</Tile>
|
|
74
|
-
</
|
|
74
|
+
</Workspace>
|
|
75
75
|
</div>
|
|
76
76
|
),
|
|
77
77
|
};
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, test, vi } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { Workspace } from './Workspace';
|
|
4
4
|
|
|
5
5
|
const VIEWPORT = { w: 800, h: 600 };
|
|
6
6
|
|
|
7
|
-
describe('
|
|
7
|
+
describe('Workspace', () => {
|
|
8
8
|
test('renders all children', () => {
|
|
9
9
|
render(
|
|
10
|
-
<
|
|
10
|
+
<Workspace viewport={VIEWPORT}>
|
|
11
11
|
<div>one</div>
|
|
12
12
|
<div>two</div>
|
|
13
13
|
<div>three</div>
|
|
14
|
-
</
|
|
14
|
+
</Workspace>,
|
|
15
15
|
);
|
|
16
16
|
expect(screen.getByText('one')).toBeInTheDocument();
|
|
17
17
|
expect(screen.getByText('two')).toBeInTheDocument();
|
|
@@ -21,54 +21,54 @@ describe('WorkspaceGrid', () => {
|
|
|
21
21
|
test('keeps a child with the tile its id names when an earlier one closes', () => {
|
|
22
22
|
const ids = ['a', 'b', 'c'];
|
|
23
23
|
const { rerender, container } = render(
|
|
24
|
-
<
|
|
24
|
+
<Workspace ids={ids} viewport={VIEWPORT}>
|
|
25
25
|
<div>a</div>
|
|
26
26
|
<div>b</div>
|
|
27
27
|
<div>c</div>
|
|
28
|
-
</
|
|
28
|
+
</Workspace>,
|
|
29
29
|
);
|
|
30
30
|
expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
|
|
31
31
|
|
|
32
32
|
rerender(
|
|
33
|
-
<
|
|
33
|
+
<Workspace ids={['b', 'c']} viewport={VIEWPORT}>
|
|
34
34
|
<div>b</div>
|
|
35
35
|
<div>c</div>
|
|
36
|
-
</
|
|
36
|
+
</Workspace>,
|
|
37
37
|
);
|
|
38
38
|
expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
|
|
39
39
|
expect(container.querySelector('[data-node="a"]')).toBeNull();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
test('uses lk-workspace
|
|
42
|
+
test('uses lk-workspace class', () => {
|
|
43
43
|
const { container } = render(
|
|
44
|
-
<
|
|
44
|
+
<Workspace viewport={VIEWPORT}>
|
|
45
45
|
<div />
|
|
46
|
-
</
|
|
46
|
+
</Workspace>,
|
|
47
47
|
);
|
|
48
|
-
expect((container.firstChild as HTMLElement).className).toContain('lk-workspace
|
|
48
|
+
expect((container.firstChild as HTMLElement).className).toContain('lk-workspace');
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
test('renders resize affordances only when resizable', () => {
|
|
52
52
|
const { container, rerender } = render(
|
|
53
|
-
<
|
|
53
|
+
<Workspace ids={['a', 'b']} viewport={VIEWPORT}>
|
|
54
54
|
<div>a</div>
|
|
55
55
|
<div>b</div>
|
|
56
|
-
</
|
|
56
|
+
</Workspace>,
|
|
57
57
|
);
|
|
58
58
|
expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
|
|
59
59
|
|
|
60
60
|
rerender(
|
|
61
|
-
<
|
|
61
|
+
<Workspace ids={['a', 'b']} resizable viewport={VIEWPORT}>
|
|
62
62
|
<div>a</div>
|
|
63
63
|
<div>b</div>
|
|
64
|
-
</
|
|
64
|
+
</Workspace>,
|
|
65
65
|
);
|
|
66
66
|
expect(container.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
test('applies a saved extent to a tile as it registers', () => {
|
|
70
70
|
const { container } = render(
|
|
71
|
-
<
|
|
71
|
+
<Workspace
|
|
72
72
|
ids={['a', 'b']}
|
|
73
73
|
resizable
|
|
74
74
|
viewport={VIEWPORT}
|
|
@@ -76,7 +76,7 @@ describe('WorkspaceGrid', () => {
|
|
|
76
76
|
>
|
|
77
77
|
<div>a</div>
|
|
78
78
|
<div>b</div>
|
|
79
|
-
</
|
|
79
|
+
</Workspace>,
|
|
80
80
|
);
|
|
81
81
|
const a = container.querySelector('[data-node="a"]') as HTMLElement;
|
|
82
82
|
const b = container.querySelector('[data-node="b"]') as HTMLElement;
|
|
@@ -87,14 +87,14 @@ describe('WorkspaceGrid', () => {
|
|
|
87
87
|
test('reports the order a drop would produce instead of applying it', () => {
|
|
88
88
|
const onReorder = vi.fn();
|
|
89
89
|
const { container } = render(
|
|
90
|
-
<
|
|
90
|
+
<Workspace ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
|
|
91
91
|
<div>a</div>
|
|
92
92
|
<div>b</div>
|
|
93
|
-
</
|
|
93
|
+
</Workspace>,
|
|
94
94
|
);
|
|
95
95
|
// The grid is controlled: it renders a handle per tile and commits nothing
|
|
96
96
|
// itself. Order still comes from `ids`.
|
|
97
|
-
expect(container.querySelectorAll('.lk-
|
|
97
|
+
expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(2);
|
|
98
98
|
expect(onReorder).not.toHaveBeenCalled();
|
|
99
99
|
const nodes = [...container.querySelectorAll('[data-node]')].map((el) =>
|
|
100
100
|
el.getAttribute('data-node'),
|
|
@@ -104,11 +104,11 @@ describe('WorkspaceGrid', () => {
|
|
|
104
104
|
|
|
105
105
|
test('renders no drag handles unless reorderable', () => {
|
|
106
106
|
const { container } = render(
|
|
107
|
-
<
|
|
107
|
+
<Workspace ids={['a', 'b']} viewport={VIEWPORT}>
|
|
108
108
|
<div>a</div>
|
|
109
109
|
<div>b</div>
|
|
110
|
-
</
|
|
110
|
+
</Workspace>,
|
|
111
111
|
);
|
|
112
|
-
expect(container.querySelectorAll('.lk-
|
|
112
|
+
expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(0);
|
|
113
113
|
});
|
|
114
114
|
});
|
|
@@ -17,18 +17,18 @@ import {
|
|
|
17
17
|
StrategyRegistryProvider,
|
|
18
18
|
} from 'windease/react';
|
|
19
19
|
|
|
20
|
-
const ZONE_ID = asNodeId('lk-
|
|
20
|
+
const ZONE_ID = asNodeId('lk-workspace');
|
|
21
21
|
const STRATEGIES = { grid: gridStrategy as never };
|
|
22
|
-
const KIND = '
|
|
22
|
+
const KIND = 'trial';
|
|
23
23
|
|
|
24
24
|
/** A tile's persisted extent, keyed by the id its caller gave it. Grid resizes
|
|
25
25
|
* write `span`; `size` is here because a strategy swap would write that. */
|
|
26
|
-
export type
|
|
26
|
+
export type TrialLayout = Record<
|
|
27
27
|
string,
|
|
28
28
|
{ size?: { w?: number; h?: number }; span?: { cols?: number; rows?: number } }
|
|
29
29
|
>;
|
|
30
30
|
|
|
31
|
-
export interface
|
|
31
|
+
export interface WorkspaceProps {
|
|
32
32
|
children: ReactNode;
|
|
33
33
|
/**
|
|
34
34
|
* Stable id per child, positionally matched. Supply these whenever a tile
|
|
@@ -48,10 +48,10 @@ export interface WorkspaceGridProps {
|
|
|
48
48
|
/** The full id list a drop would produce, in its new order. */
|
|
49
49
|
onReorder?: (ids: string[]) => void;
|
|
50
50
|
/** Extents from a previous session, applied to tiles as they register. */
|
|
51
|
-
layout?:
|
|
51
|
+
layout?: TrialLayout;
|
|
52
52
|
/** Fires when a tile's extent changes. Persist it and hand it back as
|
|
53
53
|
* `layout` to make a resize survive a reload. */
|
|
54
|
-
onLayoutChange?: (layout:
|
|
54
|
+
onLayoutChange?: (layout: TrialLayout) => void;
|
|
55
55
|
gap?: number;
|
|
56
56
|
padding?: number;
|
|
57
57
|
/**
|
|
@@ -62,23 +62,23 @@ export interface WorkspaceGridProps {
|
|
|
62
62
|
viewport?: { w: number; h: number };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function extentOf(store: Store, id: NodeId):
|
|
66
|
-
const p = store.getNode(id)?.membership?.placement as
|
|
65
|
+
function extentOf(store: Store, id: NodeId): TrialLayout[string] | null {
|
|
66
|
+
const p = store.getNode(id)?.membership?.placement as TrialLayout[string] | undefined;
|
|
67
67
|
if (!p) return null;
|
|
68
|
-
const out:
|
|
68
|
+
const out: TrialLayout[string] = {};
|
|
69
69
|
if (p.size) out.size = p.size;
|
|
70
70
|
if (p.span) out.span = p.span;
|
|
71
71
|
return Object.keys(out).length > 0 ? out : null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Auto-balanced tiling of
|
|
75
|
+
* Auto-balanced tiling of trials, `ceil(sqrt(n))` columns wide.
|
|
76
76
|
*
|
|
77
77
|
* Tiles are absolutely positioned at the rects `gridStrategy` computes, not
|
|
78
78
|
* laid out by CSS — `windease/styles.css` (folded into
|
|
79
79
|
* `@weasel-js/labkit/styles.css`) carries the rules that positioning depends on.
|
|
80
80
|
*/
|
|
81
|
-
export function
|
|
81
|
+
export function Workspace({
|
|
82
82
|
children,
|
|
83
83
|
ids,
|
|
84
84
|
resizable = false,
|
|
@@ -89,7 +89,7 @@ export function WorkspaceGrid({
|
|
|
89
89
|
gap = 12,
|
|
90
90
|
padding = 0,
|
|
91
91
|
viewport,
|
|
92
|
-
}:
|
|
92
|
+
}: WorkspaceProps) {
|
|
93
93
|
const items = Children.toArray(children);
|
|
94
94
|
const idKey = ids ? ids.join(',') : `#${items.length}`;
|
|
95
95
|
// biome-ignore lint/correctness/useExhaustiveDependencies: idKey is the stable projection of items/ids; depending on those directly rebuilds every render and re-runs the sync effect forever
|
|
@@ -142,7 +142,7 @@ export function WorkspaceGrid({
|
|
|
142
142
|
useEffect(() => {
|
|
143
143
|
if (!onLayoutChange) return;
|
|
144
144
|
return store.events.on('node.placementChanged', () => {
|
|
145
|
-
const next:
|
|
145
|
+
const next: TrialLayout = {};
|
|
146
146
|
for (const child of store.getChildren(ZONE_ID)) {
|
|
147
147
|
const extent = extentOf(store, child.id);
|
|
148
148
|
if (extent) next[child.id] = extent;
|
|
@@ -164,11 +164,11 @@ export function WorkspaceGrid({
|
|
|
164
164
|
if (!reorderable) return content;
|
|
165
165
|
// A tile is full of controls, so the whole thing can't be the handle.
|
|
166
166
|
return (
|
|
167
|
-
<div className="lk-
|
|
168
|
-
<DragHandle nodeId={node.id} className="lk-
|
|
169
|
-
<span className="lk-
|
|
167
|
+
<div className="lk-trial-tile">
|
|
168
|
+
<DragHandle nodeId={node.id} className="lk-trial-tile__grip">
|
|
169
|
+
<span className="lk-trial-tile__grip-dots" aria-hidden="true" />
|
|
170
170
|
</DragHandle>
|
|
171
|
-
<div className="lk-
|
|
171
|
+
<div className="lk-trial-tile__body">{content}</div>
|
|
172
172
|
</div>
|
|
173
173
|
);
|
|
174
174
|
},
|
|
@@ -179,7 +179,7 @@ export function WorkspaceGrid({
|
|
|
179
179
|
<Container
|
|
180
180
|
parentId={ZONE_ID}
|
|
181
181
|
chrome={chrome}
|
|
182
|
-
className="lk-workspace
|
|
182
|
+
className="lk-workspace windease-zone"
|
|
183
183
|
affordances={resizable}
|
|
184
184
|
viewport={viewport}
|
|
185
185
|
onChildOrderChange={reorderable ? commitOrder : undefined}
|
package/src/lab/index.ts
CHANGED
|
@@ -4,5 +4,5 @@ export type { LabContextValue } from './LabContext';
|
|
|
4
4
|
export { LabContext, useLabContext } from './LabContext';
|
|
5
5
|
export type { LabShellProps } from './LabShell';
|
|
6
6
|
export { LabShell } from './LabShell';
|
|
7
|
-
export type {
|
|
8
|
-
export {
|
|
7
|
+
export type { WorkspaceProps } from './Workspace';
|
|
8
|
+
export { Workspace } from './Workspace';
|