@weasel-js/labkit 1.4.4 → 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-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
- package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
- 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-MOM3GOVY.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-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
- package/dist/chunk-KJALCDWE.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
- package/dist/{chunk-BKFVHKJH.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-64ZCN3DA.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-U3IYHIAE.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 +110 -381
- package/dist/index.js +691 -817
- 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 +164 -122
- 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 +84 -50
- 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/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 +5 -4
- package/src/annotations/svgNodes.test.ts +2 -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 +38 -5
- 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/LabSwitcher.less +8 -1
- 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/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 +1 -1
- 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 +116 -157
- 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 +2 -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/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 +5 -3
- 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-2P6PP5N4.js +0 -553
- package/dist/chunk-2P6PP5N4.js.map +0 -1
- package/dist/chunk-64ZCN3DA.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-MOM3GOVY.js.map +0 -1
- package/dist/chunk-SMHP6XZ4.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/** An element's edges, in the same coordinate space as the bound it is tested against. */
|
|
4
|
+
export interface Edges {
|
|
5
|
+
top: number;
|
|
6
|
+
left: number;
|
|
7
|
+
bottom: number;
|
|
8
|
+
right: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Overshoot<T> {
|
|
12
|
+
item: T;
|
|
13
|
+
/** How far past the bound the item reaches, in px, on whichever axis is worse. */
|
|
14
|
+
by: number;
|
|
15
|
+
axis: 'x' | 'y';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The item reaching furthest past the bottom or right of `bound`. On a tie the
|
|
19
|
+
* first item wins, so given document order it names the outermost box. */
|
|
20
|
+
export function findOvershoot<T>(
|
|
21
|
+
bound: Edges,
|
|
22
|
+
items: Iterable<{ item: T; edges: Edges }>,
|
|
23
|
+
tolerance = 1,
|
|
24
|
+
): Overshoot<T> | null {
|
|
25
|
+
let worst: Overshoot<T> | null = null;
|
|
26
|
+
for (const { item, edges } of items) {
|
|
27
|
+
const y = edges.bottom - bound.bottom;
|
|
28
|
+
const x = edges.right - bound.right;
|
|
29
|
+
const by = Math.max(x, y);
|
|
30
|
+
if (by <= tolerance) continue;
|
|
31
|
+
if (!worst || by > worst.by) worst = { item, by, axis: y >= x ? 'y' : 'x' };
|
|
32
|
+
}
|
|
33
|
+
return worst;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tagAndClasses(el: Element): string {
|
|
37
|
+
return el.tagName.toLowerCase() + [...el.classList].map((c) => `.${c}`).join('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `div.lk-shell-body` — the tag and classes, which is what a reader greps for.
|
|
41
|
+
* A classless element is placed by its nearest classed ancestor. */
|
|
42
|
+
export function describeElement(el: Element): string {
|
|
43
|
+
if (el.classList.length > 0) return tagAndClasses(el);
|
|
44
|
+
let up = el.parentElement;
|
|
45
|
+
while (up && up.classList.length === 0) up = up.parentElement;
|
|
46
|
+
return up ? `${tagAndClasses(el)} in ${tagAndClasses(up)}` : tagAndClasses(el);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function edgesOf(el: Element): Edges {
|
|
50
|
+
const r = el.getBoundingClientRect();
|
|
51
|
+
return { top: r.top, left: r.left, bottom: r.bottom, right: r.right };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function* candidates(root: Element): Iterable<{ item: Element; edges: Edges }> {
|
|
55
|
+
yield { item: root, edges: edgesOf(root) };
|
|
56
|
+
for (const el of root.querySelectorAll('*')) yield { item: el, edges: edgesOf(el) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function overflows(el: Element): boolean {
|
|
60
|
+
return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Why a mounted lab scrolls, or null when it fits. The document only counts
|
|
64
|
+
* when the lab is what reaches past the viewport, so a lab embedded in a long
|
|
65
|
+
* page does not report the page's own scroll. */
|
|
66
|
+
export function explainLabScroll(lab: Element, shellBody: Element): string | null {
|
|
67
|
+
if (overflows(shellBody)) {
|
|
68
|
+
const r = shellBody.getBoundingClientRect();
|
|
69
|
+
const bound = {
|
|
70
|
+
top: r.top,
|
|
71
|
+
left: r.left,
|
|
72
|
+
bottom: r.top + shellBody.clientHeight,
|
|
73
|
+
right: r.left + shellBody.clientWidth,
|
|
74
|
+
};
|
|
75
|
+
const hit = findOvershoot(bound, candidates(shellBody));
|
|
76
|
+
return hit
|
|
77
|
+
? `.lk-shell-body scrolls: ${describeElement(hit.item)} reaches ${Math.round(hit.by)}px past it (${hit.axis})`
|
|
78
|
+
: '.lk-shell-body scrolls';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const doc = document.scrollingElement ?? document.documentElement;
|
|
82
|
+
if (!overflows(doc)) return null;
|
|
83
|
+
const r = lab.getBoundingClientRect();
|
|
84
|
+
const bound = {
|
|
85
|
+
top: r.top,
|
|
86
|
+
left: r.left,
|
|
87
|
+
bottom: r.top + Math.min(r.height, doc.clientHeight),
|
|
88
|
+
right: r.left + Math.min(r.width, doc.clientWidth),
|
|
89
|
+
};
|
|
90
|
+
const hit = findOvershoot(bound, candidates(lab));
|
|
91
|
+
return hit
|
|
92
|
+
? `the page scrolls: ${describeElement(hit.item)} reaches ${Math.round(hit.by)}px past the lab's box (${hit.axis})`
|
|
93
|
+
: null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SETTLE_MS = 300;
|
|
97
|
+
|
|
98
|
+
/** Dev only: warns once, naming the culprit, when a mounted lab scrolls. */
|
|
99
|
+
export function useLabFitWarning(labBody: Element | null): void {
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
102
|
+
if (!labBody || typeof ResizeObserver === 'undefined') return;
|
|
103
|
+
const lab = labBody.closest('.lk-lab');
|
|
104
|
+
const shellBody = labBody.closest('.lk-shell-body');
|
|
105
|
+
if (!lab || !shellBody) return;
|
|
106
|
+
|
|
107
|
+
// A shrinking window overflows for a frame or two before the workspace
|
|
108
|
+
// re-lays out, so only overflow that outlasts the resize is reported.
|
|
109
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
const observer = new ResizeObserver(() => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
timer = setTimeout(() => {
|
|
113
|
+
const why = explainLabScroll(lab, shellBody);
|
|
114
|
+
if (!why) return;
|
|
115
|
+
observer.disconnect();
|
|
116
|
+
console.warn(`[labkit] <Lab> should fit its container without scrolling, but ${why}.`);
|
|
117
|
+
}, SETTLE_MS);
|
|
118
|
+
});
|
|
119
|
+
for (const el of [document.documentElement, lab, shellBody, labBody]) observer.observe(el);
|
|
120
|
+
return () => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
observer.disconnect();
|
|
123
|
+
};
|
|
124
|
+
}, [labBody]);
|
|
125
|
+
}
|
package/src/loupe/AGENTS.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
on, painted by whichever painter suits the instrument's content.
|
|
5
5
|
|
|
6
6
|
The magnifier itself is not here. `@weasel-js/loupe` holds the model — aim,
|
|
7
|
-
factor, mode,
|
|
7
|
+
factor, mode, color, picking — over a `LoupeSurface` it asks five questions.
|
|
8
8
|
This directory binds that model to a labkit trial and draws it.
|
|
9
9
|
|
|
10
10
|
## Files
|
package/src/loupe/Loupe.less
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Nothing under the lens stops working while it is up: the pan, the wheel and
|
|
8
8
|
// any overlay of the instrument's own keep receiving the pointer.
|
|
9
9
|
pointer-events: none;
|
|
10
|
-
border: var(--wzl-border-w) solid var(--wzl-border-
|
|
10
|
+
border: var(--wzl-border-w) solid var(--wzl-border-strong);
|
|
11
11
|
box-shadow: 0 2px 8px var(--wzl-shadow);
|
|
12
12
|
// A painter's ground shows through wherever it draws nothing, and a vector
|
|
13
13
|
// lens leaves the gaps between marks transparent. This is the trial's own
|
package/src/loupe/canvasLens.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function lensSourceRect(
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* The
|
|
64
|
+
* The color a stack presents at a point: the topmost visible layer with
|
|
65
65
|
* anything opaque there, or `null` when every layer is transparent — which is
|
|
66
66
|
* the honest answer for a point showing nothing but the workspace behind.
|
|
67
67
|
*/
|
package/src/loupe/types.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface LoupeCapability<TS = unknown, TC = unknown> {
|
|
|
43
43
|
/** Held for a momentary peek while the loupe is off. Default `'Alt'`; `null`
|
|
44
44
|
* turns hold-to-peek off. Matched against `KeyboardEvent.key`. */
|
|
45
45
|
peekKey?: string | null;
|
|
46
|
-
/** Called with the
|
|
46
|
+
/** Called with the color under the aim, wherever the surface can say. The
|
|
47
47
|
* canvas painter reads it back; a DOM loupe has no pixels to sample. */
|
|
48
48
|
onColorChange?: (hex: string) => void;
|
|
49
49
|
}
|
|
@@ -148,7 +148,7 @@ describe('useLoupe', () => {
|
|
|
148
148
|
expect(wheel.defaultPrevented).toBe(true);
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
it('reports the
|
|
151
|
+
it('reports the color under the aim', () => {
|
|
152
152
|
const onColorChange = vi.fn();
|
|
153
153
|
render(
|
|
154
154
|
<Harness sample={(p) => (p.x > 10 ? '#ff0000' : '#00ff00')} onColorChange={onColorChange} />,
|
package/src/loupe/useLoupe.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface UseLoupeOptions {
|
|
|
14
14
|
hostRef: RefObject<HTMLElement | null>;
|
|
15
15
|
/** Whether the loupe is turned on. Hold-to-peek shows it regardless. */
|
|
16
16
|
enabled: boolean;
|
|
17
|
-
/** Hex
|
|
17
|
+
/** Hex color at a host point. Omitted, the loupe reports no color — which
|
|
18
18
|
* is the honest answer for a surface with no pixels to read. */
|
|
19
19
|
sample?: (p: LoupePoint) => string | null;
|
|
20
20
|
}
|
|
@@ -37,6 +37,8 @@ export {
|
|
|
37
37
|
type ChromaCurve,
|
|
38
38
|
type ChromaCurvePoint,
|
|
39
39
|
ComboBox,
|
|
40
|
+
type ComboBoxCommit,
|
|
41
|
+
type ComboBoxFilter,
|
|
40
42
|
ComboBoxItem,
|
|
41
43
|
type ComboBoxItemProps,
|
|
42
44
|
type ComboBoxOption,
|
|
@@ -100,6 +102,9 @@ export {
|
|
|
100
102
|
type LayerStackProps,
|
|
101
103
|
type LogicalMod,
|
|
102
104
|
type LogicalModSpec,
|
|
105
|
+
MenuButton,
|
|
106
|
+
type MenuButtonItem,
|
|
107
|
+
type MenuButtonProps,
|
|
103
108
|
MINUS_SIGN,
|
|
104
109
|
NumberField,
|
|
105
110
|
type NumberFieldProps,
|
|
@@ -188,6 +193,10 @@ export {
|
|
|
188
193
|
type TrackCtx,
|
|
189
194
|
type UseReorderDragListOptions,
|
|
190
195
|
type UseRovingTabIndexOptions,
|
|
196
|
+
useAsyncOptions,
|
|
197
|
+
type AsyncOption,
|
|
198
|
+
type AsyncOptionsResult,
|
|
199
|
+
type UseAsyncOptionsProps,
|
|
191
200
|
useReorderDragList,
|
|
192
201
|
useRovingTabIndex,
|
|
193
202
|
visiblePrefSubtree,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { localStorageAdapter } from '../state/adapters';
|
|
3
|
+
import { Persistence } from '../state/Persistence';
|
|
2
4
|
import { FloatingPanel } from './FloatingPanel';
|
|
3
5
|
import { Legend } from './Legend';
|
|
4
6
|
|
|
@@ -43,4 +45,14 @@ export const HoldingALegend: Story = {
|
|
|
43
45
|
},
|
|
44
46
|
};
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
// Remembered only under a provider; this one keeps it across story reloads.
|
|
49
|
+
export const Remembered: Story = {
|
|
50
|
+
args: { persist: 'story-panel' },
|
|
51
|
+
decorators: [
|
|
52
|
+
(Story) => (
|
|
53
|
+
<Persistence storageKey="labkit-story" storage={localStorageAdapter}>
|
|
54
|
+
<Story />
|
|
55
|
+
</Persistence>
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createMemoryAdapter } from '../state/adapters';
|
|
4
|
+
import { labPrefix } from '../state/labRecords';
|
|
5
|
+
import { Persistence } from '../state/Persistence';
|
|
3
6
|
import { FloatingPanel } from './FloatingPanel';
|
|
4
7
|
|
|
5
8
|
// The shared stub reports one size for every element, which makes the panel and
|
|
@@ -219,34 +222,62 @@ describe('FloatingPanel dragging', () => {
|
|
|
219
222
|
});
|
|
220
223
|
|
|
221
224
|
describe('FloatingPanel persistence', () => {
|
|
222
|
-
|
|
223
|
-
|
|
225
|
+
const PLACE = `${labPrefix('fp')}value:lab:legend`;
|
|
226
|
+
|
|
227
|
+
function persisted(backing: Map<string, unknown>, ui: React.ReactElement) {
|
|
228
|
+
return render(
|
|
229
|
+
<Persistence storageKey="fp" storage={createMemoryAdapter(backing)}>
|
|
230
|
+
<div>{ui}</div>
|
|
231
|
+
</Persistence>,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
it('writes its placement under its persist name once dragged', async () => {
|
|
236
|
+
const backing = new Map<string, unknown>();
|
|
237
|
+
const { container } = persisted(backing, <FloatingPanel persist="legend">x</FloatingPanel>);
|
|
238
|
+
await waitFor(() => expect(container.querySelector('.lk-floating-panel')).not.toBeNull());
|
|
239
|
+
const panel = container.querySelector('.lk-floating-panel') as HTMLElement;
|
|
224
240
|
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
225
241
|
fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
|
|
226
242
|
fireEvent.pointerUp(panel);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
243
|
+
await waitFor(() =>
|
|
244
|
+
expect(backing.get(PLACE)).toMatchObject({ x: expect.any(Number), y: expect.any(Number) }),
|
|
245
|
+
);
|
|
246
|
+
expect(backing.get(PLACE)).toHaveProperty('anchor');
|
|
230
247
|
});
|
|
231
248
|
|
|
232
|
-
it('restores what it stored on a later mount', () => {
|
|
233
|
-
|
|
234
|
-
const {
|
|
235
|
-
|
|
249
|
+
it('restores what it stored on a later mount', async () => {
|
|
250
|
+
const backing = new Map<string, unknown>([[PLACE, { x: 140, y: 90, anchor: null }]]);
|
|
251
|
+
const { container } = persisted(backing, <FloatingPanel persist="legend">x</FloatingPanel>);
|
|
252
|
+
// Placed only once both boxes are measured, which can land a render later.
|
|
253
|
+
await waitFor(() =>
|
|
254
|
+
expect(
|
|
255
|
+
(container.querySelector('.lk-floating-panel') as HTMLElement | null)?.style.left,
|
|
256
|
+
).toBe('140px'),
|
|
257
|
+
);
|
|
258
|
+
const panel = container.querySelector('.lk-floating-panel') as HTMLElement;
|
|
236
259
|
expect(panel.style.top).toBe('90px');
|
|
237
260
|
});
|
|
238
261
|
|
|
239
|
-
it('writes nothing
|
|
240
|
-
const
|
|
262
|
+
it('writes nothing without a persist name', async () => {
|
|
263
|
+
const backing = new Map<string, unknown>();
|
|
264
|
+
const { container, unmount } = persisted(backing, <FloatingPanel>x</FloatingPanel>);
|
|
265
|
+
await waitFor(() => expect(container.querySelector('.lk-floating-panel')).not.toBeNull());
|
|
266
|
+
const panel = container.querySelector('.lk-floating-panel') as HTMLElement;
|
|
241
267
|
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
242
268
|
fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
|
|
243
269
|
fireEvent.pointerUp(panel);
|
|
244
|
-
|
|
270
|
+
unmount();
|
|
271
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
272
|
+
expect(backing.size).toBe(0);
|
|
245
273
|
});
|
|
246
274
|
|
|
247
|
-
it('
|
|
248
|
-
|
|
249
|
-
|
|
275
|
+
it('forgets its placement with no provider above it', () => {
|
|
276
|
+
const { panel } = renderPanel(<FloatingPanel persist="legend">x</FloatingPanel>);
|
|
277
|
+
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
278
|
+
fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
|
|
279
|
+
fireEvent.pointerUp(panel);
|
|
280
|
+
expect(localStorage.length).toBe(0);
|
|
250
281
|
});
|
|
251
282
|
});
|
|
252
283
|
|
|
@@ -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
|
+
});
|