@weasel-js/labkit 1.4.1 → 1.4.2
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 +8 -0
- package/dist/_dts/{CanvasStackContext-BIRUVVGH.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
- package/dist/_dts/{frac-X7mWgd7y.d.ts → frac-A4R6v4ld.d.ts} +27 -10
- package/dist/_dts/{index-FEa1MudK.d.ts → index-CsU9WhjM.d.ts} +89 -29
- package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
- package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
- package/dist/canvas/index.d.ts +5 -5
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +12 -6
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-DG2IFMLA.js → chunk-54ZWZ5FQ.js} +127 -100
- package/dist/chunk-54ZWZ5FQ.js.map +1 -0
- package/dist/{chunk-RLOXQZW3.js → chunk-D5KQ5OY6.js} +3 -3
- package/dist/{chunk-RLOXQZW3.js.map → chunk-D5KQ5OY6.js.map} +1 -1
- package/dist/{chunk-PP7ZWAMV.js → chunk-FJG4PHTL.js} +3 -3
- package/dist/{chunk-PP7ZWAMV.js.map → chunk-FJG4PHTL.js.map} +1 -1
- package/dist/chunk-ISSVF5PT.js +7263 -0
- package/dist/chunk-ISSVF5PT.js.map +1 -0
- package/dist/chunk-NSOVI3AZ.js +170 -0
- package/dist/chunk-NSOVI3AZ.js.map +1 -0
- package/dist/{chunk-D7QIPGEB.js → chunk-TN7YSJVU.js} +62 -60
- package/dist/chunk-TN7YSJVU.js.map +1 -0
- package/dist/{chunk-7TRFMIWX.js → chunk-UDXOYZEC.js} +4 -4
- package/dist/{chunk-7TRFMIWX.js.map → chunk-UDXOYZEC.js.map} +1 -1
- package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
- package/dist/chunk-ULDW42CR.js.map +1 -0
- package/dist/{chunk-MSR7EVS6.js → chunk-UTOEDPNU.js} +5 -5
- package/dist/{chunk-MSR7EVS6.js.map → chunk-UTOEDPNU.js.map} +1 -1
- package/dist/{chunk-BMQWLKOL.js → chunk-W2FJR5FF.js} +35 -22
- package/dist/chunk-W2FJR5FF.js.map +1 -0
- package/dist/{chunk-OFCBEZMU.js → chunk-WS6ZRV75.js} +3 -3
- package/dist/{chunk-OFCBEZMU.js.map → chunk-WS6ZRV75.js.map} +1 -1
- package/dist/{chunk-53AZ2O2N.js → chunk-XKENZTNE.js} +45 -14
- package/dist/chunk-XKENZTNE.js.map +1 -0
- package/dist/controls/index.d.ts +1 -1
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +5 -5
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +71 -43
- package/dist/index.js +146 -113
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +23 -8
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +4 -4
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +84 -3
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.js +4 -4
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +20 -3
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.d.ts +22 -9
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +2 -2
- package/package.json +7 -7
- package/src/annotations/AnnotationTargets.tsx +6 -1
- package/src/annotations/Annotations.overlay.test.tsx +2 -0
- package/src/annotations/types.ts +4 -1
- package/src/canvas/useOrbit.test.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +7 -3
- package/src/chrome/ChromeRegions.stories.tsx +42 -1
- package/src/chrome/builtins.test.ts +4 -0
- package/src/chrome/builtins.tsx +18 -0
- package/src/chrome/index.ts +1 -1
- package/src/chrome/regions/SidebarRegion.tsx +7 -6
- package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
- package/src/chrome/regions/TitleBarRegion.tsx +16 -7
- package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
- package/src/chrome/regions/ToolbarRegion.tsx +2 -2
- package/src/chrome/regions/ViewportRegion.tsx +2 -2
- package/src/chrome/regions/regions.test.tsx +45 -1
- package/src/chrome/types.ts +21 -3
- package/src/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +51 -6
- package/src/dragdrop/DragDropRuntime.tsx +74 -61
- package/src/dragdrop/Palette.tsx +4 -3
- package/src/dragdrop/dragDrop.test.tsx +35 -12
- package/src/index.ts +2 -1
- package/src/instrument/types.ts +4 -5
- package/src/lab/Lab.test.tsx +7 -0
- package/src/lab/Lab.tsx +2 -2
- package/src/lab/LabContext.ts +5 -1
- package/src/lab/LabShell.less +17 -2
- package/src/layers/AGENTS.md +29 -7
- package/src/layers/LayerList.less +16 -0
- package/src/layers/LayerList.stories.tsx +66 -0
- package/src/layers/LayerList.test.tsx +117 -5
- package/src/layers/LayerList.tsx +192 -53
- package/src/layers/index.ts +1 -1
- package/src/passthrough/weasel-ui.ts +5 -0
- package/src/primitives/FloatingPanel.test.tsx +8 -3
- package/src/state/store.test.ts +78 -0
- package/src/state/store.ts +27 -0
- package/src/state/types.ts +20 -0
- package/src/trial/Trial.less +4 -0
- package/src/trial/Trial.targets.test.tsx +96 -0
- package/src/trial/Trial.test.tsx +171 -14
- package/src/trial/Trial.tsx +4 -1
- package/src/trial/TrialChrome.tsx +22 -2
- package/src/trial/TrialTitleBar.tsx +7 -3
- package/src/trial/index.ts +1 -0
- package/src/trial/trialOps.test.ts +49 -1
- package/src/trial/trialOps.ts +28 -6
- package/dist/chunk-53AZ2O2N.js.map +0 -1
- package/dist/chunk-BMQWLKOL.js.map +0 -1
- package/dist/chunk-D7QIPGEB.js.map +0 -1
- package/dist/chunk-DG2IFMLA.js.map +0 -1
- package/dist/chunk-J6SBFZYT.js +0 -7121
- package/dist/chunk-J6SBFZYT.js.map +0 -1
- package/dist/chunk-LJSIUFYD.js.map +0 -1
- package/dist/chunk-MIT3ISW4.js +0 -100
- package/dist/chunk-MIT3ISW4.js.map +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TrialInfo } from '../state/types';
|
|
1
2
|
import { AnnotationOverlay } from './AnnotationOverlay';
|
|
2
3
|
import type { AnnotationsApi, AnnotationsCapability } from './types';
|
|
3
4
|
|
|
@@ -6,6 +7,9 @@ export interface AnnotationTargetsProps {
|
|
|
6
7
|
capability: AnnotationsCapability;
|
|
7
8
|
state: unknown;
|
|
8
9
|
config: unknown;
|
|
10
|
+
/** The trial these targets belong to. `capability.targets` is told it, so a
|
|
11
|
+
* consumer holding per-trial refs can hand back this trial's. */
|
|
12
|
+
trial: TrialInfo;
|
|
9
13
|
annotations: AnnotationsApi;
|
|
10
14
|
activeToolId: string | null;
|
|
11
15
|
}
|
|
@@ -15,10 +19,11 @@ export function AnnotationTargets({
|
|
|
15
19
|
capability,
|
|
16
20
|
state,
|
|
17
21
|
config,
|
|
22
|
+
trial,
|
|
18
23
|
annotations,
|
|
19
24
|
activeToolId,
|
|
20
25
|
}: AnnotationTargetsProps) {
|
|
21
|
-
const targets = capability.targets(state, config);
|
|
26
|
+
const targets = capability.targets(state, config, trial);
|
|
22
27
|
|
|
23
28
|
return (
|
|
24
29
|
<>
|
|
@@ -82,6 +82,7 @@ function Harness({ toolId = 'rect' }: { toolId?: string }) {
|
|
|
82
82
|
capability={capability}
|
|
83
83
|
state={{}}
|
|
84
84
|
config={{}}
|
|
85
|
+
trial={{ id: 'harness', view: null }}
|
|
85
86
|
annotations={annotations}
|
|
86
87
|
activeToolId={toolId}
|
|
87
88
|
/>
|
|
@@ -170,6 +171,7 @@ function OneTrial({
|
|
|
170
171
|
capability={capability}
|
|
171
172
|
state={{}}
|
|
172
173
|
config={{}}
|
|
174
|
+
trial={{ id: trialId, view: null }}
|
|
173
175
|
annotations={annotations}
|
|
174
176
|
activeToolId="rect"
|
|
175
177
|
/>
|
package/src/annotations/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RefObject } from 'react';
|
|
2
2
|
import type { ViewTransform } from '../instrument/types';
|
|
3
|
+
import type { TrialInfo } from '../state/types';
|
|
3
4
|
import type { MarkScene } from './store';
|
|
4
5
|
|
|
5
6
|
/** A point in fractions of a target's content box. */
|
|
@@ -158,7 +159,9 @@ export interface AnnotationStorage {
|
|
|
158
159
|
/** Declares that an instrument accepts marks: which regions take them,
|
|
159
160
|
* optionally what a mark is allowed to mean, and optionally where they live. */
|
|
160
161
|
export interface AnnotationsCapability<TS = unknown, TC = unknown> {
|
|
161
|
-
|
|
162
|
+
/** `trial` is which trial is asking: a declaration made once per instrument
|
|
163
|
+
* is called once per trial, and its targets are that trial's own. */
|
|
164
|
+
targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
|
|
162
165
|
meaning?: AnnotationMeaning;
|
|
163
166
|
storage?: AnnotationStorage;
|
|
164
167
|
/** Fires after every finished export, labkit's own chrome included. A
|
|
@@ -133,14 +133,18 @@ describe('useOrbit dragging', () => {
|
|
|
133
133
|
expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
// The other half of the rule — a detached origin does cancel — is owned by
|
|
137
|
+
// pointerSession.test.ts, which exercises it without React in the way.
|
|
138
|
+
it('keeps turning when capture is lost and the element is still in the document', () => {
|
|
137
139
|
const { result, el, getView } = dragSetup();
|
|
138
140
|
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
139
141
|
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
|
|
142
|
+
// Chrome releases capture a beat before it delivers pointerup, so ending
|
|
143
|
+
// here throws away a release that has already been dispatched.
|
|
140
144
|
dispatchOn(el, 'lostpointercapture', {});
|
|
141
|
-
expect(result.current.isDragging()).toBe(
|
|
145
|
+
expect(result.current.isDragging()).toBe(true);
|
|
142
146
|
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
|
|
143
|
-
expect(getView()).toEqual(orbitAfterDrag(view,
|
|
147
|
+
expect(getView()).toEqual(orbitAfterDrag(view, 300, 300));
|
|
144
148
|
});
|
|
145
149
|
|
|
146
150
|
it('reads a move with nothing held as the release that never arrived', () => {
|
|
@@ -165,14 +165,18 @@ describe('usePanZoom dragging', () => {
|
|
|
165
165
|
expect(getView().pan).toEqual({ x: 50, y: 20 });
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
// The other half of the rule — a detached origin does cancel — is owned by
|
|
169
|
+
// pointerSession.test.ts, which exercises it without React in the way.
|
|
170
|
+
it('keeps panning when capture is lost and the element is still in the document', () => {
|
|
169
171
|
const { result, el, getView } = dragSetup();
|
|
170
172
|
act(() => result.current.onPointerDown(press(el, 100, 100)));
|
|
171
173
|
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
|
|
174
|
+
// Chrome releases capture a beat before it delivers pointerup, so ending
|
|
175
|
+
// here throws away a release that has already been dispatched.
|
|
172
176
|
dispatchOn(el, 'lostpointercapture', {});
|
|
173
|
-
expect(result.current.isDragging()).toBe(
|
|
177
|
+
expect(result.current.isDragging()).toBe(true);
|
|
174
178
|
dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
|
|
175
|
-
expect(getView().pan).toEqual({ x:
|
|
179
|
+
expect(getView().pan).toEqual({ x: 300, y: 300 });
|
|
176
180
|
});
|
|
177
181
|
|
|
178
182
|
it('reads a move with nothing held as the release that never arrived', () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
-
import { CrosshairIcon, HandIcon, PencilIcon } from '@weasel-js/ui';
|
|
2
|
+
import { CrosshairIcon, HandIcon, PencilIcon, SnapshotIcon } from '@weasel-js/ui';
|
|
3
3
|
import type { ConfigField } from '../controls/types';
|
|
4
4
|
import type { Instrument } from '../instrument/types';
|
|
5
5
|
import { Lab } from '../lab/Lab';
|
|
@@ -104,3 +104,44 @@ export const SuppressedAndReplaced: Story = {
|
|
|
104
104
|
],
|
|
105
105
|
},
|
|
106
106
|
};
|
|
107
|
+
|
|
108
|
+
const SUBJECTS = ['Ash', 'Birch', 'Cedar'];
|
|
109
|
+
|
|
110
|
+
/** A trial titled by its subject rather than its instrument, with the leading
|
|
111
|
+
* end of the title bar carrying the control that renames it — and the
|
|
112
|
+
* snapshot built-in re-declared into the toolbar, which needs the context
|
|
113
|
+
* `onActivate` is handed. */
|
|
114
|
+
export const SubjectTitleAndLead: Story = {
|
|
115
|
+
args: {
|
|
116
|
+
instruments: [FullInstrument],
|
|
117
|
+
defaultInstrument: 'Every Region',
|
|
118
|
+
title: 'Title Bar Lead',
|
|
119
|
+
storage: null,
|
|
120
|
+
suppress: ['snapshot'],
|
|
121
|
+
chrome: [
|
|
122
|
+
{
|
|
123
|
+
id: 'subject',
|
|
124
|
+
region: 'titlebar',
|
|
125
|
+
item: {
|
|
126
|
+
icon: PencilIcon,
|
|
127
|
+
label: 'Next subject',
|
|
128
|
+
onActivate: (ctx) => {
|
|
129
|
+
const i = SUBJECTS.indexOf(ctx.title);
|
|
130
|
+
ctx.setTitle(SUBJECTS[(i + 1) % SUBJECTS.length]);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'snapshot-in-toolbar',
|
|
136
|
+
region: 'toolbar',
|
|
137
|
+
group: 'trial',
|
|
138
|
+
item: {
|
|
139
|
+
icon: SnapshotIcon,
|
|
140
|
+
label: 'Snapshot',
|
|
141
|
+
showLabel: true,
|
|
142
|
+
onActivate: (ctx) => ctx.saveSnapshot(),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
};
|
|
@@ -9,9 +9,13 @@ import type { TrialChromeContext } from './types';
|
|
|
9
9
|
const ctx: TrialChromeContext = {
|
|
10
10
|
trialId: 't1',
|
|
11
11
|
undockedPanels: [],
|
|
12
|
+
collapsedSections: {},
|
|
13
|
+
setSectionCollapsed: () => {},
|
|
12
14
|
undockPanel: () => {},
|
|
13
15
|
dockPanel: () => {},
|
|
14
16
|
instrumentName: 'Stub',
|
|
17
|
+
title: 'Stub',
|
|
18
|
+
setTitle: () => {},
|
|
15
19
|
isLastTrial: false,
|
|
16
20
|
zoom: 1,
|
|
17
21
|
setZoom: () => {},
|
package/src/chrome/builtins.tsx
CHANGED
|
@@ -24,6 +24,22 @@ import type { TrialChromeContext, TrialContribution } from './types';
|
|
|
24
24
|
|
|
25
25
|
const ZOOM_STEP = 1.25;
|
|
26
26
|
|
|
27
|
+
/** The property groups inside a contribution are folded under keys prefixed
|
|
28
|
+
* with that contribution's id, so a group called `marks` cannot fold the
|
|
29
|
+
* sidebar section of the same name. `ControlPanel` keys by bare label, so the
|
|
30
|
+
* prefix goes on here and comes off again. */
|
|
31
|
+
function groupFolds(
|
|
32
|
+
collapsedSections: Readonly<Record<string, boolean>>,
|
|
33
|
+
id: string,
|
|
34
|
+
): Record<string, boolean> {
|
|
35
|
+
const prefix = `${id}/`;
|
|
36
|
+
const out: Record<string, boolean> = {};
|
|
37
|
+
for (const [key, value] of Object.entries(collapsedSections)) {
|
|
38
|
+
if (key.startsWith(prefix)) out[key.slice(prefix.length)] = value;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
/**
|
|
28
44
|
* The contributions a trial gets from what its instrument declared. This is
|
|
29
45
|
* the whole "declaring a capability provides the chrome" rule — it replaces
|
|
@@ -180,6 +196,8 @@ export function builtinContributions(
|
|
|
180
196
|
config={ctx.config as Record<string, unknown>}
|
|
181
197
|
setConfig={(key, value) => ctx.setConfig(String(key), value)}
|
|
182
198
|
renderers={controls}
|
|
199
|
+
collapsed={groupFolds(ctx.collapsedSections, 'settings')}
|
|
200
|
+
onCollapse={(label, next) => ctx.setSectionCollapsed(`settings/${label}`, next)}
|
|
183
201
|
/>
|
|
184
202
|
),
|
|
185
203
|
},
|
package/src/chrome/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type { SidebarRegionProps } from './regions/SidebarRegion';
|
|
|
6
6
|
export { SidebarRegion } from './regions/SidebarRegion';
|
|
7
7
|
export type { StatusRegionProps } from './regions/StatusRegion';
|
|
8
8
|
export { StatusRegion } from './regions/StatusRegion';
|
|
9
|
-
export type { TitleBarRegionProps } from './regions/TitleBarRegion';
|
|
9
|
+
export type { TitleBarPlacement, TitleBarRegionProps } from './regions/TitleBarRegion';
|
|
10
10
|
export { TitleBarRegion } from './regions/TitleBarRegion';
|
|
11
11
|
export type { ToolbarRegionProps } from './regions/ToolbarRegion';
|
|
12
12
|
export { ToolbarRegion } from './regions/ToolbarRegion';
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { useState } from 'react';
|
|
3
2
|
import type { SidebarSection, TrialChromeContext, TrialContribution } from '../types';
|
|
4
3
|
|
|
5
4
|
/** Props for `<SidebarRegion>`. */
|
|
@@ -10,16 +9,17 @@ export interface SidebarRegionProps {
|
|
|
10
9
|
|
|
11
10
|
function Section({
|
|
12
11
|
title,
|
|
13
|
-
|
|
12
|
+
collapsed,
|
|
13
|
+
onCollapsedChange,
|
|
14
14
|
onUndock,
|
|
15
15
|
children,
|
|
16
16
|
}: {
|
|
17
17
|
title: string;
|
|
18
|
-
|
|
18
|
+
collapsed: boolean;
|
|
19
|
+
onCollapsedChange: (collapsed: boolean) => void;
|
|
19
20
|
onUndock?: () => void;
|
|
20
21
|
children: ReactNode;
|
|
21
22
|
}) {
|
|
22
|
-
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
23
23
|
return (
|
|
24
24
|
<section className="lk-sidebar-section">
|
|
25
25
|
<div className="lk-sidebar-section__bar">
|
|
@@ -27,7 +27,7 @@ function Section({
|
|
|
27
27
|
type="button"
|
|
28
28
|
className="lk-sidebar-section__head"
|
|
29
29
|
aria-expanded={!collapsed}
|
|
30
|
-
onClick={() =>
|
|
30
|
+
onClick={() => onCollapsedChange(!collapsed)}
|
|
31
31
|
>
|
|
32
32
|
{title}
|
|
33
33
|
</button>
|
|
@@ -64,7 +64,8 @@ export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
|
|
|
64
64
|
<Section
|
|
65
65
|
key={c.id}
|
|
66
66
|
title={item.title}
|
|
67
|
-
|
|
67
|
+
collapsed={ctx.collapsedSections[c.id] ?? item.defaultCollapsed ?? false}
|
|
68
|
+
onCollapsedChange={(next) => ctx.setSectionCollapsed(c.id, next)}
|
|
68
69
|
onUndock={
|
|
69
70
|
item.undockable === false
|
|
70
71
|
? undefined
|
|
@@ -0,0 +1,64 @@
|
|
|
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 { TitleBarRegion } from './TitleBarRegion';
|
|
5
|
+
|
|
6
|
+
const Glyph = () => <svg />;
|
|
7
|
+
const ctx = { trialId: 't1', saveSnapshot: vi.fn() } as unknown as TrialChromeContext;
|
|
8
|
+
|
|
9
|
+
function item(id: string, over: Partial<TrialContribution> = {}): TrialContribution {
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
region: 'titlebar',
|
|
13
|
+
item: { icon: Glyph, label: id, onActivate: () => {} },
|
|
14
|
+
...over,
|
|
15
|
+
} as TrialContribution;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('TitleBarRegion', () => {
|
|
19
|
+
it('renders nothing when it has nothing for its placement', () => {
|
|
20
|
+
const { container } = render(<TitleBarRegion contributions={[item('lead')]} ctx={ctx} />);
|
|
21
|
+
expect(container).toBeEmptyDOMElement();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('takes the contributions that set `end` into the actions span', () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<TitleBarRegion contributions={[item('lead'), item('close', { end: true })]} ctx={ctx} />,
|
|
27
|
+
);
|
|
28
|
+
expect(container.querySelector('.lk-trial__titlebar-actions')).toBeTruthy();
|
|
29
|
+
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
|
|
30
|
+
expect(screen.queryByRole('button', { name: 'lead' })).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('takes the rest into the lead span', () => {
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<TitleBarRegion
|
|
36
|
+
placement="lead"
|
|
37
|
+
contributions={[item('lead'), item('close', { end: true })]}
|
|
38
|
+
ctx={ctx}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
expect(container.querySelector('.lk-trial__titlebar-lead')).toBeTruthy();
|
|
42
|
+
expect(screen.getByRole('button', { name: 'lead' })).toBeInTheDocument();
|
|
43
|
+
expect(screen.queryByRole('button', { name: 'close' })).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('hands onActivate the chrome context', () => {
|
|
47
|
+
const onActivate = vi.fn();
|
|
48
|
+
render(
|
|
49
|
+
<TitleBarRegion
|
|
50
|
+
contributions={[
|
|
51
|
+
{
|
|
52
|
+
id: 'go',
|
|
53
|
+
region: 'titlebar',
|
|
54
|
+
end: true,
|
|
55
|
+
item: { icon: Glyph, label: 'Go', onActivate },
|
|
56
|
+
},
|
|
57
|
+
]}
|
|
58
|
+
ctx={ctx}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
screen.getByRole('button', { name: 'Go' }).click();
|
|
62
|
+
expect(onActivate).toHaveBeenCalledWith(ctx);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
3
3
|
|
|
4
|
+
/** Which end of the title bar a `<TitleBarRegion>` lays out: `'lead'` renders
|
|
5
|
+
* the contributions that leave `end` unset, before the title; `'actions'`
|
|
6
|
+
* renders the ones that set it, after everything else. */
|
|
7
|
+
export type TitleBarPlacement = 'lead' | 'actions';
|
|
8
|
+
|
|
4
9
|
/** Props for `<TitleBarRegion>`. */
|
|
5
10
|
export interface TitleBarRegionProps {
|
|
6
11
|
contributions: readonly TrialContribution[];
|
|
7
12
|
ctx: TrialChromeContext;
|
|
13
|
+
/** Defaults to `'actions'`, where the built-ins live. */
|
|
14
|
+
placement?: TitleBarPlacement;
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
11
18
|
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
12
19
|
if (c.region !== 'titlebar' || !c.item) return null;
|
|
13
|
-
const { icon: Icon, label, shortcut, disabled, danger } = c.item;
|
|
20
|
+
const { icon: Icon, label, shortcut, disabled, danger, onActivate } = c.item;
|
|
14
21
|
return (
|
|
15
22
|
<button
|
|
16
23
|
key={c.id}
|
|
@@ -19,7 +26,7 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
|
19
26
|
disabled={disabled}
|
|
20
27
|
aria-label={label}
|
|
21
28
|
title={shortcut ? `${label} (${shortcut})` : label}
|
|
22
|
-
onClick={
|
|
29
|
+
onClick={() => onActivate(ctx)}
|
|
23
30
|
// The bar behind these buttons is the window drag surface, so a press
|
|
24
31
|
// that lands here must not also start a drag.
|
|
25
32
|
onPointerDown={(e) => e.stopPropagation()}
|
|
@@ -30,12 +37,14 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
|
30
37
|
);
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
/** Lays a trial's `titlebar` contributions out
|
|
34
|
-
export function TitleBarRegion({ contributions, ctx }: TitleBarRegionProps) {
|
|
35
|
-
|
|
40
|
+
/** Lays a trial's `titlebar` contributions out at one end of the bar. */
|
|
41
|
+
export function TitleBarRegion({ contributions, ctx, placement = 'actions' }: TitleBarRegionProps) {
|
|
42
|
+
const wanted = placement === 'actions';
|
|
43
|
+
const mine = contributions.filter((c) => (c.end ?? false) === wanted);
|
|
44
|
+
if (mine.length === 0) return null;
|
|
36
45
|
return (
|
|
37
|
-
<span className=
|
|
38
|
-
{
|
|
46
|
+
<span className={wanted ? 'lk-trial__titlebar-actions' : 'lk-trial__titlebar-lead'}>
|
|
47
|
+
{mine.map((c) => renderEntry(c, ctx))}
|
|
39
48
|
</span>
|
|
40
49
|
);
|
|
41
50
|
}
|
|
@@ -42,6 +42,20 @@ describe('ToolbarRegion', () => {
|
|
|
42
42
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
it('hands onActivate the chrome context', () => {
|
|
46
|
+
const onActivate = vi.fn();
|
|
47
|
+
render(
|
|
48
|
+
<ToolbarRegion
|
|
49
|
+
contributions={[
|
|
50
|
+
{ id: 'snap', region: 'toolbar', item: { icon: Glyph, label: 'Snap', onActivate } },
|
|
51
|
+
]}
|
|
52
|
+
ctx={ctx}
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
screen.getByRole('button', { name: 'Snap' }).click();
|
|
56
|
+
expect(onActivate).toHaveBeenCalledWith(ctx);
|
|
57
|
+
});
|
|
58
|
+
|
|
45
59
|
it('groups contributions sharing a group into one group element', () => {
|
|
46
60
|
render(
|
|
47
61
|
<ToolbarRegion
|
|
@@ -38,7 +38,7 @@ function groupsOf(contributions: readonly TrialContribution[]): Group[] {
|
|
|
38
38
|
function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
39
39
|
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
40
40
|
if (c.region !== 'toolbar' || !c.item) return null;
|
|
41
|
-
const { icon: Icon, label, shortcut, disabled, danger, showLabel, pressed } = c.item;
|
|
41
|
+
const { icon: Icon, label, shortcut, disabled, danger, showLabel, pressed, onActivate } = c.item;
|
|
42
42
|
return (
|
|
43
43
|
<Toolbar.Button
|
|
44
44
|
key={c.id}
|
|
@@ -48,7 +48,7 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
|
|
|
48
48
|
pressed={pressed}
|
|
49
49
|
aria-label={label}
|
|
50
50
|
title={shortcut ? `${label} (${shortcut})` : label}
|
|
51
|
-
onClick={
|
|
51
|
+
onClick={() => onActivate(ctx)}
|
|
52
52
|
>
|
|
53
53
|
<Icon size={16} />
|
|
54
54
|
{showLabel ? <span>{label}</span> : null}
|
|
@@ -25,7 +25,7 @@ export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
|
|
|
25
25
|
{contributions.map((c) => {
|
|
26
26
|
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
27
27
|
if (c.region !== 'viewport' || !c.item) return null;
|
|
28
|
-
const { icon: Icon, label, disabled } = c.item;
|
|
28
|
+
const { icon: Icon, label, disabled, onActivate } = c.item;
|
|
29
29
|
return (
|
|
30
30
|
<button
|
|
31
31
|
key={c.id}
|
|
@@ -34,7 +34,7 @@ export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
|
|
|
34
34
|
aria-label={label}
|
|
35
35
|
title={label}
|
|
36
36
|
disabled={disabled}
|
|
37
|
-
onClick={
|
|
37
|
+
onClick={() => onActivate(ctx)}
|
|
38
38
|
>
|
|
39
39
|
<Icon size={16} />
|
|
40
40
|
</button>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
4
|
import { SidebarRegion } from './SidebarRegion';
|
|
@@ -9,6 +9,8 @@ const Glyph = () => <svg />;
|
|
|
9
9
|
const ctx = {
|
|
10
10
|
trialId: 't1',
|
|
11
11
|
undockedPanels: [],
|
|
12
|
+
collapsedSections: {},
|
|
13
|
+
setSectionCollapsed: () => {},
|
|
12
14
|
undockPanel: () => {},
|
|
13
15
|
dockPanel: () => {},
|
|
14
16
|
} as unknown as TrialChromeContext;
|
|
@@ -45,6 +47,34 @@ describe('SidebarRegion', () => {
|
|
|
45
47
|
);
|
|
46
48
|
expect(screen.queryByText('hidden')).not.toBeInTheDocument();
|
|
47
49
|
});
|
|
50
|
+
|
|
51
|
+
it('takes the trial’s remembered fold over the section’s default', () => {
|
|
52
|
+
render(
|
|
53
|
+
<SidebarRegion
|
|
54
|
+
contributions={[
|
|
55
|
+
{
|
|
56
|
+
id: 's',
|
|
57
|
+
region: 'sidebar',
|
|
58
|
+
item: { title: 'S', defaultCollapsed: true, body: <p>shown</p> },
|
|
59
|
+
},
|
|
60
|
+
]}
|
|
61
|
+
ctx={{ ...ctx, collapsedSections: { s: false } }}
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
expect(screen.getByText('shown')).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reports a toggle rather than keeping the fold itself', () => {
|
|
68
|
+
const setSectionCollapsed = vi.fn();
|
|
69
|
+
render(
|
|
70
|
+
<SidebarRegion
|
|
71
|
+
contributions={[{ id: 's', region: 'sidebar', item: { title: 'S', body: <p>body</p> } }]}
|
|
72
|
+
ctx={{ ...ctx, setSectionCollapsed }}
|
|
73
|
+
/>,
|
|
74
|
+
);
|
|
75
|
+
fireEvent.click(screen.getByRole('button', { name: 'S' }));
|
|
76
|
+
expect(setSectionCollapsed).toHaveBeenCalledWith('s', true);
|
|
77
|
+
});
|
|
48
78
|
});
|
|
49
79
|
|
|
50
80
|
describe('StatusRegion', () => {
|
|
@@ -83,4 +113,18 @@ describe('ViewportRegion', () => {
|
|
|
83
113
|
screen.getByRole('button', { name: 'Fit' }).click();
|
|
84
114
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
85
115
|
});
|
|
116
|
+
|
|
117
|
+
it('hands onActivate the chrome context', () => {
|
|
118
|
+
const onActivate = vi.fn();
|
|
119
|
+
render(
|
|
120
|
+
<ViewportRegion
|
|
121
|
+
contributions={[
|
|
122
|
+
{ id: 'fit', region: 'viewport', item: { icon: Glyph, label: 'Fit', onActivate } },
|
|
123
|
+
]}
|
|
124
|
+
ctx={ctx}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
screen.getByRole('button', { name: 'Fit' }).click();
|
|
128
|
+
expect(onActivate).toHaveBeenCalledWith(ctx);
|
|
129
|
+
});
|
|
86
130
|
});
|
package/src/chrome/types.ts
CHANGED
|
@@ -24,7 +24,10 @@ export interface ToolbarItem {
|
|
|
24
24
|
pressed?: boolean;
|
|
25
25
|
/** Render the label beside the glyph rather than only in the tooltip. */
|
|
26
26
|
showLabel?: boolean;
|
|
27
|
-
|
|
27
|
+
/** Handed the trial's chrome context, so a contribution declared from the
|
|
28
|
+
* lab can reach `ctx.saveSnapshot()` and the rest without the `render`
|
|
29
|
+
* escape. A zero-argument handler stays valid. */
|
|
30
|
+
onActivate: (ctx: TrialChromeContext) => void;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/** A selectable tool in the palette region. */
|
|
@@ -38,7 +41,7 @@ export interface ToolItem {
|
|
|
38
41
|
/** A titled block in the sidebar. */
|
|
39
42
|
export interface SidebarSection {
|
|
40
43
|
title: string;
|
|
41
|
-
/** Starts collapsed
|
|
44
|
+
/** Starts collapsed, until the trial remembers a fold of its own. */
|
|
42
45
|
defaultCollapsed?: boolean;
|
|
43
46
|
/** Offer the tear-out control. On by default; a section that only makes
|
|
44
47
|
* sense beside its trial sets this false. */
|
|
@@ -53,7 +56,8 @@ export interface ViewportControl {
|
|
|
53
56
|
icon: IconComponent;
|
|
54
57
|
label: string;
|
|
55
58
|
disabled?: boolean;
|
|
56
|
-
onActivate
|
|
59
|
+
/** Handed the trial's chrome context, as `ToolbarItem.onActivate` is. */
|
|
60
|
+
onActivate: (ctx: TrialChromeContext) => void;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/** A readout in the status bar. */
|
|
@@ -100,6 +104,12 @@ export type TrialContribution =
|
|
|
100
104
|
export interface TrialChromeContext {
|
|
101
105
|
trialId: string;
|
|
102
106
|
instrumentName: string;
|
|
107
|
+
/** What the title bar reads, which is the instrument's name until something
|
|
108
|
+
* calls `setTitle`. */
|
|
109
|
+
title: string;
|
|
110
|
+
/** Retitle this trial; `null` restores the instrument name. Persisted with
|
|
111
|
+
* the trial, so a title survives a reload. */
|
|
112
|
+
setTitle: (title: string | null) => void;
|
|
103
113
|
isLastTrial: boolean;
|
|
104
114
|
|
|
105
115
|
/** Null when the trial holds a view that is not the 2D one. */
|
|
@@ -125,6 +135,14 @@ export interface TrialChromeContext {
|
|
|
125
135
|
config: unknown;
|
|
126
136
|
setConfig: (key: string, value: unknown) => void;
|
|
127
137
|
|
|
138
|
+
/** Which of this trial's collapsible sections are folded. A sidebar
|
|
139
|
+
* section's key is its contribution id; a section *inside* a contribution —
|
|
140
|
+
* a control panel's property group — is keyed `<contribution id>/<label>`.
|
|
141
|
+
* A section absent here is at its own default. Persisted with the trial. */
|
|
142
|
+
collapsedSections: Readonly<Record<string, boolean>>;
|
|
143
|
+
/** Fold or unfold one section, keyed as `collapsedSections` is. */
|
|
144
|
+
setSectionCollapsed: (key: string, collapsed: boolean) => void;
|
|
145
|
+
|
|
128
146
|
/** Section ids this trial currently has torn out of its sidebar. */
|
|
129
147
|
undockedPanels: readonly string[];
|
|
130
148
|
/** Tear a sidebar section out into the workspace. */
|
|
@@ -443,3 +443,41 @@ describe('<ControlPanel> suffix', () => {
|
|
|
443
443
|
expect(container.textContent).toContain('px');
|
|
444
444
|
});
|
|
445
445
|
});
|
|
446
|
+
|
|
447
|
+
describe('<ControlPanel> collapsible sections', () => {
|
|
448
|
+
const schema = resolveConfigSchema(
|
|
449
|
+
f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
|
|
450
|
+
[],
|
|
451
|
+
);
|
|
452
|
+
const config = { showGrid: true, seed: 0 };
|
|
453
|
+
|
|
454
|
+
it('draws no twisty by default', () => {
|
|
455
|
+
render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} />);
|
|
456
|
+
expect(screen.queryByRole('button', { name: 'Advanced' })).toBeNull();
|
|
457
|
+
expect(screen.getByLabelText('Seed')).toBeVisible();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('folds a section away when the panel starts closed', () => {
|
|
461
|
+
render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} collapse="closed" />);
|
|
462
|
+
expect(screen.getByLabelText('Seed')).not.toBeVisible();
|
|
463
|
+
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }));
|
|
464
|
+
expect(screen.getByLabelText('Seed')).toBeVisible();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('hands a controlled panel its toggles instead of acting on them', () => {
|
|
468
|
+
const onCollapse = vi.fn();
|
|
469
|
+
render(
|
|
470
|
+
<ControlPanel
|
|
471
|
+
schema={schema}
|
|
472
|
+
config={config}
|
|
473
|
+
setConfig={vi.fn()}
|
|
474
|
+
collapsed={{ Advanced: true }}
|
|
475
|
+
onCollapse={onCollapse}
|
|
476
|
+
/>,
|
|
477
|
+
);
|
|
478
|
+
expect(screen.getByLabelText('Seed')).not.toBeVisible();
|
|
479
|
+
fireEvent.click(screen.getByRole('button', { name: 'Advanced' }));
|
|
480
|
+
expect(onCollapse).toHaveBeenCalledWith('Advanced', false);
|
|
481
|
+
expect(screen.getByLabelText('Seed')).not.toBeVisible();
|
|
482
|
+
});
|
|
483
|
+
});
|