@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
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
display: flex;
|
|
3
3
|
flex-direction: column;
|
|
4
4
|
width: 280px;
|
|
5
|
+
flex: 1 1 auto;
|
|
5
6
|
background: var(--wzl-surface-raised);
|
|
6
|
-
border-
|
|
7
|
+
border-right: 1px solid var(--wzl-border);
|
|
8
|
+
min-height: 0;
|
|
7
9
|
transition: width 150ms ease;
|
|
8
10
|
|
|
9
11
|
&--collapsed {
|
|
@@ -21,7 +23,7 @@
|
|
|
21
23
|
|
|
22
24
|
.lk-sidebar-title {
|
|
23
25
|
flex: 1;
|
|
24
|
-
font-weight:
|
|
26
|
+
font-weight: var(--wzl-font-weight-bold);
|
|
25
27
|
color: var(--wzl-fg);
|
|
26
28
|
font-size: var(--wzl-font-size);
|
|
27
29
|
|
|
@@ -55,3 +57,31 @@
|
|
|
55
57
|
display: none;
|
|
56
58
|
}
|
|
57
59
|
}
|
|
60
|
+
|
|
61
|
+
.lk-sidebar-section {
|
|
62
|
+
border-bottom: var(--wzl-border-w) solid var(--wzl-line-subtle);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.lk-sidebar-section__head {
|
|
66
|
+
width: 100%;
|
|
67
|
+
padding: var(--wzl-space-xs);
|
|
68
|
+
border: 0;
|
|
69
|
+
background: transparent;
|
|
70
|
+
color: var(--wzl-fg-muted);
|
|
71
|
+
font-family: var(--wzl-font-display);
|
|
72
|
+
font-size: var(--wzl-font-size-sm);
|
|
73
|
+
letter-spacing: 0.08em;
|
|
74
|
+
text-align: left;
|
|
75
|
+
text-transform: uppercase;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
|
|
78
|
+
&:hover {
|
|
79
|
+
color: var(--wzl-fg);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The section body is the only thing that insets a panel — the panels
|
|
84
|
+
// themselves carry no padding, so this is the whole gutter.
|
|
85
|
+
.lk-sidebar-section__body {
|
|
86
|
+
padding: 0 var(--wzl-space-xs) var(--wzl-space-xs);
|
|
87
|
+
}
|
|
@@ -18,4 +18,11 @@
|
|
|
18
18
|
border-right: 1px solid var(--wzl-line-subtle);
|
|
19
19
|
|
|
20
20
|
&:last-child { border-right: none; }
|
|
21
|
+
|
|
22
|
+
&--end {
|
|
23
|
+
margin-left: auto;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A section pushed to the end already reads as separated by the gap.
|
|
27
|
+
&:has(+ &--end) { border-right: none; }
|
|
21
28
|
}
|
|
@@ -10,11 +10,18 @@ export function StatusBar({ children }: StatusBarProps) {
|
|
|
10
10
|
return <div className="lk-status-bar">{children}</div>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** Props for `<StatusBar.Section>`. */
|
|
14
|
+
export interface StatusBarSectionProps {
|
|
14
15
|
children: ReactNode;
|
|
16
|
+
/** Pushes this section, and everything after it, to the far end. */
|
|
17
|
+
end?: boolean;
|
|
15
18
|
}
|
|
16
|
-
function Section({ children }:
|
|
17
|
-
return
|
|
19
|
+
function Section({ children, end }: StatusBarSectionProps) {
|
|
20
|
+
return (
|
|
21
|
+
<span className={`lk-status-bar-section${end ? ' lk-status-bar-section--end' : ''}`}>
|
|
22
|
+
{children}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/** One readout within a status bar. */
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
.lk-toolbar {
|
|
2
|
+
// A trial toolbar is chrome around the instrument, not a page's own control
|
|
3
|
+
// strip, so it runs a compact control height. Overriding the token rather
|
|
4
|
+
// than the button keeps the slider and number field in step with it.
|
|
5
|
+
--wzl-control-h: 22px;
|
|
6
|
+
|
|
2
7
|
display: flex;
|
|
3
8
|
align-items: center;
|
|
4
9
|
gap: var(--wzl-space-sm);
|
|
5
|
-
padding:
|
|
10
|
+
padding: 2px var(--wzl-space-sm);
|
|
6
11
|
background: var(--wzl-surface-raised);
|
|
7
12
|
border-bottom: 1px solid var(--wzl-border);
|
|
8
|
-
min-height: calc(var(--wzl-control-h) +
|
|
13
|
+
min-height: calc(var(--wzl-control-h) + 4px);
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
.lk-toolbar-title {
|
|
12
|
-
font-weight:
|
|
17
|
+
font-weight: var(--wzl-font-weight-bold);
|
|
13
18
|
color: var(--wzl-fg);
|
|
14
19
|
margin-right: var(--wzl-space-sm);
|
|
15
20
|
}
|
|
@@ -18,7 +23,35 @@
|
|
|
18
23
|
flex: 1;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
.lk-toolbar-group {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 2px;
|
|
30
|
+
min-width: 0;
|
|
31
|
+
|
|
32
|
+
// The rule belongs between groups, so it rides on the group rather than
|
|
33
|
+
// being a separate node the caller has to remember to interleave.
|
|
34
|
+
& + & {
|
|
35
|
+
padding-left: var(--wzl-space-sm);
|
|
36
|
+
border-left: 1px solid var(--wzl-line-subtle);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&--end {
|
|
40
|
+
margin-left: auto;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A group pushed to the end already reads as separated by the gap.
|
|
44
|
+
&--end + &,
|
|
45
|
+
& + &--end {
|
|
46
|
+
border-left: none;
|
|
47
|
+
padding-left: 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
.lk-toolbar-button {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: var(--wzl-space-xs);
|
|
22
55
|
height: var(--wzl-control-h);
|
|
23
56
|
padding: 0 var(--wzl-space-sm);
|
|
24
57
|
font-size: var(--wzl-font-size);
|
|
@@ -27,15 +60,29 @@
|
|
|
27
60
|
border: 1px solid transparent;
|
|
28
61
|
border-radius: var(--wzl-radius-md);
|
|
29
62
|
cursor: pointer;
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
|
|
65
|
+
&--icon {
|
|
66
|
+
padding: 0;
|
|
67
|
+
width: var(--wzl-control-h);
|
|
68
|
+
justify-content: center;
|
|
69
|
+
}
|
|
30
70
|
|
|
31
71
|
&:hover:not(:disabled) {
|
|
32
72
|
background: var(--wzl-surface);
|
|
33
73
|
border-color: var(--wzl-border);
|
|
34
74
|
}
|
|
75
|
+
|
|
76
|
+
&--danger:hover:not(:disabled) {
|
|
77
|
+
color: var(--wzl-danger);
|
|
78
|
+
border-color: var(--wzl-danger);
|
|
79
|
+
}
|
|
80
|
+
|
|
35
81
|
&:focus-visible {
|
|
36
82
|
outline: 2px solid var(--wzl-focus-ring);
|
|
37
83
|
outline-offset: 1px;
|
|
38
84
|
}
|
|
85
|
+
|
|
39
86
|
&:disabled {
|
|
40
87
|
color: var(--wzl-fg-subtle);
|
|
41
88
|
cursor: not-allowed;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, test } from 'vitest';
|
|
3
3
|
import { Toolbar } from './Toolbar';
|
|
4
4
|
|
|
@@ -63,3 +63,69 @@ describe('Toolbar', () => {
|
|
|
63
63
|
expect(screen.getByRole('button', { name: 'X' })).toBeDisabled();
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
function threeButtons() {
|
|
68
|
+
return render(
|
|
69
|
+
<Toolbar aria-label="Trial actions">
|
|
70
|
+
<Toolbar.Group aria-label="History">
|
|
71
|
+
<Toolbar.Button onClick={() => {}} title="Undo">
|
|
72
|
+
U
|
|
73
|
+
</Toolbar.Button>
|
|
74
|
+
<Toolbar.Button onClick={() => {}} title="Redo">
|
|
75
|
+
R
|
|
76
|
+
</Toolbar.Button>
|
|
77
|
+
</Toolbar.Group>
|
|
78
|
+
<Toolbar.Button onClick={() => {}} title="Close">
|
|
79
|
+
X
|
|
80
|
+
</Toolbar.Button>
|
|
81
|
+
</Toolbar>,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('Toolbar keyboard contract', () => {
|
|
86
|
+
test('claims the toolbar role and is nameable', () => {
|
|
87
|
+
threeButtons();
|
|
88
|
+
expect(screen.getByRole('toolbar', { name: 'Trial actions' })).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('keeps exactly one button in the tab order', () => {
|
|
92
|
+
threeButtons();
|
|
93
|
+
const inOrder = screen.getAllByRole('button').filter((b) => b.tabIndex === 0);
|
|
94
|
+
expect(inOrder).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('keeps one tab stop when a button becomes enabled', async () => {
|
|
98
|
+
function Bar({ busy }: { busy: boolean }) {
|
|
99
|
+
return (
|
|
100
|
+
<Toolbar aria-label="Trial actions">
|
|
101
|
+
<Toolbar.Button onClick={() => {}} title="Undo" disabled={busy}>
|
|
102
|
+
U
|
|
103
|
+
</Toolbar.Button>
|
|
104
|
+
<Toolbar.Button onClick={() => {}} title="Close">
|
|
105
|
+
X
|
|
106
|
+
</Toolbar.Button>
|
|
107
|
+
</Toolbar>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const { rerender } = render(<Bar busy />);
|
|
111
|
+
rerender(<Bar busy={false} />);
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
const inOrder = screen
|
|
114
|
+
.getAllByRole<HTMLButtonElement>('button')
|
|
115
|
+
.filter((b) => !b.disabled && b.tabIndex === 0);
|
|
116
|
+
expect(inOrder).toHaveLength(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('moves focus with the arrow keys, wrapping at the end', async () => {
|
|
121
|
+
const { default: userEvent } = await import('@testing-library/user-event');
|
|
122
|
+
const user = userEvent.setup();
|
|
123
|
+
threeButtons();
|
|
124
|
+
await user.tab();
|
|
125
|
+
expect(screen.getByTitle('Undo')).toHaveFocus();
|
|
126
|
+
await user.keyboard('{ArrowRight}');
|
|
127
|
+
expect(screen.getByTitle('Redo')).toHaveFocus();
|
|
128
|
+
await user.keyboard('{ArrowRight}{ArrowRight}');
|
|
129
|
+
expect(screen.getByTitle('Undo')).toHaveFocus();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import type { MouseEventHandler, ReactNode } from 'react';
|
|
2
|
+
import { useRovingTabIndex } from './useRovingTabIndex';
|
|
2
3
|
|
|
3
4
|
/** Props for `<Toolbar>`. */
|
|
4
5
|
export interface ToolbarProps {
|
|
5
6
|
children: ReactNode;
|
|
7
|
+
/** Names the toolbar for assistive tech. Required by the APG pattern when a
|
|
8
|
+
* view holds more than one. */
|
|
9
|
+
'aria-label'?: string;
|
|
6
10
|
}
|
|
7
11
|
|
|
8
12
|
/** A horizontal bar of controls. Fill it with `<Toolbar.Title>`,
|
|
9
|
-
* `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
|
|
10
|
-
export function Toolbar({ children }: ToolbarProps) {
|
|
11
|
-
|
|
13
|
+
* `<Toolbar.Group>`, `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
|
|
14
|
+
export function Toolbar({ children, 'aria-label': ariaLabel }: ToolbarProps) {
|
|
15
|
+
const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>();
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
ref={ref}
|
|
19
|
+
className="lk-toolbar"
|
|
20
|
+
role="toolbar"
|
|
21
|
+
aria-label={ariaLabel}
|
|
22
|
+
onKeyDown={onKeyDown}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
12
27
|
}
|
|
13
28
|
|
|
14
29
|
interface TitleProps {
|
|
@@ -18,20 +33,69 @@ function Title({ children }: TitleProps) {
|
|
|
18
33
|
return <span className="lk-toolbar-title">{children}</span>;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
/** Props for `<Toolbar.Group>`. */
|
|
37
|
+
export interface ToolbarGroupProps {
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
/** Pushes this group, and everything after it, to the far end. */
|
|
40
|
+
end?: boolean;
|
|
41
|
+
'aria-label'?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Related controls, kept tight and ruled off from their neighbors. Group
|
|
45
|
+
* rather than separate with repeated `<Toolbar.Spacer>`: consecutive spacers
|
|
46
|
+
* share the free space, so every gap collapses to the same slack and the bar
|
|
47
|
+
* reads as one undifferentiated run. */
|
|
48
|
+
function Group({ children, end, 'aria-label': ariaLabel }: ToolbarGroupProps) {
|
|
49
|
+
return (
|
|
50
|
+
// biome-ignore lint/a11y/useSemanticElements: a group inside role="toolbar" is the APG pattern; the rule is local and cannot see the parent. <fieldset> means form controls, needs a <legend> to be named, and its UA min-width breaks flex children.
|
|
51
|
+
<div
|
|
52
|
+
className={`lk-toolbar-group${end ? ' lk-toolbar-group--end' : ''}`}
|
|
53
|
+
role="group"
|
|
54
|
+
aria-label={ariaLabel}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Props for `<Toolbar.Button>`. */
|
|
62
|
+
export interface ToolbarButtonProps {
|
|
22
63
|
children: ReactNode;
|
|
23
64
|
onClick: MouseEventHandler<HTMLButtonElement>;
|
|
24
65
|
disabled?: boolean;
|
|
25
66
|
title?: string;
|
|
67
|
+
/** Required when `children` is an icon with no text of its own. */
|
|
68
|
+
'aria-label'?: string;
|
|
69
|
+
/** `danger` reddens on hover — for actions that discard work. */
|
|
70
|
+
variant?: 'default' | 'danger';
|
|
71
|
+
/** Square, for a button whose whole content is one glyph. */
|
|
72
|
+
iconOnly?: boolean;
|
|
26
73
|
}
|
|
27
|
-
|
|
74
|
+
|
|
75
|
+
function Button({
|
|
76
|
+
children,
|
|
77
|
+
onClick,
|
|
78
|
+
disabled,
|
|
79
|
+
title,
|
|
80
|
+
'aria-label': ariaLabel,
|
|
81
|
+
variant = 'default',
|
|
82
|
+
iconOnly,
|
|
83
|
+
}: ToolbarButtonProps) {
|
|
84
|
+
const cls = [
|
|
85
|
+
'lk-toolbar-button',
|
|
86
|
+
variant === 'danger' && 'lk-toolbar-button--danger',
|
|
87
|
+
iconOnly && 'lk-toolbar-button--icon',
|
|
88
|
+
]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join(' ');
|
|
28
91
|
return (
|
|
29
92
|
<button
|
|
30
93
|
type="button"
|
|
31
|
-
className=
|
|
94
|
+
className={cls}
|
|
32
95
|
onClick={onClick}
|
|
33
96
|
disabled={disabled}
|
|
34
97
|
title={title}
|
|
98
|
+
aria-label={ariaLabel ?? title}
|
|
35
99
|
>
|
|
36
100
|
{children}
|
|
37
101
|
</button>
|
|
@@ -44,6 +108,8 @@ function Spacer() {
|
|
|
44
108
|
|
|
45
109
|
/** A label within a toolbar. */
|
|
46
110
|
Toolbar.Title = Title;
|
|
111
|
+
/** A ruled-off run of related controls. */
|
|
112
|
+
Toolbar.Group = Group;
|
|
47
113
|
/** A button within a toolbar. */
|
|
48
114
|
Toolbar.Button = Button;
|
|
49
115
|
/** Flexible space that pushes what follows to the far end of the toolbar. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
.lk-zoom {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--wzl-space-sm);
|
|
5
|
+
min-width: 0;
|
|
6
|
+
|
|
7
|
+
&__slider {
|
|
8
|
+
flex: 0 1 108px;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
&__field {
|
|
12
|
+
flex: 0 0 auto;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Both children are weasel-ui components whose module classes set `width`.
|
|
17
|
+
// See the `.lk-root` prefix convention in theme/base.less.
|
|
18
|
+
.lk-root {
|
|
19
|
+
.lk-zoom {
|
|
20
|
+
// Narrow enough to sit in a trial toolbar, wide enough for the octave
|
|
21
|
+
// detents to be separately hittable.
|
|
22
|
+
&__slider {
|
|
23
|
+
width: 108px;
|
|
24
|
+
min-width: 64px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
&__field {
|
|
28
|
+
width: 62px;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { NumberField, Slider } from '../passthrough/weasel-ui';
|
|
2
|
+
|
|
3
|
+
/** Props for `<ZoomControl>`. */
|
|
4
|
+
export interface ZoomControlProps {
|
|
5
|
+
/** Current zoom as a scale factor: 1 is actual size. */
|
|
6
|
+
zoom: number;
|
|
7
|
+
/** Fired continuously while the slider drags. */
|
|
8
|
+
onZoomChange: (zoom: number) => void;
|
|
9
|
+
/** Fired when a drag settles or the field is committed. Defaults to
|
|
10
|
+
* `onZoomChange`. */
|
|
11
|
+
onZoomCommit?: (zoom: number) => void;
|
|
12
|
+
/** Smallest selectable zoom. Defaults to 0.1 (10%). */
|
|
13
|
+
min?: number;
|
|
14
|
+
/** Largest selectable zoom. Defaults to 8 (800%). */
|
|
15
|
+
max?: number;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Zoom is perceptually geometric — halving is as big a step as doubling — so
|
|
20
|
+
// the slider runs on log2 and 100% lands mid-track for a symmetric range.
|
|
21
|
+
const toTrack = (zoom: number): number => Math.log2(zoom);
|
|
22
|
+
const fromTrack = (v: number): number => 2 ** v;
|
|
23
|
+
|
|
24
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
|
|
25
|
+
|
|
26
|
+
/** Zoom as a log-scaled slider with an editable percentage beside it. Pair it
|
|
27
|
+
* with zoom-in / zoom-out buttons in a `<Toolbar.Group>`; this component owns
|
|
28
|
+
* only the continuous part. */
|
|
29
|
+
export function ZoomControl({
|
|
30
|
+
zoom,
|
|
31
|
+
onZoomChange,
|
|
32
|
+
onZoomCommit,
|
|
33
|
+
min = 0.1,
|
|
34
|
+
max = 8,
|
|
35
|
+
className,
|
|
36
|
+
}: ZoomControlProps) {
|
|
37
|
+
const commit = onZoomCommit ?? onZoomChange;
|
|
38
|
+
const emit = (track: number, done: boolean): void => {
|
|
39
|
+
const next = clamp(fromTrack(track), min, max);
|
|
40
|
+
(done ? commit : onZoomChange)(next);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={`lk-zoom${className ? ` ${className}` : ''}`}>
|
|
45
|
+
<Slider
|
|
46
|
+
className="lk-zoom__slider"
|
|
47
|
+
ariaLabel="Zoom"
|
|
48
|
+
min={toTrack(min)}
|
|
49
|
+
max={toTrack(max)}
|
|
50
|
+
// Detents at the octaves plus actual size, so 25/50/100/200/400% are
|
|
51
|
+
// reachable by drag rather than only by typing.
|
|
52
|
+
stops={[-2, -1, 0, 1, 2]}
|
|
53
|
+
thumbs={[{ value: clamp(toTrack(zoom), toTrack(min), toTrack(max)) }]}
|
|
54
|
+
onInput={(next) => emit(next[0].value, false)}
|
|
55
|
+
onChange={(next) => emit(next[0].value, true)}
|
|
56
|
+
readoutPlacement="none"
|
|
57
|
+
/>
|
|
58
|
+
{/* `style: 'percent'` formats a fraction, so the field carries the scale
|
|
59
|
+
factor itself and renders 0.5 as "50%". */}
|
|
60
|
+
<NumberField
|
|
61
|
+
className="lk-zoom__field"
|
|
62
|
+
aria-label="Zoom"
|
|
63
|
+
value={zoom}
|
|
64
|
+
minValue={min}
|
|
65
|
+
maxValue={max}
|
|
66
|
+
step={0.01}
|
|
67
|
+
hideSteppers
|
|
68
|
+
formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
|
|
69
|
+
onChange={(next) => {
|
|
70
|
+
if (!Number.isFinite(next)) return;
|
|
71
|
+
commit(clamp(next, min, max));
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { FloatingPanel, Legend } from '../index';
|
|
4
|
+
|
|
5
|
+
describe('floating public entry', () => {
|
|
6
|
+
it('reaches a consumer from the package entry point alone', () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<FloatingPanel>
|
|
9
|
+
<Legend entries={[{ key: 'a', label: 'a', color: '#fff' }]} />
|
|
10
|
+
</FloatingPanel>,
|
|
11
|
+
);
|
|
12
|
+
expect(container.querySelector('.lk-floating-panel .lk-legend')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
});
|
package/src/primitives/index.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
export type { FloatingPanelProps } from './FloatingPanel';
|
|
2
|
+
export { FloatingPanel } from './FloatingPanel';
|
|
1
3
|
export { FpsMeter } from './FpsMeter';
|
|
4
|
+
export type { JobProgressProps } from './JobProgress';
|
|
5
|
+
export { JobProgress } from './JobProgress';
|
|
6
|
+
export type { LegendEntry, LegendMark, LegendProps } from './Legend';
|
|
7
|
+
export { Legend } from './Legend';
|
|
2
8
|
export type { ScaleIndicatorProps } from './ScaleIndicator';
|
|
3
9
|
export { ScaleIndicator } from './ScaleIndicator';
|
|
4
10
|
export type { SidebarProps } from './Sidebar';
|
|
5
11
|
export { Sidebar } from './Sidebar';
|
|
6
|
-
export type { StatusBarProps } from './StatusBar';
|
|
12
|
+
export type { StatusBarProps, StatusBarSectionProps } from './StatusBar';
|
|
7
13
|
export { StatusBar } from './StatusBar';
|
|
8
|
-
export type { ToolbarProps } from './Toolbar';
|
|
14
|
+
export type { ToolbarButtonProps, ToolbarGroupProps, ToolbarProps } from './Toolbar';
|
|
9
15
|
export { Toolbar } from './Toolbar';
|
|
16
|
+
export type { ZoomControlProps } from './ZoomControl';
|
|
17
|
+
export { ZoomControl } from './ZoomControl';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { nextIndex } from './useRovingTabIndex';
|
|
3
|
+
|
|
4
|
+
describe('nextIndex', () => {
|
|
5
|
+
it('steps forward and wraps', () => {
|
|
6
|
+
expect(nextIndex(0, 'ArrowRight', 3)).toBe(1);
|
|
7
|
+
expect(nextIndex(2, 'ArrowRight', 3)).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('steps back and wraps', () => {
|
|
11
|
+
expect(nextIndex(1, 'ArrowLeft', 3)).toBe(0);
|
|
12
|
+
expect(nextIndex(0, 'ArrowLeft', 3)).toBe(2);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('jumps to the ends', () => {
|
|
16
|
+
expect(nextIndex(1, 'Home', 3)).toBe(0);
|
|
17
|
+
expect(nextIndex(1, 'End', 3)).toBe(2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns null for a key it does not handle', () => {
|
|
21
|
+
expect(nextIndex(1, 'Enter', 3)).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns null when there is nothing to move to', () => {
|
|
25
|
+
expect(nextIndex(0, 'ArrowRight', 0)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Where a key takes focus within `count` items, or null if it does not move it. */
|
|
4
|
+
export function nextIndex(current: number, key: string, count: number): number | null {
|
|
5
|
+
if (count === 0) return null;
|
|
6
|
+
switch (key) {
|
|
7
|
+
case 'ArrowRight':
|
|
8
|
+
return (current + 1) % count;
|
|
9
|
+
case 'ArrowLeft':
|
|
10
|
+
return (current - 1 + count) % count;
|
|
11
|
+
case 'Home':
|
|
12
|
+
return 0;
|
|
13
|
+
case 'End':
|
|
14
|
+
return count - 1;
|
|
15
|
+
default:
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The APG roving-tabindex contract: exactly one item in the tab order, arrows
|
|
22
|
+
* moving focus within. `tabIndex` is written onto the DOM nodes rather than
|
|
23
|
+
* threaded through props, because the items are arbitrary children — the
|
|
24
|
+
* container never sees them as a list it could index.
|
|
25
|
+
*/
|
|
26
|
+
export function useRovingTabIndex<T extends HTMLElement>() {
|
|
27
|
+
const ref = useRef<T | null>(null);
|
|
28
|
+
const stop = useRef(0);
|
|
29
|
+
|
|
30
|
+
const items = useCallback(
|
|
31
|
+
(): HTMLElement[] =>
|
|
32
|
+
ref.current
|
|
33
|
+
? Array.from(
|
|
34
|
+
ref.current.querySelectorAll<HTMLElement>('button:not([disabled]), [role="button"]'),
|
|
35
|
+
)
|
|
36
|
+
: [],
|
|
37
|
+
[],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const setTabStop = useCallback(
|
|
41
|
+
(index: number) => {
|
|
42
|
+
const found = items();
|
|
43
|
+
const at = Math.min(Math.max(index, 0), Math.max(found.length - 1, 0));
|
|
44
|
+
stop.current = at;
|
|
45
|
+
for (const [i, el] of found.entries()) el.tabIndex = i === at ? 0 : -1;
|
|
46
|
+
},
|
|
47
|
+
[items],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Re-establish the tab stop whenever the item set changes — a trial's toolbar
|
|
51
|
+
// contributions are dynamic, and `disabled` moves a button in and out of the
|
|
52
|
+
// set without touching the child list. The focused item keeps the stop.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const resync = () => {
|
|
55
|
+
const focused = items().indexOf(document.activeElement as HTMLElement);
|
|
56
|
+
setTabStop(focused === -1 ? stop.current : focused);
|
|
57
|
+
};
|
|
58
|
+
resync();
|
|
59
|
+
if (!ref.current) return;
|
|
60
|
+
const mo = new MutationObserver(resync);
|
|
61
|
+
mo.observe(ref.current, { childList: true, subtree: true, attributeFilter: ['disabled'] });
|
|
62
|
+
return () => mo.disconnect();
|
|
63
|
+
}, [items, setTabStop]);
|
|
64
|
+
|
|
65
|
+
const onKeyDown = useCallback(
|
|
66
|
+
(e: KeyboardEvent<T>) => {
|
|
67
|
+
const found = items();
|
|
68
|
+
const current = found.indexOf(document.activeElement as HTMLElement);
|
|
69
|
+
const next = nextIndex(current === -1 ? 0 : current, e.key, found.length);
|
|
70
|
+
if (next === null) return;
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
found[next]?.focus();
|
|
73
|
+
setTabStop(next);
|
|
74
|
+
},
|
|
75
|
+
[items, setTabStop],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return { ref, onKeyDown };
|
|
79
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface LabStoreActions {
|
|
|
39
39
|
deleteSnapshot: (snapshotId: string) => void;
|
|
40
40
|
listSnapshots: (trialId?: string) => SavedSnapshot[];
|
|
41
41
|
setMode: (mode: LabMode) => void;
|
|
42
|
+
setLabTool: (id: string | null) => void;
|
|
43
|
+
setTrialTool: (trialId: string, id: string | null) => void;
|
|
42
44
|
setLayout: (layout: Record<string, unknown>) => void;
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -70,6 +72,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
70
72
|
savedSnapshots: hydratedSnapshots,
|
|
71
73
|
mode: hydratedMode,
|
|
72
74
|
layout: hydratedLayout,
|
|
75
|
+
activeToolId: null,
|
|
73
76
|
|
|
74
77
|
addTrial: (record) => {
|
|
75
78
|
set((s) => ({
|
|
@@ -114,9 +117,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
114
117
|
|
|
115
118
|
updateTrialView: (id, view) => {
|
|
116
119
|
set((s) => ({
|
|
117
|
-
trials: s.trials.map((w) =>
|
|
118
|
-
w.id === id && !Object.is(view, w.view) ? { ...w, view } : w,
|
|
119
|
-
),
|
|
120
|
+
trials: s.trials.map((w) => (w.id === id && !Object.is(view, w.view) ? { ...w, view } : w)),
|
|
120
121
|
}));
|
|
121
122
|
scheduleFlush();
|
|
122
123
|
},
|
|
@@ -203,6 +204,13 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
203
204
|
scheduleFlush();
|
|
204
205
|
},
|
|
205
206
|
|
|
207
|
+
setLabTool: (id) => set({ activeToolId: id }),
|
|
208
|
+
|
|
209
|
+
setTrialTool: (trialId, id) =>
|
|
210
|
+
set((s) => ({
|
|
211
|
+
trials: s.trials.map((t) => (t.id === trialId ? { ...t, activeToolId: id } : t)),
|
|
212
|
+
})),
|
|
213
|
+
|
|
206
214
|
setLayout: (layout) => {
|
|
207
215
|
set({ layout });
|
|
208
216
|
scheduleFlush();
|