@weasel-js/labkit 1.2.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/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
- 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/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
- package/dist/canvas/index.d.ts +7 -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-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
- package/dist/chunk-HFDVGF4X.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-G5TJVQQT.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-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
- 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 +4 -2
- package/dist/index.d.ts +246 -106
- package/dist/index.js +673 -559
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +5 -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 +3799 -3672
- 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 +542 -153
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +4 -2
- package/package.json +16 -7
- package/src/canvas/AGENTS.md +4 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- 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.ts +25 -3
- package/src/instrument/SineWave.smoke.test.tsx +8 -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 +27 -4
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/lab/Lab.chrome.test.tsx +65 -0
- 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.test.tsx +20 -10
- package/src/lab/Workspace.tsx +5 -8
- 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 +11 -3
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +4 -0
- package/src/styles.less +6 -0
- package/src/surface/useTiledSurface.ts +14 -10
- 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 +13 -15
- package/src/trial/Trial.less +115 -10
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +80 -39
- package/src/trial/TrialChrome.tsx +99 -80
- 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-JFAYj5Tv.d.ts +0 -144
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- 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-73KA7WBO.js +0 -5992
- package/dist/chunk-73KA7WBO.js.map +0 -1
- package/dist/chunk-BOHF3PQO.js.map +0 -1
- package/dist/chunk-G5TJVQQT.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/dist/chunk-THBG7FQZ.js.map +0 -1
- package/dist/chunk-VUU5UXHE.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 -54
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloneIcon,
|
|
3
|
+
CloseIcon,
|
|
4
|
+
FitIcon,
|
|
5
|
+
RedoIcon,
|
|
6
|
+
ResetIcon,
|
|
7
|
+
SnapshotIcon,
|
|
8
|
+
UndoIcon,
|
|
9
|
+
ZoomInIcon,
|
|
10
|
+
ZoomOutIcon,
|
|
11
|
+
} from '@weasel-js/ui';
|
|
12
|
+
import type { ControlRenderer } from '../config/types';
|
|
13
|
+
import { ControlPanel } from '../controls/ControlPanel';
|
|
14
|
+
import type { Instrument } from '../instrument/types';
|
|
15
|
+
import { Select } from '../passthrough/weasel-ui';
|
|
16
|
+
import { FpsMeter } from '../primitives/FpsMeter';
|
|
17
|
+
import { ScaleIndicator } from '../primitives/ScaleIndicator';
|
|
18
|
+
import { ZoomControl } from '../primitives/ZoomControl';
|
|
19
|
+
import type { TrialChromeContext, TrialContribution } from './types';
|
|
20
|
+
|
|
21
|
+
const ZOOM_STEP = 1.25;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The contributions a trial gets from what its instrument declared. This is
|
|
25
|
+
* the whole "declaring a capability provides the chrome" rule — it replaces
|
|
26
|
+
* the presence checks that used to be spread across the runtime.
|
|
27
|
+
*/
|
|
28
|
+
export function builtinContributions(
|
|
29
|
+
instrument: Instrument,
|
|
30
|
+
ctx: TrialChromeContext,
|
|
31
|
+
controls?: Record<string, ControlRenderer>,
|
|
32
|
+
): TrialContribution[] {
|
|
33
|
+
const out: TrialContribution[] = [];
|
|
34
|
+
const zoom = ctx.zoom;
|
|
35
|
+
|
|
36
|
+
if (instrument.undo != null) {
|
|
37
|
+
out.push({
|
|
38
|
+
id: 'undo',
|
|
39
|
+
region: 'toolbar',
|
|
40
|
+
group: 'history',
|
|
41
|
+
item: {
|
|
42
|
+
icon: UndoIcon,
|
|
43
|
+
label: 'Undo',
|
|
44
|
+
shortcut: 'Mod+Z',
|
|
45
|
+
disabled: !ctx.canUndo,
|
|
46
|
+
onActivate: ctx.undo,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
out.push({
|
|
50
|
+
id: 'redo',
|
|
51
|
+
region: 'toolbar',
|
|
52
|
+
group: 'history',
|
|
53
|
+
item: {
|
|
54
|
+
icon: RedoIcon,
|
|
55
|
+
label: 'Redo',
|
|
56
|
+
shortcut: 'Mod+Shift+Z',
|
|
57
|
+
disabled: !ctx.canRedo,
|
|
58
|
+
onActivate: ctx.redo,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A tool id shares the contribution namespace, so a tool called `close`
|
|
64
|
+
// collides with the built-in close button and throws. Both are contributions
|
|
65
|
+
// to one trial's chrome.
|
|
66
|
+
for (const t of instrument.tools?.tools ?? []) {
|
|
67
|
+
out.push({
|
|
68
|
+
id: t.id,
|
|
69
|
+
region: 'palette',
|
|
70
|
+
group: t.group,
|
|
71
|
+
item: { icon: t.icon, label: t.label, shortcut: t.shortcut },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Zoom acts on the view of the trial, so it is a viewport control. A trial
|
|
76
|
+
// holding a non-2D view reports zoom as null and gets none of this.
|
|
77
|
+
if (instrument.canvas != null && zoom !== null) {
|
|
78
|
+
out.push({
|
|
79
|
+
id: 'zoom-out',
|
|
80
|
+
region: 'viewport',
|
|
81
|
+
group: 'zoom',
|
|
82
|
+
item: {
|
|
83
|
+
icon: ZoomOutIcon,
|
|
84
|
+
label: 'Zoom out',
|
|
85
|
+
onActivate: () => ctx.setZoom(zoom / ZOOM_STEP),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
out.push({
|
|
89
|
+
id: 'zoom-in',
|
|
90
|
+
region: 'viewport',
|
|
91
|
+
group: 'zoom',
|
|
92
|
+
item: {
|
|
93
|
+
icon: ZoomInIcon,
|
|
94
|
+
label: 'Zoom in',
|
|
95
|
+
onActivate: () => ctx.setZoom(zoom * ZOOM_STEP),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
out.push({
|
|
99
|
+
id: 'actual-size',
|
|
100
|
+
region: 'viewport',
|
|
101
|
+
group: 'zoom',
|
|
102
|
+
item: { icon: FitIcon, label: 'Actual size', onActivate: () => ctx.setZoom(1) },
|
|
103
|
+
});
|
|
104
|
+
out.push({
|
|
105
|
+
id: 'zoom-control',
|
|
106
|
+
region: 'viewport',
|
|
107
|
+
group: 'zoom',
|
|
108
|
+
render: (c) => <ZoomControl zoom={c.zoom ?? 1} onZoomChange={c.setZoom} />,
|
|
109
|
+
});
|
|
110
|
+
out.push({
|
|
111
|
+
id: 'scale',
|
|
112
|
+
region: 'status',
|
|
113
|
+
group: 'view',
|
|
114
|
+
render: () => <ScaleIndicator />,
|
|
115
|
+
});
|
|
116
|
+
out.push({
|
|
117
|
+
id: 'fps',
|
|
118
|
+
region: 'status',
|
|
119
|
+
group: 'view',
|
|
120
|
+
end: true,
|
|
121
|
+
render: () => <FpsMeter />,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (Object.keys(ctx.configSchema.group.children).length > 0) {
|
|
126
|
+
out.push({
|
|
127
|
+
id: 'settings',
|
|
128
|
+
region: 'sidebar',
|
|
129
|
+
item: {
|
|
130
|
+
title: 'Settings',
|
|
131
|
+
body: (
|
|
132
|
+
<ControlPanel
|
|
133
|
+
schema={ctx.configSchema}
|
|
134
|
+
config={ctx.config as Record<string, unknown>}
|
|
135
|
+
setConfig={(key, value) => ctx.setConfig(String(key), value)}
|
|
136
|
+
renderers={controls}
|
|
137
|
+
/>
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
out.push({
|
|
144
|
+
id: 'snapshot',
|
|
145
|
+
region: 'toolbar',
|
|
146
|
+
group: 'history',
|
|
147
|
+
item: {
|
|
148
|
+
icon: SnapshotIcon,
|
|
149
|
+
label: 'Save snapshot',
|
|
150
|
+
shortcut: 'Mod+S',
|
|
151
|
+
onActivate: () => ctx.saveSnapshot(),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
if (ctx.savedSnapshots.length > 0) {
|
|
155
|
+
// A picker is not an icon button, so it takes the render escape. Held at
|
|
156
|
+
// `selectedKey={null}` so it stays a "load one" action rather than
|
|
157
|
+
// drifting into a display of what was loaded last.
|
|
158
|
+
out.push({
|
|
159
|
+
id: 'snapshot-load',
|
|
160
|
+
region: 'toolbar',
|
|
161
|
+
group: 'trial',
|
|
162
|
+
end: true,
|
|
163
|
+
render: (c) => (
|
|
164
|
+
<Select
|
|
165
|
+
className="lk-toolbar__load-select"
|
|
166
|
+
aria-label="Load snapshot"
|
|
167
|
+
placeholder="Load…"
|
|
168
|
+
selectedKey={null}
|
|
169
|
+
options={c.savedSnapshots.map((sn) => ({ value: sn.id, label: sn.name }))}
|
|
170
|
+
onSelectionChange={(id) => {
|
|
171
|
+
if (id != null) c.loadSnapshot(String(id));
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
out.push({
|
|
178
|
+
id: 'clone',
|
|
179
|
+
region: 'toolbar',
|
|
180
|
+
group: 'trial',
|
|
181
|
+
end: true,
|
|
182
|
+
item: { icon: CloneIcon, label: 'Clone trial', onActivate: ctx.clone },
|
|
183
|
+
});
|
|
184
|
+
out.push({
|
|
185
|
+
id: 'reset',
|
|
186
|
+
region: 'toolbar',
|
|
187
|
+
group: 'trial',
|
|
188
|
+
end: true,
|
|
189
|
+
item: { icon: ResetIcon, label: 'Reset trial', onActivate: ctx.reset },
|
|
190
|
+
});
|
|
191
|
+
out.push({
|
|
192
|
+
id: 'close',
|
|
193
|
+
region: 'titlebar',
|
|
194
|
+
end: true,
|
|
195
|
+
item: {
|
|
196
|
+
icon: CloseIcon,
|
|
197
|
+
label: ctx.isLastTrial ? 'Cannot close the last trial' : 'Close trial',
|
|
198
|
+
danger: true,
|
|
199
|
+
disabled: ctx.isLastTrial,
|
|
200
|
+
onActivate: ctx.close,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { builtinContributions } from './builtins';
|
|
2
|
+
export { mergeContributions, suppressContributions } from './merge';
|
|
3
|
+
export type { PaletteRegionProps } from './regions/PaletteRegion';
|
|
4
|
+
export { PaletteRegion } from './regions/PaletteRegion';
|
|
5
|
+
export type { SidebarRegionProps } from './regions/SidebarRegion';
|
|
6
|
+
export { SidebarRegion } from './regions/SidebarRegion';
|
|
7
|
+
export type { StatusRegionProps } from './regions/StatusRegion';
|
|
8
|
+
export { StatusRegion } from './regions/StatusRegion';
|
|
9
|
+
export type { TitleBarRegionProps } from './regions/TitleBarRegion';
|
|
10
|
+
export { TitleBarRegion } from './regions/TitleBarRegion';
|
|
11
|
+
export type { ToolbarRegionProps } from './regions/ToolbarRegion';
|
|
12
|
+
export { ToolbarRegion } from './regions/ToolbarRegion';
|
|
13
|
+
export type { ViewportRegionProps } from './regions/ViewportRegion';
|
|
14
|
+
export { ViewportRegion } from './regions/ViewportRegion';
|
|
15
|
+
export type {
|
|
16
|
+
IconComponent,
|
|
17
|
+
SidebarSection,
|
|
18
|
+
StatusReadout,
|
|
19
|
+
ToolbarItem,
|
|
20
|
+
ToolItem,
|
|
21
|
+
TrialChromeContext,
|
|
22
|
+
TrialContribution,
|
|
23
|
+
TrialRegion,
|
|
24
|
+
ViewportControl,
|
|
25
|
+
} from './types';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { mergeContributions, suppressContributions } from './merge';
|
|
3
|
+
import type { TrialContribution } from './types';
|
|
4
|
+
|
|
5
|
+
const Glyph = () => null;
|
|
6
|
+
|
|
7
|
+
function toolbarItem(id: string): TrialContribution {
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
region: 'toolbar',
|
|
11
|
+
item: { icon: Glyph, label: id, onActivate: () => {} },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('mergeContributions', () => {
|
|
16
|
+
it('concatenates bundles in order', () => {
|
|
17
|
+
const out = mergeContributions([toolbarItem('a')], [toolbarItem('b'), toolbarItem('c')]);
|
|
18
|
+
expect(out.map((c) => c.id)).toEqual(['a', 'b', 'c']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('throws on a duplicate id, naming it', () => {
|
|
22
|
+
expect(() => mergeContributions([toolbarItem('undo')], [toolbarItem('undo')])).toThrow(
|
|
23
|
+
/duplicate contribution id "undo"/,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('accepts no bundles', () => {
|
|
28
|
+
expect(mergeContributions()).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('suppressContributions', () => {
|
|
33
|
+
it('removes the named ids', () => {
|
|
34
|
+
const out = suppressContributions(
|
|
35
|
+
[toolbarItem('a'), toolbarItem('b'), toolbarItem('c')],
|
|
36
|
+
['b'],
|
|
37
|
+
);
|
|
38
|
+
expect(out.map((c) => c.id)).toEqual(['a', 'c']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws on an id that is not there', () => {
|
|
42
|
+
expect(() => suppressContributions([toolbarItem('a')], ['snapshto'])).toThrow(
|
|
43
|
+
/cannot suppress "snapshto"/,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is a no-op for an empty suppress list', () => {
|
|
48
|
+
const bundle = [toolbarItem('a')];
|
|
49
|
+
expect(suppressContributions(bundle, [])).toEqual(bundle);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { TrialContribution } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Concatenate contribution bundles into one list, preserving order — order
|
|
5
|
+
* decides how a region lays its contributions out, so it is part of the
|
|
6
|
+
* result, not an accident of it.
|
|
7
|
+
*
|
|
8
|
+
* Throws on a duplicate id rather than dropping one: a contribution silently
|
|
9
|
+
* losing to a later bundle is the failure a registry exists to prevent.
|
|
10
|
+
*/
|
|
11
|
+
export function mergeContributions(
|
|
12
|
+
...bundles: readonly TrialContribution[][]
|
|
13
|
+
): TrialContribution[] {
|
|
14
|
+
const out: TrialContribution[] = [];
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
for (const bundle of bundles) {
|
|
17
|
+
for (const entry of bundle) {
|
|
18
|
+
if (seen.has(entry.id)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`[labkit] mergeContributions: duplicate contribution id "${entry.id}". ` +
|
|
21
|
+
'Two bundles registered the same id; rename one, or suppress the ' +
|
|
22
|
+
'built-in before adding yours.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
seen.add(entry.id);
|
|
26
|
+
out.push(entry);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Drop contributions by id. Throws when an id is not present: a typo that
|
|
34
|
+
* silently suppresses nothing is the same class of bug as a duplicate id
|
|
35
|
+
* silently winning.
|
|
36
|
+
*/
|
|
37
|
+
export function suppressContributions(
|
|
38
|
+
bundle: readonly TrialContribution[],
|
|
39
|
+
ids: readonly string[],
|
|
40
|
+
): TrialContribution[] {
|
|
41
|
+
const present = new Set(bundle.map((c) => c.id));
|
|
42
|
+
for (const id of ids) {
|
|
43
|
+
if (!present.has(id)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`[labkit] cannot suppress "${id}": no contribution with that id. ` +
|
|
46
|
+
`Present ids: ${[...present].join(', ')}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const drop = new Set(ids);
|
|
51
|
+
return bundle.filter((c) => !drop.has(c.id));
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.lk-palette-region {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
padding: var(--wzl-space-xs);
|
|
5
|
+
border-right: var(--wzl-border-w) solid var(--wzl-line-subtle);
|
|
6
|
+
background: var(--wzl-surface-sunken);
|
|
7
|
+
|
|
8
|
+
// labkit's `:where(button)` default sets an explicit height, and because
|
|
9
|
+
// :where() carries no specificity it also wins over ToolButton, which sizes
|
|
10
|
+
// itself from padding.
|
|
11
|
+
button {
|
|
12
|
+
height: auto;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
import { PaletteRegion } from './PaletteRegion';
|
|
5
|
+
|
|
6
|
+
const Glyph = () => <svg />;
|
|
7
|
+
|
|
8
|
+
function ctxWith(activeToolId: string | null, setActiveTool = vi.fn()) {
|
|
9
|
+
return { trialId: 't1', activeToolId, setActiveTool } as unknown as TrialChromeContext;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tool(id: string): TrialContribution {
|
|
13
|
+
return { id, region: 'palette', item: { icon: Glyph, label: id } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('PaletteRegion', () => {
|
|
17
|
+
it('renders nothing when empty', () => {
|
|
18
|
+
const { container } = render(<PaletteRegion contributions={[]} ctx={ctxWith(null)} />);
|
|
19
|
+
expect(container).toBeEmptyDOMElement();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('marks the resolved tool as current', () => {
|
|
23
|
+
render(
|
|
24
|
+
<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith('brush')} />,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByRole('button', { name: 'brush' })).toHaveAttribute('aria-current', 'true');
|
|
27
|
+
expect(screen.getByRole('button', { name: 'eraser' })).not.toHaveAttribute('aria-current');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('sets the tool on click', () => {
|
|
31
|
+
const setActiveTool = vi.fn();
|
|
32
|
+
render(<PaletteRegion contributions={[tool('brush')]} ctx={ctxWith(null, setActiveTool)} />);
|
|
33
|
+
screen.getByRole('button', { name: 'brush' }).click();
|
|
34
|
+
expect(setActiveTool).toHaveBeenCalledWith('brush');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
|
|
2
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
3
|
+
|
|
4
|
+
/** Props for `<PaletteRegion>`. */
|
|
5
|
+
export interface PaletteRegionProps {
|
|
6
|
+
contributions: readonly TrialContribution[];
|
|
7
|
+
ctx: TrialChromeContext;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A trial's tool strip. Selection lives in the trial's or the lab's tool
|
|
11
|
+
* slot; this region only reflects it. */
|
|
12
|
+
export function PaletteRegion({ contributions, ctx }: PaletteRegionProps) {
|
|
13
|
+
if (contributions.length === 0) return null;
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className="lk-palette-region"
|
|
17
|
+
role="toolbar"
|
|
18
|
+
aria-label="Tools"
|
|
19
|
+
aria-orientation="vertical"
|
|
20
|
+
>
|
|
21
|
+
<ToolGroup orientation="vertical">
|
|
22
|
+
{contributions.map((c) => {
|
|
23
|
+
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
24
|
+
if (c.region !== 'palette' || !c.item) return null;
|
|
25
|
+
const { icon: Icon, label, shortcut, disabled } = c.item;
|
|
26
|
+
return (
|
|
27
|
+
<ToolButton
|
|
28
|
+
key={c.id}
|
|
29
|
+
icon={<Icon size={16} />}
|
|
30
|
+
label={label}
|
|
31
|
+
shortcut={shortcut}
|
|
32
|
+
active={ctx.activeToolId === c.id}
|
|
33
|
+
disabled={disabled}
|
|
34
|
+
onClick={() => ctx.setActiveTool(c.id)}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
})}
|
|
38
|
+
</ToolGroup>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
|
|
5
|
+
/** Props for `<SidebarRegion>`. */
|
|
6
|
+
export interface SidebarRegionProps {
|
|
7
|
+
contributions: readonly TrialContribution[];
|
|
8
|
+
ctx: TrialChromeContext;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Section({
|
|
12
|
+
title,
|
|
13
|
+
defaultCollapsed,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
title: string;
|
|
17
|
+
defaultCollapsed: boolean;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}) {
|
|
20
|
+
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
21
|
+
return (
|
|
22
|
+
<section className="lk-sidebar-section">
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
className="lk-sidebar-section__head"
|
|
26
|
+
aria-expanded={!collapsed}
|
|
27
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
28
|
+
>
|
|
29
|
+
{title}
|
|
30
|
+
</button>
|
|
31
|
+
{collapsed ? null : <div className="lk-sidebar-section__body">{children}</div>}
|
|
32
|
+
</section>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Lays a trial's `sidebar` contributions out as titled, collapsible sections. */
|
|
37
|
+
export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
|
|
38
|
+
if (contributions.length === 0) return null;
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
{contributions.map((c) => {
|
|
42
|
+
if (c.render) return <div key={c.id}>{c.render(ctx)}</div>;
|
|
43
|
+
if (c.region !== 'sidebar' || !c.item) return null;
|
|
44
|
+
return (
|
|
45
|
+
<Section
|
|
46
|
+
key={c.id}
|
|
47
|
+
title={c.item.title}
|
|
48
|
+
defaultCollapsed={c.item.defaultCollapsed ?? false}
|
|
49
|
+
>
|
|
50
|
+
{c.item.body}
|
|
51
|
+
</Section>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { StatusBar } from '../../primitives/StatusBar';
|
|
2
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
3
|
+
|
|
4
|
+
/** Props for `<StatusRegion>`. */
|
|
5
|
+
export interface StatusRegionProps {
|
|
6
|
+
contributions: readonly TrialContribution[];
|
|
7
|
+
ctx: TrialChromeContext;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Lays a trial's `status` contributions out as readouts. */
|
|
11
|
+
export function StatusRegion({ contributions, ctx }: StatusRegionProps) {
|
|
12
|
+
if (contributions.length === 0) return null;
|
|
13
|
+
return (
|
|
14
|
+
<StatusBar>
|
|
15
|
+
{contributions.map((c) => {
|
|
16
|
+
if (c.render)
|
|
17
|
+
return (
|
|
18
|
+
<StatusBar.Section key={c.id} end={c.end}>
|
|
19
|
+
{c.render(ctx)}
|
|
20
|
+
</StatusBar.Section>
|
|
21
|
+
);
|
|
22
|
+
if (c.region !== 'status' || !c.item) return null;
|
|
23
|
+
return (
|
|
24
|
+
<StatusBar.Section key={c.id} end={c.end}>
|
|
25
|
+
<span title={c.item.title}>{c.item.text}</span>
|
|
26
|
+
</StatusBar.Section>
|
|
27
|
+
);
|
|
28
|
+
})}
|
|
29
|
+
</StatusBar>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
3
|
+
|
|
4
|
+
/** Props for `<TitleBarRegion>`. */
|
|
5
|
+
export interface TitleBarRegionProps {
|
|
6
|
+
contributions: readonly TrialContribution[];
|
|
7
|
+
ctx: TrialChromeContext;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
11
|
+
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
12
|
+
if (c.region !== 'titlebar' || !c.item) return null;
|
|
13
|
+
const { icon: Icon, label, shortcut, disabled, danger } = c.item;
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
key={c.id}
|
|
17
|
+
type="button"
|
|
18
|
+
className={`lk-titlebar-button${danger ? ' lk-titlebar-button--danger' : ''}`}
|
|
19
|
+
disabled={disabled}
|
|
20
|
+
aria-label={label}
|
|
21
|
+
title={shortcut ? `${label} (${shortcut})` : label}
|
|
22
|
+
onClick={c.item.onActivate}
|
|
23
|
+
// The bar behind these buttons is the window drag surface, so a press
|
|
24
|
+
// that lands here must not also start a drag.
|
|
25
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
26
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
27
|
+
>
|
|
28
|
+
<Icon size={14} />
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Lays a trial's `titlebar` contributions out, at the far end of the bar. */
|
|
34
|
+
export function TitleBarRegion({ contributions, ctx }: TitleBarRegionProps) {
|
|
35
|
+
if (contributions.length === 0) return null;
|
|
36
|
+
return (
|
|
37
|
+
<span className="lk-trial__titlebar-actions">
|
|
38
|
+
{contributions.map((c) => renderEntry(c, ctx))}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
import { ToolbarRegion } from './ToolbarRegion';
|
|
5
|
+
|
|
6
|
+
const Glyph = () => <svg data-testid="glyph" />;
|
|
7
|
+
|
|
8
|
+
const ctx = { trialId: 't1' } as unknown as TrialChromeContext;
|
|
9
|
+
|
|
10
|
+
function item(id: string, over: Partial<TrialContribution> = {}): TrialContribution {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
region: 'toolbar',
|
|
14
|
+
item: { icon: Glyph, label: id, onActivate: () => {} },
|
|
15
|
+
...over,
|
|
16
|
+
} as TrialContribution;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('ToolbarRegion', () => {
|
|
20
|
+
it('renders nothing when it has no contributions', () => {
|
|
21
|
+
const { container } = render(<ToolbarRegion contributions={[]} ctx={ctx} />);
|
|
22
|
+
expect(container).toBeEmptyDOMElement();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders one button per contribution, labelled', () => {
|
|
26
|
+
render(<ToolbarRegion contributions={[item('undo'), item('redo')]} ctx={ctx} />);
|
|
27
|
+
expect(screen.getByRole('button', { name: 'undo' })).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByRole('button', { name: 'redo' })).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('fires onActivate on click', () => {
|
|
32
|
+
const onActivate = vi.fn();
|
|
33
|
+
render(
|
|
34
|
+
<ToolbarRegion
|
|
35
|
+
contributions={[
|
|
36
|
+
{ id: 'go', region: 'toolbar', item: { icon: Glyph, label: 'Go', onActivate } },
|
|
37
|
+
]}
|
|
38
|
+
ctx={ctx}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
screen.getByRole('button', { name: 'Go' }).click();
|
|
42
|
+
expect(onActivate).toHaveBeenCalledOnce();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('groups contributions sharing a group into one group element', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ToolbarRegion
|
|
48
|
+
contributions={[
|
|
49
|
+
item('undo', { group: 'history' }),
|
|
50
|
+
item('redo', { group: 'history' }),
|
|
51
|
+
item('close', { group: 'trial' }),
|
|
52
|
+
]}
|
|
53
|
+
ctx={ctx}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
expect(screen.getAllByRole('group')).toHaveLength(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('renders a render-escape contribution verbatim', () => {
|
|
60
|
+
render(
|
|
61
|
+
<ToolbarRegion
|
|
62
|
+
contributions={[{ id: 'custom', region: 'toolbar', render: () => <b>custom</b> }]}
|
|
63
|
+
ctx={ctx}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
expect(screen.getByText('custom')).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('disables a button whose item says so', () => {
|
|
70
|
+
render(
|
|
71
|
+
<ToolbarRegion
|
|
72
|
+
contributions={[
|
|
73
|
+
item('undo', {
|
|
74
|
+
item: { icon: Glyph, label: 'Undo', disabled: true, onActivate: () => {} },
|
|
75
|
+
}),
|
|
76
|
+
]}
|
|
77
|
+
ctx={ctx}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
|
81
|
+
});
|
|
82
|
+
});
|