@weasel-js/labkit 1.1.0 → 1.3.0-pre.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 +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
- package/dist/canvas/index.d.ts +8 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/chunk-HFDVGF4X.js +170 -0
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +5 -2
- package/dist/index.d.ts +307 -107
- package/dist/index.js +797 -540
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3804 -3661
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +543 -144
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +4 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +5 -2
- package/package.json +24 -7
- package/src/canvas/AGENTS.md +12 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.test.ts +18 -0
- package/src/index.ts +38 -3
- package/src/instrument/SineWave.smoke.test.tsx +9 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +38 -3
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.stories.tsx +1 -2
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +19 -9
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +16 -2
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +8 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +6 -0
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +161 -0
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +73 -0
- package/src/trial/Trial.less +121 -4
- package/src/trial/Trial.stories.tsx +9 -1
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +112 -45
- package/src/trial/TrialChrome.tsx +110 -64
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js +0 -5986
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
- package/dist/chunk-NRKWVTVT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -53
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { FloatingPanel } from './FloatingPanel';
|
|
4
|
+
|
|
5
|
+
// The shared stub reports one size for every element, which makes the panel and
|
|
6
|
+
// its container the same box. Report per-target sizes instead so placement math
|
|
7
|
+
// has something real to chew on. Keyed by class, because `observe()` fires
|
|
8
|
+
// synchronously inside the effect — before a test could register an element.
|
|
9
|
+
const sizes = { host: { w: 400, h: 300 }, panel: { w: 100, h: 40 } };
|
|
10
|
+
const realRO = globalThis.ResizeObserver;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
localStorage.clear();
|
|
14
|
+
sizes.host = { w: 400, h: 300 };
|
|
15
|
+
sizes.panel = { w: 100, h: 40 };
|
|
16
|
+
globalThis.ResizeObserver = class {
|
|
17
|
+
#cb: ResizeObserverCallback;
|
|
18
|
+
constructor(cb: ResizeObserverCallback) {
|
|
19
|
+
this.#cb = cb;
|
|
20
|
+
}
|
|
21
|
+
observe(target: Element) {
|
|
22
|
+
const { w, h } = target.classList.contains('lk-floating-panel') ? sizes.panel : sizes.host;
|
|
23
|
+
const contentRect = { width: w, height: h, x: 0, y: 0, top: 0, left: 0 };
|
|
24
|
+
this.#cb([{ target, contentRect } as ResizeObserverEntry], this);
|
|
25
|
+
}
|
|
26
|
+
unobserve() {}
|
|
27
|
+
disconnect() {}
|
|
28
|
+
} as unknown as typeof ResizeObserver;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
globalThis.ResizeObserver = realRO;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Renders a panel in a 400x300 host, the panel itself measuring 100x40. */
|
|
36
|
+
function renderPanel(ui: React.ReactElement) {
|
|
37
|
+
const result = render(<div>{ui}</div>);
|
|
38
|
+
return { ...result, panel: result.container.querySelector('.lk-floating-panel') as HTMLElement };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('FloatingPanel', () => {
|
|
42
|
+
it('renders its children', () => {
|
|
43
|
+
render(<FloatingPanel>hello</FloatingPanel>);
|
|
44
|
+
expect(screen.getByText('hello')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is absolutely positioned so it floats over whatever it sits in', () => {
|
|
48
|
+
const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
49
|
+
expect(panel.style.position).toBe('absolute');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('accepts an extra class name', () => {
|
|
53
|
+
const { container } = render(<FloatingPanel className="is-mine">x</FloatingPanel>);
|
|
54
|
+
expect(container.querySelector('.lk-floating-panel')?.className).toContain('is-mine');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rests in the bottom-left corner by default, one inset in', () => {
|
|
58
|
+
const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
59
|
+
expect(panel.style.left).toBe('12px');
|
|
60
|
+
expect(panel.style.top).toBe('248px'); // 300 - 40 - 12
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('honors the anchor it is given', () => {
|
|
64
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-right">x</FloatingPanel>);
|
|
65
|
+
expect(panel.style.left).toBe('288px'); // 400 - 100 - 12
|
|
66
|
+
expect(panel.style.top).toBe('12px');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('honors a custom inset', () => {
|
|
70
|
+
const { panel } = renderPanel(
|
|
71
|
+
<FloatingPanel anchor="top-left" inset={30}>
|
|
72
|
+
x
|
|
73
|
+
</FloatingPanel>,
|
|
74
|
+
);
|
|
75
|
+
expect(panel.style.left).toBe('30px');
|
|
76
|
+
expect(panel.style.top).toBe('30px');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('stays hidden until it has been measured and placed', () => {
|
|
80
|
+
sizes.panel = { w: 0, h: 0 };
|
|
81
|
+
const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
82
|
+
// The strategy withholds an item with no size, so there is no rect to paint.
|
|
83
|
+
expect(panel.getAttribute('data-placed')).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('measures its border box, so padding does not push it past a corner', () => {
|
|
87
|
+
// Same content box, but 8px padding and a 1px border on every side.
|
|
88
|
+
const realObserve = globalThis.ResizeObserver;
|
|
89
|
+
globalThis.ResizeObserver = class {
|
|
90
|
+
#cb: ResizeObserverCallback;
|
|
91
|
+
constructor(cb: ResizeObserverCallback) {
|
|
92
|
+
this.#cb = cb;
|
|
93
|
+
}
|
|
94
|
+
observe(target: Element) {
|
|
95
|
+
const isPanel = target.classList.contains('lk-floating-panel');
|
|
96
|
+
const { w, h } = isPanel ? sizes.panel : sizes.host;
|
|
97
|
+
const contentRect = { width: w, height: h, x: 0, y: 0, top: 0, left: 0 };
|
|
98
|
+
const entry = {
|
|
99
|
+
target,
|
|
100
|
+
contentRect,
|
|
101
|
+
...(isPanel ? { borderBoxSize: [{ inlineSize: w + 18, blockSize: h + 18 }] } : {}),
|
|
102
|
+
};
|
|
103
|
+
this.#cb([entry as unknown as ResizeObserverEntry], this);
|
|
104
|
+
}
|
|
105
|
+
unobserve() {}
|
|
106
|
+
disconnect() {}
|
|
107
|
+
} as unknown as typeof ResizeObserver;
|
|
108
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-right">x</FloatingPanel>);
|
|
109
|
+
globalThis.ResizeObserver = realObserve;
|
|
110
|
+
expect(panel.style.left).toBe('270px'); // 400 - (100 + 18) - 12
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('marks itself placed once it has a rect', () => {
|
|
114
|
+
const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
115
|
+
expect(panel.dataset.placed).toBe('true');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('FloatingPanel dragging', () => {
|
|
120
|
+
it('marks itself dragging between pointerdown and pointerup', () => {
|
|
121
|
+
const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
122
|
+
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
123
|
+
expect(panel.dataset.dragging).toBe('true');
|
|
124
|
+
fireEvent.pointerUp(panel);
|
|
125
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('moves with the pointer, accumulating across moves', () => {
|
|
129
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
130
|
+
expect(panel.style.left).toBe('12px');
|
|
131
|
+
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
132
|
+
fireEvent.pointerMove(panel, { clientX: 200, clientY: 180 });
|
|
133
|
+
expect(panel.style.left).toBe('100px');
|
|
134
|
+
expect(panel.style.top).toBe('80px');
|
|
135
|
+
fireEvent.pointerMove(panel, { clientX: 250, clientY: 180 });
|
|
136
|
+
expect(panel.style.left).toBe('150px');
|
|
137
|
+
expect(panel.style.top).toBe('80px');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('snaps to a corner when dropped near one', () => {
|
|
141
|
+
const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
|
|
142
|
+
fireEvent.pointerDown(panel, { clientX: 0, clientY: 0 });
|
|
143
|
+
// Aim at the bottom-right corner; the snap threshold captures it.
|
|
144
|
+
fireEvent.pointerMove(panel, { clientX: 400, clientY: 300 });
|
|
145
|
+
expect(panel.style.left).toBe('288px'); // 400 - 100 - 12
|
|
146
|
+
expect(panel.style.top).toBe('248px'); // 300 - 40 - 12
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('refuses a corner it was not given', () => {
|
|
150
|
+
const { panel } = renderPanel(
|
|
151
|
+
<FloatingPanel anchor="top-left" snapCorners={['top-left']}>
|
|
152
|
+
x
|
|
153
|
+
</FloatingPanel>,
|
|
154
|
+
);
|
|
155
|
+
fireEvent.pointerDown(panel, { clientX: 0, clientY: 0 });
|
|
156
|
+
fireEvent.pointerMove(panel, { clientX: 400, clientY: 300 });
|
|
157
|
+
// Clamped inside the container, but not snapped to the bottom-right inset.
|
|
158
|
+
expect(panel.style.left).toBe('300px');
|
|
159
|
+
expect(panel.style.top).toBe('260px');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not start a drag from an interactive child', () => {
|
|
163
|
+
const { panel } = renderPanel(
|
|
164
|
+
<FloatingPanel>
|
|
165
|
+
<button type="button">press</button>
|
|
166
|
+
</FloatingPanel>,
|
|
167
|
+
);
|
|
168
|
+
fireEvent.pointerDown(screen.getByRole('button'), { clientX: 10, clientY: 10 });
|
|
169
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does not start a drag from a child opting out', () => {
|
|
173
|
+
const { panel } = renderPanel(
|
|
174
|
+
<FloatingPanel>
|
|
175
|
+
<span data-no-drag="">nope</span>
|
|
176
|
+
</FloatingPanel>,
|
|
177
|
+
);
|
|
178
|
+
fireEvent.pointerDown(screen.getByText('nope'), { clientX: 10, clientY: 10 });
|
|
179
|
+
expect(panel.dataset.dragging).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('stops the pointerdown reaching a pan/zoom surface underneath', () => {
|
|
183
|
+
const onDown = vi.fn();
|
|
184
|
+
const { container } = render(
|
|
185
|
+
<div onPointerDown={onDown}>
|
|
186
|
+
<FloatingPanel>x</FloatingPanel>
|
|
187
|
+
</div>,
|
|
188
|
+
);
|
|
189
|
+
fireEvent.pointerDown(container.querySelector('.lk-floating-panel') as HTMLElement, {
|
|
190
|
+
clientX: 5,
|
|
191
|
+
clientY: 5,
|
|
192
|
+
});
|
|
193
|
+
expect(onDown).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('lets a pointerdown on an interactive child through to the surface', () => {
|
|
197
|
+
const onDown = vi.fn();
|
|
198
|
+
render(
|
|
199
|
+
<div onPointerDown={onDown}>
|
|
200
|
+
<FloatingPanel>
|
|
201
|
+
<button type="button">press</button>
|
|
202
|
+
</FloatingPanel>
|
|
203
|
+
</div>,
|
|
204
|
+
);
|
|
205
|
+
fireEvent.pointerDown(screen.getByRole('button'), { clientX: 5, clientY: 5 });
|
|
206
|
+
expect(onDown).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('FloatingPanel persistence', () => {
|
|
211
|
+
it('writes its placement under the given key once dragged', () => {
|
|
212
|
+
const { panel } = renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>);
|
|
213
|
+
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
214
|
+
fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
|
|
215
|
+
fireEvent.pointerUp(panel);
|
|
216
|
+
const stored = JSON.parse(localStorage.getItem('k') ?? 'null');
|
|
217
|
+
expect(stored).toMatchObject({ x: expect.any(Number), y: expect.any(Number) });
|
|
218
|
+
expect(stored).toHaveProperty('anchor');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('restores what it stored on a later mount', () => {
|
|
222
|
+
localStorage.setItem('k', JSON.stringify({ x: 140, y: 90, anchor: null }));
|
|
223
|
+
const { panel } = renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>);
|
|
224
|
+
expect(panel.style.left).toBe('140px');
|
|
225
|
+
expect(panel.style.top).toBe('90px');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('writes nothing when no key is given', () => {
|
|
229
|
+
const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
|
|
230
|
+
fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
|
|
231
|
+
fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
|
|
232
|
+
fireEvent.pointerUp(panel);
|
|
233
|
+
expect(localStorage.length).toBe(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('survives a corrupt stored value rather than throwing', () => {
|
|
237
|
+
localStorage.setItem('k', '{{{');
|
|
238
|
+
expect(() => renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>)).not.toThrow();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
type Corner,
|
|
5
|
+
DEFAULT_ANCHOR,
|
|
6
|
+
DEFAULT_INSET,
|
|
7
|
+
FLOATING_DRAG_PREFIX,
|
|
8
|
+
type FloatingPlacement,
|
|
9
|
+
floatingStrategy,
|
|
10
|
+
} from 'windease';
|
|
11
|
+
|
|
12
|
+
/** The strategy holds a map keyed by item id; a panel is always a lone item. */
|
|
13
|
+
const ITEM_ID = 'panel';
|
|
14
|
+
|
|
15
|
+
/** Props for `<FloatingPanel>`. */
|
|
16
|
+
export interface FloatingPanelProps {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
/** Corner it rests in before it has ever been dragged. Default `'bottom-left'`. */
|
|
19
|
+
anchor?: Corner;
|
|
20
|
+
/** Corners that may capture it. Default: all four. */
|
|
21
|
+
snapCorners?: readonly Corner[];
|
|
22
|
+
/** Pixels in from a corner when snapped. Default 12. */
|
|
23
|
+
inset?: number;
|
|
24
|
+
/** localStorage key to remember its position under. Omit to forget on reload. */
|
|
25
|
+
storageKey?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The panel's footprint is its border box — `contentRect` excludes padding and
|
|
31
|
+
* border, which would place a panel that then overflows its corner by exactly
|
|
32
|
+
* that much.
|
|
33
|
+
*/
|
|
34
|
+
function borderBoxOf(entry: ResizeObserverEntry): { w: number; h: number } {
|
|
35
|
+
const box = entry.borderBoxSize?.[0];
|
|
36
|
+
if (box) return { w: box.inlineSize, h: box.blockSize };
|
|
37
|
+
return { w: entry.contentRect.width, h: entry.contentRect.height };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A pointerdown on one of these is the child's, not a drag. */
|
|
41
|
+
function isInteractive(target: EventTarget | null): boolean {
|
|
42
|
+
if (!(target instanceof Element)) return false;
|
|
43
|
+
return target.closest('input, button, a, select, textarea, [data-no-drag]') !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readStored(key: string | undefined): FloatingPlacement | null {
|
|
47
|
+
if (!key) return null;
|
|
48
|
+
try {
|
|
49
|
+
const raw = localStorage.getItem(key);
|
|
50
|
+
return raw ? (JSON.parse(raw) as FloatingPlacement) : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A draggable box that floats over its offset parent and snaps to its corners.
|
|
58
|
+
*
|
|
59
|
+
* Drives windease's `floatingStrategy` as a pure function rather than mounting a
|
|
60
|
+
* windease container: a lab overlay has one item and no zone tree, so the node
|
|
61
|
+
* model would be all cost. Pointer handling is therefore this component's, and
|
|
62
|
+
* windease's own affordance rendering never participates.
|
|
63
|
+
*/
|
|
64
|
+
export function FloatingPanel({
|
|
65
|
+
children,
|
|
66
|
+
anchor = DEFAULT_ANCHOR,
|
|
67
|
+
snapCorners,
|
|
68
|
+
inset = DEFAULT_INSET,
|
|
69
|
+
storageKey,
|
|
70
|
+
className,
|
|
71
|
+
}: FloatingPanelProps) {
|
|
72
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
73
|
+
const strategy = useMemo(() => floatingStrategy(), []);
|
|
74
|
+
const options = useMemo(() => ({ defaultAnchor: anchor, inset }), [anchor, inset]);
|
|
75
|
+
|
|
76
|
+
const [container, setContainer] = useState({ w: 0, h: 0 });
|
|
77
|
+
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
78
|
+
const [place, setPlace] = useState<FloatingPlacement>(
|
|
79
|
+
() => readStored(storageKey) ?? { x: 0, y: 0, anchor },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!storageKey) return;
|
|
84
|
+
try {
|
|
85
|
+
localStorage.setItem(storageKey, JSON.stringify(place));
|
|
86
|
+
} catch {
|
|
87
|
+
// A full or disabled store is not worth failing a lab over.
|
|
88
|
+
}
|
|
89
|
+
}, [place, storageKey]);
|
|
90
|
+
|
|
91
|
+
const item = useMemo(
|
|
92
|
+
() => ({
|
|
93
|
+
id: ITEM_ID,
|
|
94
|
+
meta: { floating: true, ...(snapCorners ? { snapCorners: [...snapCorners] } : {}) },
|
|
95
|
+
natural: size,
|
|
96
|
+
}),
|
|
97
|
+
[size, snapCorners],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const el = ref.current;
|
|
102
|
+
const parent = el?.offsetParent ?? el?.parentElement;
|
|
103
|
+
if (!el || !parent) return;
|
|
104
|
+
// Sizes come off the entry, not the DOM: jsdom reports 0 for every
|
|
105
|
+
// clientWidth/offsetWidth, and an unmeasured item is withheld from layout.
|
|
106
|
+
const observer = new ResizeObserver((entries) => {
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (entry.target === parent) {
|
|
109
|
+
setContainer({ w: entry.contentRect.width, h: entry.contentRect.height });
|
|
110
|
+
} else {
|
|
111
|
+
setSize(borderBoxOf(entry));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
observer.observe(parent);
|
|
116
|
+
observer.observe(el);
|
|
117
|
+
return () => observer.disconnect();
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
const rect = strategy
|
|
121
|
+
.layout({
|
|
122
|
+
items: [item],
|
|
123
|
+
container,
|
|
124
|
+
state: { at: { [ITEM_ID]: place }, inner: undefined },
|
|
125
|
+
options,
|
|
126
|
+
})
|
|
127
|
+
.placements.get(ITEM_ID);
|
|
128
|
+
|
|
129
|
+
const dragging = useRef<{ x: number; y: number } | null>(null);
|
|
130
|
+
const [isDragging, setDragging] = useState(false);
|
|
131
|
+
|
|
132
|
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
133
|
+
if (isInteractive(e.target)) return;
|
|
134
|
+
// The canvas stack underneath owns pan/zoom on the same pointer events.
|
|
135
|
+
e.stopPropagation();
|
|
136
|
+
dragging.current = { x: e.clientX, y: e.clientY };
|
|
137
|
+
setDragging(true);
|
|
138
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
142
|
+
const from = dragging.current;
|
|
143
|
+
if (!from) return;
|
|
144
|
+
const dx = e.clientX - from.x;
|
|
145
|
+
const dy = e.clientY - from.y;
|
|
146
|
+
if (dx === 0 && dy === 0) return;
|
|
147
|
+
dragging.current = { x: e.clientX, y: e.clientY };
|
|
148
|
+
setPlace(
|
|
149
|
+
(prev) =>
|
|
150
|
+
strategy.reduce?.(
|
|
151
|
+
{ at: { [ITEM_ID]: prev }, inner: undefined },
|
|
152
|
+
{
|
|
153
|
+
affordanceId: `${FLOATING_DRAG_PREFIX}${ITEM_ID}`,
|
|
154
|
+
kind: 'drag',
|
|
155
|
+
payload: { dx, dy },
|
|
156
|
+
},
|
|
157
|
+
{ container, options, items: [item] },
|
|
158
|
+
).at[ITEM_ID] ?? prev,
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
163
|
+
if (!dragging.current) return;
|
|
164
|
+
dragging.current = null;
|
|
165
|
+
setDragging(false);
|
|
166
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
className={className ? `lk-floating-panel ${className}` : 'lk-floating-panel'}
|
|
172
|
+
data-dragging={isDragging ? 'true' : undefined}
|
|
173
|
+
data-placed={rect ? 'true' : undefined}
|
|
174
|
+
onPointerCancel={endDrag}
|
|
175
|
+
onPointerDown={onPointerDown}
|
|
176
|
+
onPointerMove={onPointerMove}
|
|
177
|
+
onPointerUp={endDrag}
|
|
178
|
+
ref={ref}
|
|
179
|
+
style={
|
|
180
|
+
{
|
|
181
|
+
position: 'absolute',
|
|
182
|
+
left: `${rect?.x ?? 0}px`,
|
|
183
|
+
top: `${rect?.y ?? 0}px`,
|
|
184
|
+
} as CSSProperties
|
|
185
|
+
}
|
|
186
|
+
>
|
|
187
|
+
{children}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -35,4 +35,48 @@ describe('FpsMeter', () => {
|
|
|
35
35
|
|
|
36
36
|
vi.unstubAllGlobals();
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
test('does not report a fabricated rate after a spell in a background tab', () => {
|
|
40
|
+
const frames: FrameRequestCallback[] = [];
|
|
41
|
+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
42
|
+
frames.push(cb);
|
|
43
|
+
return frames.length;
|
|
44
|
+
});
|
|
45
|
+
vi.stubGlobal('cancelAnimationFrame', () => {});
|
|
46
|
+
const setHidden = (next: boolean) => {
|
|
47
|
+
Object.defineProperty(document, 'hidden', { configurable: true, get: () => next });
|
|
48
|
+
document.dispatchEvent(new Event('visibilitychange'));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const run = (t: number) => {
|
|
52
|
+
const due = frames.splice(0, frames.length);
|
|
53
|
+
for (const cb of due) cb(t);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
render(<FpsMeter />);
|
|
57
|
+
act(() => {
|
|
58
|
+
run(0);
|
|
59
|
+
run(16.67);
|
|
60
|
+
run(33.34);
|
|
61
|
+
});
|
|
62
|
+
const before = screen.getByText(/FPS\s+\d+/).textContent;
|
|
63
|
+
|
|
64
|
+
// An hour hidden. The frame that lands on resume is not a 60-minute frame,
|
|
65
|
+
// and must not enter the rolling average as one.
|
|
66
|
+
act(() => {
|
|
67
|
+
setHidden(true);
|
|
68
|
+
});
|
|
69
|
+
act(() => {
|
|
70
|
+
setHidden(false);
|
|
71
|
+
});
|
|
72
|
+
act(() => {
|
|
73
|
+
run(3_600_000);
|
|
74
|
+
run(3_600_016.67);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText(/FPS\s+\d+/).textContent).toBe(before);
|
|
78
|
+
|
|
79
|
+
setHidden(false);
|
|
80
|
+
vi.unstubAllGlobals();
|
|
81
|
+
});
|
|
38
82
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useVisibleRaf } from '@weasel-js/core';
|
|
1
2
|
import { useEffect, useRef, useState } from 'react';
|
|
2
3
|
import { rollingAverage } from './fpsAverage';
|
|
3
4
|
|
|
@@ -8,10 +9,10 @@ export function FpsMeter() {
|
|
|
8
9
|
const [fps, setFps] = useState(0);
|
|
9
10
|
const samplesRef = useRef<number[]>([]);
|
|
10
11
|
const lastTimeRef = useRef<number | null>(null);
|
|
12
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const tick = (time: number) => {
|
|
14
|
+
const loop = useVisibleRaf(
|
|
15
|
+
(time) => {
|
|
15
16
|
const last = lastTimeRef.current;
|
|
16
17
|
if (last !== null) {
|
|
17
18
|
const delta = time - last;
|
|
@@ -23,14 +24,24 @@ export function FpsMeter() {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
lastTimeRef.current = time;
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
loop.request();
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
target: rootRef,
|
|
31
|
+
// The gap spent suspended is not a frame that took an hour to draw.
|
|
32
|
+
onResume: () => {
|
|
33
|
+
lastTimeRef.current = null;
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
loop.request();
|
|
40
|
+
return () => loop.cancel();
|
|
41
|
+
}, [loop]);
|
|
31
42
|
|
|
32
43
|
return (
|
|
33
|
-
<div className="lk-fps-meter">
|
|
44
|
+
<div className="lk-fps-meter" ref={rootRef}>
|
|
34
45
|
<span className="lk-fps-meter-value">FPS {fps}</span>
|
|
35
46
|
</div>
|
|
36
47
|
);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
.lk-job {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--wzl-space-sm);
|
|
5
|
+
min-width: 0;
|
|
6
|
+
font-size: var(--wzl-font-size-sm);
|
|
7
|
+
color: var(--wzl-fg-muted);
|
|
8
|
+
|
|
9
|
+
&__track {
|
|
10
|
+
flex: 0 1 120px;
|
|
11
|
+
height: 3px;
|
|
12
|
+
border-radius: var(--wzl-radius-pill);
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
background: color-mix(in srgb, currentColor 18%, transparent);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
&__fill {
|
|
18
|
+
height: 100%;
|
|
19
|
+
background: var(--wzl-accent);
|
|
20
|
+
transform-origin: left center;
|
|
21
|
+
transition: transform 120ms linear;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A job that never reports a total sweeps instead of filling, so the bar
|
|
25
|
+
// says "working" without implying a position.
|
|
26
|
+
&__track--indeterminate &__fill,
|
|
27
|
+
&__track--indeterminate > .lk-job__fill {
|
|
28
|
+
animation: lk-job-sweep 1.1s ease-in-out infinite;
|
|
29
|
+
transform-origin: center;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
&__count {
|
|
33
|
+
font-variant-numeric: tabular-nums;
|
|
34
|
+
white-space: nowrap;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&__failures,
|
|
38
|
+
&__error {
|
|
39
|
+
display: inline-flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
gap: 4px;
|
|
42
|
+
min-width: 0;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
text-overflow: ellipsis;
|
|
45
|
+
white-space: nowrap;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&__failures { color: var(--wzl-warning); }
|
|
49
|
+
&__error { color: var(--wzl-danger); }
|
|
50
|
+
|
|
51
|
+
&__cancel {
|
|
52
|
+
height: 20px;
|
|
53
|
+
padding: 0 var(--wzl-space-sm);
|
|
54
|
+
font-size: var(--wzl-font-size-sm);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
&--error &__fill { background: var(--wzl-danger); }
|
|
58
|
+
&--done &__fill { background: color-mix(in srgb, var(--wzl-accent) 55%, transparent); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@keyframes lk-job-sweep {
|
|
62
|
+
0% { transform: scaleX(0.15) translateX(-260%); }
|
|
63
|
+
50% { transform: scaleX(0.5) translateX(0); }
|
|
64
|
+
100% { transform: scaleX(0.15) translateX(260%); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@media (prefers-reduced-motion: reduce) {
|
|
68
|
+
.lk-job__track--indeterminate > .lk-job__fill { animation: none; transform: scaleX(0.35); }
|
|
69
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import type { JobHandle } from '../job/types';
|
|
3
|
+
import { JobProgress } from './JobProgress';
|
|
4
|
+
|
|
5
|
+
const base: JobHandle = {
|
|
6
|
+
status: 'running',
|
|
7
|
+
done: 0,
|
|
8
|
+
total: null,
|
|
9
|
+
failures: [],
|
|
10
|
+
error: null,
|
|
11
|
+
start: () => {},
|
|
12
|
+
cancel: () => {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const meta: Meta<typeof JobProgress> = {
|
|
16
|
+
title: 'labkit/primitives/JobProgress',
|
|
17
|
+
component: JobProgress,
|
|
18
|
+
};
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof JobProgress>;
|
|
21
|
+
|
|
22
|
+
/** A job that reported a total drives a determinate bar. */
|
|
23
|
+
export const Determinate: Story = {
|
|
24
|
+
args: { job: { ...base, done: 34, total: 120 } },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A job that never reports a total sweeps instead, so the bar says "working"
|
|
28
|
+
* without implying a position. */
|
|
29
|
+
export const Indeterminate: Story = { args: { job: { ...base, done: 34 } } };
|
|
30
|
+
|
|
31
|
+
export const WithFailures: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
job: {
|
|
34
|
+
...base,
|
|
35
|
+
done: 120,
|
|
36
|
+
total: 120,
|
|
37
|
+
status: 'done',
|
|
38
|
+
failures: [
|
|
39
|
+
{ index: 3, error: 'timed out' },
|
|
40
|
+
{ index: 91, error: 'bad input' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const Failed: Story = {
|
|
47
|
+
args: { job: { ...base, done: 12, total: 120, status: 'error', error: 'worker crashed' } },
|
|
48
|
+
};
|