@weasel-js/labkit 1.4.3 → 1.5.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 +13 -2
- package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
- package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
- package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
- package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
- package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
- package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
- package/dist/canvas/index.d.ts +17 -128
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +4 -92
- package/dist/chrome/index.js +9 -5
- package/dist/chunk-67SJLMC7.js +704 -0
- package/dist/chunk-67SJLMC7.js.map +1 -0
- package/dist/chunk-6ZDGOZQV.js +310 -0
- package/dist/chunk-6ZDGOZQV.js.map +1 -0
- package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
- package/dist/chunk-EKIICY6X.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
- package/dist/chunk-KJALCDWE.js.map +1 -0
- package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
- package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
- package/dist/chunk-NDRYLVOW.js +15 -0
- package/dist/chunk-NDRYLVOW.js.map +1 -0
- package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
- package/dist/chunk-NS64DXMN.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
- package/dist/chunk-PGETSEDK.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
- package/dist/chunk-QLSV2N3G.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-S2SHZRD7.js +7681 -0
- package/dist/chunk-S2SHZRD7.js.map +1 -0
- package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
- package/dist/chunk-TQBBYTAF.js.map +1 -0
- package/dist/chunk-UU3NJO6N.js +72 -0
- package/dist/chunk-UU3NJO6N.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
- package/dist/chunk-ZPH5WTWF.js.map +1 -0
- package/dist/config/index.d.ts +232 -0
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +2 -3
- package/dist/controls/index.js +3 -2
- package/dist/dragdrop/index.d.ts +2 -4
- package/dist/index.d.ts +123 -348
- package/dist/index.js +794 -837
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -5
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +6 -8
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +169 -117
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +53 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +12 -7
- package/dist/state/index.js +16 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +163 -62
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +2 -4
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +53 -5
- package/src/annotations/Annotations.overlay.test.tsx +85 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- 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/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +7 -6
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -2
- package/src/annotations/toolMap.ts +3 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.ts +10 -5
- package/src/chrome/ChromeRegions.stories.tsx +0 -3
- package/src/chrome/LabChrome.tsx +114 -0
- package/src/chrome/builtins.tsx +20 -31
- package/src/chrome/index.ts +15 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +21 -9
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +30 -16
- package/src/chrome/types.ts +47 -20
- package/src/config/builder.test.ts +4 -0
- package/src/config/builder.ts +16 -10
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +13 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/types.ts +3 -1
- package/src/controls/ControlPanel.test.tsx +16 -0
- package/src/controls/ControlPanel.tsx +5 -2
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +39 -6
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +11 -0
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +0 -2
- package/src/lab/Lab.surface.test.tsx +66 -3
- package/src/lab/Lab.test.tsx +91 -17
- package/src/lab/Lab.tsx +337 -124
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +0 -3
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -13
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabShell.less +49 -12
- package/src/lab/LabShell.tsx +14 -2
- package/src/lab/LabSwitcher.less +92 -0
- package/src/lab/LabSwitcher.test.tsx +96 -0
- package/src/lab/LabSwitcher.tsx +112 -0
- package/src/lab/Workspace.surface.test.tsx +2 -1
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/loupe/AGENTS.md +1 -1
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +1 -1
- package/src/loupe/useLoupe.ts +1 -1
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +13 -1
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +108 -0
- package/src/primitives/Split.tsx +159 -0
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +20 -0
- package/src/state/helpers.ts +16 -0
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +104 -635
- package/src/state/store.ts +117 -158
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +39 -15
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +2 -3
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +2 -1
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +175 -0
- package/src/surface/useTiledSurface.ts +54 -5
- package/src/theme/Interstellar.stories.tsx +7 -7
- package/src/theme/base.less +4 -4
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.tokens.json +2 -2
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +47 -4
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +1 -26
- package/src/trial/Trial.stories.tsx +1 -2
- package/src/trial/Trial.test.tsx +28 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +118 -38
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +7 -4
- package/src/trial/trialOps.ts +7 -4
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2RQPGOQF.js.map +0 -1
- package/dist/chunk-5TROQVQ2.js +0 -547
- package/dist/chunk-5TROQVQ2.js.map +0 -1
- package/dist/chunk-ASWKLRKJ.js.map +0 -1
- package/dist/chunk-C42T6DJT.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MHAGC6VD.js +0 -7320
- package/dist/chunk-MHAGC6VD.js.map +0 -1
- package/dist/chunk-QAGAHJKZ.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type FloatingPlacement,
|
|
10
10
|
floatingStrategy,
|
|
11
11
|
} from 'windease';
|
|
12
|
+
import { usePersistedState } from '../state/usePersistedState';
|
|
12
13
|
|
|
13
14
|
/** The strategy holds a map keyed by item id; a panel is always a lone item. */
|
|
14
15
|
const ITEM_ID = 'panel';
|
|
@@ -22,8 +23,10 @@ export interface FloatingPanelProps {
|
|
|
22
23
|
snapCorners?: readonly Corner[];
|
|
23
24
|
/** Pixels in from a corner when snapped. Default 12. */
|
|
24
25
|
inset?: number;
|
|
25
|
-
/**
|
|
26
|
-
|
|
26
|
+
/** Name to remember its position under, through `usePersistedState` — so it
|
|
27
|
+
* is remembered only inside a lab or a `<Persistence>`. Omit to forget on
|
|
28
|
+
* reload. */
|
|
29
|
+
persist?: string;
|
|
27
30
|
className?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -47,16 +50,6 @@ function isInteractive(target: EventTarget | null): boolean {
|
|
|
47
50
|
return target.closest('input, button, a, select, textarea, [data-no-drag]') !== null;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
function readStored(key: string | undefined): FloatingPlacement | null {
|
|
51
|
-
if (!key) return null;
|
|
52
|
-
try {
|
|
53
|
-
const raw = localStorage.getItem(key);
|
|
54
|
-
return raw ? (JSON.parse(raw) as FloatingPlacement) : null;
|
|
55
|
-
} catch {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
53
|
/**
|
|
61
54
|
* A draggable box that floats over its offset parent and snaps to its corners.
|
|
62
55
|
*
|
|
@@ -70,7 +63,7 @@ export function FloatingPanel({
|
|
|
70
63
|
anchor = DEFAULT_ANCHOR,
|
|
71
64
|
snapCorners,
|
|
72
65
|
inset = DEFAULT_INSET,
|
|
73
|
-
|
|
66
|
+
persist,
|
|
74
67
|
className,
|
|
75
68
|
}: FloatingPanelProps) {
|
|
76
69
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -79,18 +72,11 @@ export function FloatingPanel({
|
|
|
79
72
|
|
|
80
73
|
const [container, setContainer] = useState({ w: 0, h: 0 });
|
|
81
74
|
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
82
|
-
const [place, setPlace] =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (!storageKey) return;
|
|
88
|
-
try {
|
|
89
|
-
localStorage.setItem(storageKey, JSON.stringify(place));
|
|
90
|
-
} catch {
|
|
91
|
-
// A full or disabled store is not worth failing a lab over.
|
|
92
|
-
}
|
|
93
|
-
}, [place, storageKey]);
|
|
75
|
+
const [place, setPlace] = usePersistedState<FloatingPlacement>(persist, () => ({
|
|
76
|
+
x: 0,
|
|
77
|
+
y: 0,
|
|
78
|
+
anchor,
|
|
79
|
+
}));
|
|
94
80
|
|
|
95
81
|
const item = useMemo(
|
|
96
82
|
() => ({
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The strip's own seam. windease positions and sizes it; this is only what it
|
|
2
|
+
// looks like.
|
|
3
|
+
.lk-split [data-affordance-kind='resize-x'] {
|
|
4
|
+
background: var(--wzl-border);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.lk-split .windease-affordance-hit:hover [data-affordance-kind='resize-x'],
|
|
8
|
+
.lk-split .windease-affordance-hit:focus-visible [data-affordance-kind='resize-x'] {
|
|
9
|
+
background: var(--wzl-accent);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.lk-split .windease-affordance-hit:focus-visible {
|
|
13
|
+
outline: none;
|
|
14
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { Split } from './Split';
|
|
4
|
+
|
|
5
|
+
const VIEWPORT = { w: 800, h: 600 };
|
|
6
|
+
|
|
7
|
+
function renderBody(props: Partial<Parameters<typeof Split>[0]> = {}) {
|
|
8
|
+
return render(
|
|
9
|
+
<Split viewport={VIEWPORT} sidebar={<p>sidebar body</p>} {...props}>
|
|
10
|
+
<p>instrument output</p>
|
|
11
|
+
</Split>,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Split', () => {
|
|
16
|
+
it('renders both panes', () => {
|
|
17
|
+
renderBody();
|
|
18
|
+
expect(screen.getByText('sidebar body')).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText('instrument output')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('puts a labeled separator between the sidebar and the content', () => {
|
|
23
|
+
renderBody({ width: 320 });
|
|
24
|
+
const seam = screen.getByRole('separator');
|
|
25
|
+
expect(seam).toHaveAttribute('aria-orientation', 'horizontal');
|
|
26
|
+
expect(seam).toHaveAttribute('aria-valuenow', '320');
|
|
27
|
+
expect(seam).toHaveAccessibleName(/sidebar/i);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reports the width a drag lands on', () => {
|
|
31
|
+
const onWidthChange = vi.fn();
|
|
32
|
+
renderBody({ width: 320, onWidthChange });
|
|
33
|
+
const seam = screen.getByRole('separator');
|
|
34
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
35
|
+
fireEvent.pointerMove(seam, { clientX: 380, clientY: 100 });
|
|
36
|
+
fireEvent.pointerUp(seam, { clientX: 380, clientY: 100 });
|
|
37
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(380);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('resizes from the keyboard', () => {
|
|
41
|
+
const onWidthChange = vi.fn();
|
|
42
|
+
renderBody({ width: 320, onWidthChange });
|
|
43
|
+
const seam = screen.getByRole('separator');
|
|
44
|
+
fireEvent.keyDown(seam, { key: 'ArrowRight' });
|
|
45
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(328);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('refuses to drag the sidebar past its stated bounds', () => {
|
|
49
|
+
const onWidthChange = vi.fn();
|
|
50
|
+
renderBody({ width: 320, minWidth: 200, maxWidth: 400, onWidthChange });
|
|
51
|
+
const seam = screen.getByRole('separator');
|
|
52
|
+
expect(seam).toHaveAttribute('aria-valuemin', '200');
|
|
53
|
+
expect(seam).toHaveAttribute('aria-valuemax', '400');
|
|
54
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
55
|
+
fireEvent.pointerMove(seam, { clientX: 900, clientY: 100 });
|
|
56
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(400);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('leaves the content pane a floor the seam cannot cross', () => {
|
|
60
|
+
const onWidthChange = vi.fn();
|
|
61
|
+
renderBody({ width: 320, maxWidth: 10_000, contentMinWidth: 300, onWidthChange });
|
|
62
|
+
const seam = screen.getByRole('separator');
|
|
63
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
64
|
+
fireEvent.pointerMove(seam, { clientX: 5_000, clientY: 100 });
|
|
65
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(VIEWPORT.w - 300);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('puts the caller’s class names on its boxes', () => {
|
|
69
|
+
const { container } = render(
|
|
70
|
+
<Split
|
|
71
|
+
viewport={VIEWPORT}
|
|
72
|
+
className="lk-x__panes"
|
|
73
|
+
sidebarClassName="lk-x__side"
|
|
74
|
+
contentClassName="lk-x__main"
|
|
75
|
+
label="Stories"
|
|
76
|
+
sidebar={<p>side</p>}
|
|
77
|
+
>
|
|
78
|
+
<p>main</p>
|
|
79
|
+
</Split>,
|
|
80
|
+
);
|
|
81
|
+
expect(container.querySelector('.lk-x__panes.lk-split')).not.toBeNull();
|
|
82
|
+
expect(container.querySelector('.lk-x__side')?.textContent).toBe('side');
|
|
83
|
+
expect(container.querySelector('.lk-x__main')?.textContent).toBe('main');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('names its seam after the sidebar label', () => {
|
|
87
|
+
render(
|
|
88
|
+
<Split viewport={VIEWPORT} label="Stories" sidebar={<p>side</p>}>
|
|
89
|
+
<p>main</p>
|
|
90
|
+
</Split>,
|
|
91
|
+
);
|
|
92
|
+
expect(screen.getByRole('separator')).toHaveAccessibleName(/stories/i);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renames its seam when the label changes', () => {
|
|
96
|
+
const { rerender } = render(
|
|
97
|
+
<Split viewport={VIEWPORT} label="Stories" sidebar={<p>side</p>}>
|
|
98
|
+
<p>main</p>
|
|
99
|
+
</Split>,
|
|
100
|
+
);
|
|
101
|
+
rerender(
|
|
102
|
+
<Split viewport={VIEWPORT} label="Outline" sidebar={<p>side</p>}>
|
|
103
|
+
<p>main</p>
|
|
104
|
+
</Split>,
|
|
105
|
+
);
|
|
106
|
+
expect(screen.getByRole('separator')).toHaveAccessibleName(/outline/i);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { asNodeId, createNode, Store, stripStrategy } from 'windease';
|
|
3
|
+
import { type ChromeMap, Container, Provider, StrategyRegistryProvider } from 'windease/react';
|
|
4
|
+
|
|
5
|
+
const ZONE_ID = asNodeId('lk-split');
|
|
6
|
+
const SIDEBAR_ID = asNodeId('lk-split-sidebar');
|
|
7
|
+
const CONTENT_ID = asNodeId('lk-split-content');
|
|
8
|
+
const STRATEGIES = { strip: stripStrategy as never };
|
|
9
|
+
|
|
10
|
+
/** Props for `<Split>`. */
|
|
11
|
+
export interface SplitProps {
|
|
12
|
+
/** The sidebar pane's contents. */
|
|
13
|
+
sidebar: ReactNode;
|
|
14
|
+
/** The content pane's contents. */
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
/** Names the sidebar pane, and through it the seam. Default `'Sidebar'`. */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Extra classes on the strip, which always has `lk-split`. */
|
|
19
|
+
className?: string;
|
|
20
|
+
/** The class on the sidebar pane. */
|
|
21
|
+
sidebarClassName?: string;
|
|
22
|
+
/** The class on the content pane. */
|
|
23
|
+
contentClassName?: string;
|
|
24
|
+
/** The sidebar's current extent in pixels. Omit to leave it unpersisted: the
|
|
25
|
+
* seam still works, its result just does not outlive the mount. */
|
|
26
|
+
width?: number;
|
|
27
|
+
/** Where the sidebar starts before anyone drags it. */
|
|
28
|
+
defaultWidth?: number;
|
|
29
|
+
minWidth?: number;
|
|
30
|
+
maxWidth?: number;
|
|
31
|
+
/** How little the content pane may be squeezed to. Reported as the seam's
|
|
32
|
+
* reachable maximum, so the drag stops there rather than clamping silently. */
|
|
33
|
+
contentMinWidth?: number;
|
|
34
|
+
/** Fires through the drag, not on release: the pane is already there. */
|
|
35
|
+
onWidthChange?: (width: number) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Fixed extent. Omit in an app — the strip measures its own box. Supply it
|
|
38
|
+
* where nothing measures, notably jsdom, or both panes lay out at zero.
|
|
39
|
+
*/
|
|
40
|
+
viewport?: { w: number; h: number };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A sidebar and a content pane as a two-pane strip with a draggable seam.
|
|
45
|
+
*
|
|
46
|
+
* The seam is `stripStrategy`'s own resize affordance, so it arrives as a
|
|
47
|
+
* `role="separator"` carrying the reachable range, operable by pointer and by
|
|
48
|
+
* arrows / Home / End, and clamped against the content pane's floor rather
|
|
49
|
+
* than against the sidebar's alone.
|
|
50
|
+
*/
|
|
51
|
+
export function Split({
|
|
52
|
+
sidebar,
|
|
53
|
+
children,
|
|
54
|
+
label = 'Sidebar',
|
|
55
|
+
className,
|
|
56
|
+
sidebarClassName,
|
|
57
|
+
contentClassName,
|
|
58
|
+
width,
|
|
59
|
+
defaultWidth = 320,
|
|
60
|
+
minWidth = 140,
|
|
61
|
+
maxWidth = 720,
|
|
62
|
+
contentMinWidth = 160,
|
|
63
|
+
onWidthChange,
|
|
64
|
+
viewport,
|
|
65
|
+
}: SplitProps) {
|
|
66
|
+
// The cross-axis half of every size hint below is inert: a horizontal strip
|
|
67
|
+
// stretches its panes to the container's height and never reads `h`.
|
|
68
|
+
const storeRef = useRef<Store | null>(null);
|
|
69
|
+
if (storeRef.current === null) {
|
|
70
|
+
const store = new Store();
|
|
71
|
+
store.registerNode(
|
|
72
|
+
createNode({
|
|
73
|
+
kind: 'zone',
|
|
74
|
+
id: ZONE_ID,
|
|
75
|
+
container: { strategyId: 'strip', config: { axis: 'x', resizable: true } },
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
store.registerNode(
|
|
79
|
+
createNode({
|
|
80
|
+
kind: 'sidebar',
|
|
81
|
+
id: SIDEBAR_ID,
|
|
82
|
+
parentId: ZONE_ID,
|
|
83
|
+
meta: { title: label },
|
|
84
|
+
placement: { size: { w: width ?? defaultWidth } },
|
|
85
|
+
hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
store.registerNode(
|
|
89
|
+
createNode({
|
|
90
|
+
kind: 'content',
|
|
91
|
+
id: CONTENT_ID,
|
|
92
|
+
parentId: ZONE_ID,
|
|
93
|
+
meta: { title: 'Content' },
|
|
94
|
+
hints: { minSize: { w: contentMinWidth, h: 0 } },
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
store.showNode(SIDEBAR_ID);
|
|
98
|
+
store.showNode(CONTENT_ID);
|
|
99
|
+
storeRef.current = store;
|
|
100
|
+
}
|
|
101
|
+
const store = storeRef.current;
|
|
102
|
+
|
|
103
|
+
// What the store last held, so the placement event can tell a drag from the
|
|
104
|
+
// echo of our own write and a controlled `width` does not fight the seam.
|
|
105
|
+
const widthRef = useRef(width ?? defaultWidth);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
store.setHints(SIDEBAR_ID, {
|
|
109
|
+
minSize: { w: minWidth, h: 0 },
|
|
110
|
+
maxSize: { w: maxWidth, h: 0 },
|
|
111
|
+
});
|
|
112
|
+
store.setHints(CONTENT_ID, { minSize: { w: contentMinWidth, h: 0 } });
|
|
113
|
+
}, [store, minWidth, maxWidth, contentMinWidth]);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
store.setMeta(SIDEBAR_ID, { title: label });
|
|
117
|
+
}, [store, label]);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (width === undefined || width === widthRef.current) return;
|
|
121
|
+
widthRef.current = width;
|
|
122
|
+
store.patchPlacement(SIDEBAR_ID, { size: { w: width } });
|
|
123
|
+
}, [store, width]);
|
|
124
|
+
|
|
125
|
+
const onWidthChangeRef = useRef(onWidthChange);
|
|
126
|
+
onWidthChangeRef.current = onWidthChange;
|
|
127
|
+
useEffect(
|
|
128
|
+
() =>
|
|
129
|
+
store.events.on('node.placementChanged', () => {
|
|
130
|
+
const next = (store.getPlacement(SIDEBAR_ID)?.size as { w?: number } | undefined)?.w;
|
|
131
|
+
if (typeof next !== 'number' || next === widthRef.current) return;
|
|
132
|
+
widthRef.current = next;
|
|
133
|
+
onWidthChangeRef.current?.(next);
|
|
134
|
+
}),
|
|
135
|
+
[store],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const chrome = useMemo<ChromeMap>(
|
|
139
|
+
() => ({
|
|
140
|
+
sidebar: () => <div className={sidebarClassName}>{sidebar}</div>,
|
|
141
|
+
content: () => <div className={contentClassName}>{children}</div>,
|
|
142
|
+
}),
|
|
143
|
+
[sidebar, children, sidebarClassName, contentClassName],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<Provider store={store}>
|
|
148
|
+
<StrategyRegistryProvider strategies={STRATEGIES}>
|
|
149
|
+
<Container
|
|
150
|
+
parentId={ZONE_ID}
|
|
151
|
+
chrome={chrome}
|
|
152
|
+
className={className ? `lk-split windease-zone ${className}` : 'lk-split windease-zone'}
|
|
153
|
+
affordances
|
|
154
|
+
viewport={viewport}
|
|
155
|
+
/>
|
|
156
|
+
</StrategyRegistryProvider>
|
|
157
|
+
</Provider>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeZoom } from '@weasel-js/core';
|
|
1
2
|
import { NumberField, Slider } from '../passthrough/weasel-ui';
|
|
2
3
|
|
|
3
4
|
/** Props for `<ZoomControl>`. */
|
|
@@ -30,10 +31,12 @@ export function ZoomControl({
|
|
|
30
31
|
zoom,
|
|
31
32
|
onZoomChange,
|
|
32
33
|
onZoomCommit,
|
|
33
|
-
min = 0.1,
|
|
34
|
-
max = 8,
|
|
34
|
+
min: minProp = 0.1,
|
|
35
|
+
max: maxProp = 8,
|
|
35
36
|
className,
|
|
36
37
|
}: ZoomControlProps) {
|
|
38
|
+
const min = normalizeZoom(minProp);
|
|
39
|
+
const max = normalizeZoom(maxProp);
|
|
37
40
|
const commit = onZoomCommit ?? onZoomChange;
|
|
38
41
|
const emit = (track: number, done: boolean): void => {
|
|
39
42
|
const next = clamp(fromTrack(track), min, max);
|
package/src/primitives/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type { ScaleIndicatorProps } from './ScaleIndicator';
|
|
|
9
9
|
export { ScaleIndicator } from './ScaleIndicator';
|
|
10
10
|
export type { SidebarProps } from './Sidebar';
|
|
11
11
|
export { Sidebar } from './Sidebar';
|
|
12
|
+
export type { SplitProps } from './Split';
|
|
13
|
+
export { Split } from './Split';
|
|
12
14
|
export type { StatusBarProps, StatusBarSectionProps } from './StatusBar';
|
|
13
15
|
export { StatusBar } from './StatusBar';
|
|
14
16
|
export type { ToolbarButtonProps, ToolbarGroupProps, ToolbarProps } from './Toolbar';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
|
4
|
+
import { createIndexedDbAdapter } from './adapters';
|
|
5
|
+
import { Persistence } from './Persistence';
|
|
6
|
+
import { usePersistedState } from './usePersistedState';
|
|
7
|
+
|
|
8
|
+
// jsdom only has fake-indexeddb, an imitation. This round-trips a value through
|
|
9
|
+
// the browser's own IndexedDB: write it, unmount the provider so it closes,
|
|
10
|
+
// mount a fresh one, and read it back.
|
|
11
|
+
|
|
12
|
+
function Tab() {
|
|
13
|
+
const [tab, setTab] = usePersistedState('tab', 'shape');
|
|
14
|
+
return (
|
|
15
|
+
<p>
|
|
16
|
+
<output aria-label="tab">{tab}</output>
|
|
17
|
+
<button type="button" onClick={() => setTab('color')}>
|
|
18
|
+
choose color
|
|
19
|
+
</button>
|
|
20
|
+
</p>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Remountable({ database }: { database: string }) {
|
|
25
|
+
const [generation, setGeneration] = useState(0);
|
|
26
|
+
const [shown, setShown] = useState(true);
|
|
27
|
+
const storage = useState(() => createIndexedDbAdapter({ database }))[0];
|
|
28
|
+
return (
|
|
29
|
+
<div>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={() => {
|
|
33
|
+
setShown(false);
|
|
34
|
+
// Past the provider's deferred close, which sends the queued write.
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
setGeneration((g) => g + 1);
|
|
37
|
+
setShown(true);
|
|
38
|
+
}, 100);
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
remount
|
|
42
|
+
</button>
|
|
43
|
+
{shown ? (
|
|
44
|
+
<Persistence
|
|
45
|
+
key={generation}
|
|
46
|
+
storageKey="story"
|
|
47
|
+
storage={storage}
|
|
48
|
+
fallback={<p>loading</p>}
|
|
49
|
+
>
|
|
50
|
+
<Tab />
|
|
51
|
+
</Persistence>
|
|
52
|
+
) : null}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const meta: Meta<typeof Remountable> = {
|
|
58
|
+
title: 'labkit/State/Persistence',
|
|
59
|
+
component: Remountable,
|
|
60
|
+
};
|
|
61
|
+
export default meta;
|
|
62
|
+
|
|
63
|
+
export const SurvivesARemountInIndexedDb: StoryObj<typeof Remountable> = {
|
|
64
|
+
args: { database: `labkit-story-${Date.now()}` },
|
|
65
|
+
play: async ({ canvasElement }) => {
|
|
66
|
+
const canvas = within(canvasElement);
|
|
67
|
+
await waitFor(() => expect(canvas.getByLabelText('tab')).toHaveTextContent('shape'));
|
|
68
|
+
await userEvent.click(canvas.getByText('choose color'));
|
|
69
|
+
await userEvent.click(canvas.getByText('remount'));
|
|
70
|
+
await waitFor(() => expect(canvas.queryByLabelText('tab')).toBeNull());
|
|
71
|
+
await waitFor(() => expect(canvas.getByLabelText('tab')).toHaveTextContent('color'), {
|
|
72
|
+
timeout: 3000,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createContext, type ReactNode } from 'react';
|
|
2
|
+
import { defaultStorage } from './adapters';
|
|
3
|
+
import { labPrefix } from './labRecords';
|
|
4
|
+
import { openRecords, type RecordCache } from './records';
|
|
5
|
+
import type { StorageAdapter } from './types';
|
|
6
|
+
import { useOpenOnce, useWarnIgnoredChange } from './useOpenOnce';
|
|
7
|
+
|
|
8
|
+
/** The records `usePersistedState` reads and writes. `<Lab>`,
|
|
9
|
+
* `SingletonExperimentProvider` and `<Persistence>` provide one. */
|
|
10
|
+
export const PersistenceContext = createContext<RecordCache | null>(null);
|
|
11
|
+
|
|
12
|
+
/** Props for `<Persistence>`. */
|
|
13
|
+
export interface PersistenceProps {
|
|
14
|
+
/** Names the records, as `<Lab storageKey>` does. A lab and a
|
|
15
|
+
* `<Persistence>` given the same key share lab-scoped values. */
|
|
16
|
+
storageKey: string;
|
|
17
|
+
/** Default: IndexedDB, or localStorage where IndexedDB will not open. */
|
|
18
|
+
storage?: StorageAdapter;
|
|
19
|
+
/** Rendered until the records have loaded. Default: nothing. */
|
|
20
|
+
fallback?: ReactNode;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Persisted values for a page with no lab: `usePersistedState` below it keeps
|
|
25
|
+
* what it holds across a reload. Both props are read once, at mount. */
|
|
26
|
+
export function Persistence({ storageKey, storage, fallback = null, children }: PersistenceProps) {
|
|
27
|
+
useWarnIgnoredChange('<Persistence>', { storageKey, storage });
|
|
28
|
+
const records = useOpenOnce(async () =>
|
|
29
|
+
openRecords({ storage: storage ?? (await defaultStorage()), prefix: labPrefix(storageKey) }),
|
|
30
|
+
);
|
|
31
|
+
if (!records) return <>{fallback}</>;
|
|
32
|
+
return <PersistenceContext.Provider value={records}>{children}</PersistenceContext.Provider>;
|
|
33
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { act, render, renderHook } from '@testing-library/react';
|
|
2
|
-
import {
|
|
1
|
+
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import { createMemoryAdapter } from './adapters';
|
|
4
|
-
import {
|
|
4
|
+
import { labPrefix } from './labRecords';
|
|
5
5
|
import { SingletonExperimentProvider } from './SingletonExperiment';
|
|
6
6
|
import { useTrialState } from './useTrialState';
|
|
7
7
|
|
|
@@ -13,31 +13,41 @@ interface State {
|
|
|
13
13
|
zoom: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
function Provider({
|
|
17
|
+
backing,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
backing: Map<string, unknown>;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<SingletonExperimentProvider<State, Config>
|
|
25
|
+
id="test"
|
|
26
|
+
initialConfig={{ width: 100, bg: '#000' }}
|
|
27
|
+
initialState={{ zoom: 1 }}
|
|
28
|
+
storage={createMemoryAdapter(backing)}
|
|
29
|
+
storageKey="test"
|
|
30
|
+
fallback={<p>loading</p>}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</SingletonExperimentProvider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
20
36
|
|
|
21
|
-
|
|
37
|
+
describe('SingletonExperimentProvider', () => {
|
|
38
|
+
it('shows its fallback, then exposes config and state via useTrialState', async () => {
|
|
39
|
+
const backing = new Map<string, unknown>();
|
|
22
40
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
23
|
-
<
|
|
24
|
-
id="test"
|
|
25
|
-
initialConfig={{ width: 100, bg: '#000' }}
|
|
26
|
-
initialState={{ zoom: 1 }}
|
|
27
|
-
storage={createMemoryAdapter()}
|
|
28
|
-
storageKey="test"
|
|
29
|
-
>
|
|
30
|
-
{children}
|
|
31
|
-
</SingletonExperimentProvider>
|
|
41
|
+
<Provider backing={backing}>{children}</Provider>
|
|
32
42
|
);
|
|
33
43
|
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
34
|
-
expect(
|
|
44
|
+
expect(screen.getByText('loading')).toBeInTheDocument();
|
|
45
|
+
await waitFor(() => expect(result.current?.config).toEqual({ width: 100, bg: '#000' }));
|
|
35
46
|
expect(result.current.state).toEqual({ zoom: 1 });
|
|
36
47
|
});
|
|
37
48
|
|
|
38
|
-
it('persists config changes to storage', () => {
|
|
39
|
-
|
|
40
|
-
const storage = createMemoryAdapter();
|
|
49
|
+
it('persists config changes to storage', async () => {
|
|
50
|
+
const backing = new Map<string, unknown>();
|
|
41
51
|
const Probe = () => {
|
|
42
52
|
const h = useTrialState<State, Config>();
|
|
43
53
|
return (
|
|
@@ -46,32 +56,24 @@ describe('SingletonExperimentProvider', () => {
|
|
|
46
56
|
</button>
|
|
47
57
|
);
|
|
48
58
|
};
|
|
49
|
-
|
|
50
|
-
<
|
|
51
|
-
id="test"
|
|
52
|
-
initialConfig={{ width: 100, bg: '#000' }}
|
|
53
|
-
initialState={{ zoom: 1 }}
|
|
54
|
-
storage={storage}
|
|
55
|
-
storageKey="test"
|
|
56
|
-
>
|
|
59
|
+
render(
|
|
60
|
+
<Provider backing={backing}>
|
|
57
61
|
<Probe />
|
|
58
|
-
</
|
|
62
|
+
</Provider>,
|
|
63
|
+
);
|
|
64
|
+
const go = await screen.findByText('go');
|
|
65
|
+
act(() => go.click());
|
|
66
|
+
await waitFor(() =>
|
|
67
|
+
expect(backing.get(`${labPrefix('test')}trial:test`)).toMatchObject({
|
|
68
|
+
config: { width: 200 },
|
|
69
|
+
}),
|
|
59
70
|
);
|
|
60
|
-
act(() => {
|
|
61
|
-
getByText('go').click();
|
|
62
|
-
});
|
|
63
|
-
act(() => {
|
|
64
|
-
vi.advanceTimersByTime(400);
|
|
65
|
-
});
|
|
66
|
-
const raw = storage.read(labDocumentKey('test'));
|
|
67
|
-
expect(raw).toBeTruthy();
|
|
68
|
-
expect(raw).toContain('"width":200');
|
|
69
71
|
});
|
|
70
72
|
|
|
71
|
-
it('rehydrates from storage on mount', () => {
|
|
72
|
-
const
|
|
73
|
-
// A pre-document lab, which
|
|
74
|
-
|
|
73
|
+
it('rehydrates from storage on mount', async () => {
|
|
74
|
+
const backing = new Map<string, unknown>();
|
|
75
|
+
// A pre-document lab, which opening folds forward.
|
|
76
|
+
backing.set(
|
|
75
77
|
'lk:test:workspaces',
|
|
76
78
|
JSON.stringify([
|
|
77
79
|
{
|
|
@@ -84,18 +86,10 @@ describe('SingletonExperimentProvider', () => {
|
|
|
84
86
|
]),
|
|
85
87
|
);
|
|
86
88
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
87
|
-
<
|
|
88
|
-
id="test"
|
|
89
|
-
initialConfig={{ width: 100, bg: '#000' }}
|
|
90
|
-
initialState={{ zoom: 1 }}
|
|
91
|
-
storage={storage}
|
|
92
|
-
storageKey="test"
|
|
93
|
-
>
|
|
94
|
-
{children}
|
|
95
|
-
</SingletonExperimentProvider>
|
|
89
|
+
<Provider backing={backing}>{children}</Provider>
|
|
96
90
|
);
|
|
97
91
|
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
98
|
-
expect(result.current
|
|
92
|
+
await waitFor(() => expect(result.current?.config).toEqual({ width: 999, bg: '#fff' }));
|
|
99
93
|
expect(result.current.state).toEqual({ zoom: 2 });
|
|
100
94
|
});
|
|
101
95
|
});
|