@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.
Files changed (196) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  4. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  5. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +2 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +8 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  13. package/dist/chunk-4TMMVIDM.js.map +1 -0
  14. package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
  15. package/dist/chunk-HFDVGF4X.js.map +1 -0
  16. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  17. package/dist/chunk-NS54R52R.js.map +1 -0
  18. package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
  19. package/dist/chunk-O2BULFHX.js.map +1 -0
  20. package/dist/chunk-O7NVSCLN.js +266 -0
  21. package/dist/chunk-O7NVSCLN.js.map +1 -0
  22. package/dist/chunk-PO6L3NSH.js +366 -0
  23. package/dist/chunk-PO6L3NSH.js.map +1 -0
  24. package/dist/chunk-PQJ5B2U2.js +27 -0
  25. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  26. package/dist/chunk-RVNN4CHQ.js +212 -0
  27. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  28. package/dist/chunk-TLGRYALP.js +6151 -0
  29. package/dist/chunk-TLGRYALP.js.map +1 -0
  30. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  31. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  32. package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
  33. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  34. package/dist/chunk-ZZAYVX4X.js +505 -0
  35. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  36. package/dist/controls/index.d.ts +19 -5
  37. package/dist/controls/index.js +3 -1
  38. package/dist/dragdrop/index.d.ts +4 -2
  39. package/dist/index.d.ts +246 -106
  40. package/dist/index.js +673 -559
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +5 -3
  43. package/dist/layers/index.js +2 -1
  44. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  45. package/dist/passthrough/weasel-canvas.js +1 -1
  46. package/dist/passthrough/weasel-ui.d.ts +3799 -3672
  47. package/dist/passthrough/weasel-ui.js +2 -2
  48. package/dist/primitives/index.d.ts +102 -8
  49. package/dist/primitives/index.js +4 -1
  50. package/dist/state/index.d.ts +3 -2
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +542 -153
  53. package/dist/surface/index.js +2 -1
  54. package/dist/ui/layers/index.js +4 -3
  55. package/dist/undo/index.d.ts +4 -2
  56. package/package.json +16 -7
  57. package/src/canvas/AGENTS.md +4 -0
  58. package/src/canvas/CanvasStack.tsx +6 -2
  59. package/src/canvas/useLayerScheduler.ts +22 -15
  60. package/src/canvas/usePanZoom.test.ts +80 -0
  61. package/src/canvas/usePanZoom.ts +16 -2
  62. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  63. package/src/chrome/builtins.test.ts +153 -0
  64. package/src/chrome/builtins.tsx +205 -0
  65. package/src/chrome/index.ts +25 -0
  66. package/src/chrome/merge.test.ts +51 -0
  67. package/src/chrome/merge.ts +52 -0
  68. package/src/chrome/regions/PaletteRegion.less +14 -0
  69. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  70. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  71. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  72. package/src/chrome/regions/StatusRegion.tsx +31 -0
  73. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  74. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  75. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  76. package/src/chrome/regions/ViewportRegion.less +42 -0
  77. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  78. package/src/chrome/regions/regions.test.tsx +81 -0
  79. package/src/chrome/types.ts +127 -0
  80. package/src/config/builder.test.ts +87 -0
  81. package/src/config/builder.ts +224 -0
  82. package/src/config/fromConfigField.test.ts +93 -0
  83. package/src/config/fromConfigField.ts +74 -0
  84. package/src/config/index.ts +22 -0
  85. package/src/config/resolve.test.ts +120 -0
  86. package/src/config/resolve.ts +67 -0
  87. package/src/config/rules.ts +68 -0
  88. package/src/config/types.ts +108 -0
  89. package/src/config/useConfigSchema.ts +30 -0
  90. package/src/config/visible.ts +20 -0
  91. package/src/controls/ControlPanel.less +4 -58
  92. package/src/controls/ControlPanel.stories.tsx +86 -0
  93. package/src/controls/ControlPanel.test.tsx +223 -10
  94. package/src/controls/ControlPanel.tsx +202 -203
  95. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  96. package/src/dragdrop/DragGhost.less +1 -1
  97. package/src/dragdrop/Palette.less +1 -1
  98. package/src/index.ts +25 -3
  99. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  100. package/src/instrument/defineInstrument.test.ts +38 -1
  101. package/src/instrument/defineInstrument.ts +31 -4
  102. package/src/instrument/index.ts +1 -0
  103. package/src/instrument/types.ts +27 -4
  104. package/src/instrument/validateConfigSchema.test.ts +13 -3
  105. package/src/instrument/validateConfigSchema.ts +7 -10
  106. package/src/lab/Lab.chrome.test.tsx +65 -0
  107. package/src/lab/Lab.tsx +58 -15
  108. package/src/lab/LabContext.ts +6 -0
  109. package/src/lab/LabFullChrome.stories.tsx +308 -0
  110. package/src/lab/LabHeader.test.tsx +42 -0
  111. package/src/lab/LabHeader.tsx +57 -0
  112. package/src/lab/LabPalette.test.tsx +35 -0
  113. package/src/lab/LabPalette.tsx +34 -0
  114. package/src/lab/LabShell.less +50 -3
  115. package/src/lab/Workspace.less +1 -30
  116. package/src/lab/Workspace.test.tsx +20 -10
  117. package/src/lab/Workspace.tsx +5 -8
  118. package/src/lab/index.ts +2 -0
  119. package/src/layers/AGENTS.md +1 -1
  120. package/src/layers/LayerList.less +31 -7
  121. package/src/layers/LayerList.tsx +17 -4
  122. package/src/passthrough/weasel-ui.ts +23 -0
  123. package/src/primitives/DragHandleGlyph.tsx +29 -0
  124. package/src/primitives/FloatingPanel.less +28 -0
  125. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  126. package/src/primitives/FloatingPanel.test.tsx +240 -0
  127. package/src/primitives/FloatingPanel.tsx +190 -0
  128. package/src/primitives/FpsMeter.test.tsx +44 -0
  129. package/src/primitives/FpsMeter.tsx +20 -9
  130. package/src/primitives/JobProgress.less +69 -0
  131. package/src/primitives/JobProgress.stories.tsx +48 -0
  132. package/src/primitives/JobProgress.tsx +65 -0
  133. package/src/primitives/Legend.less +47 -0
  134. package/src/primitives/Legend.stories.tsx +32 -0
  135. package/src/primitives/Legend.test.tsx +57 -0
  136. package/src/primitives/Legend.tsx +40 -0
  137. package/src/primitives/Sidebar.less +32 -2
  138. package/src/primitives/StatusBar.less +7 -0
  139. package/src/primitives/StatusBar.tsx +10 -3
  140. package/src/primitives/Toolbar.less +50 -3
  141. package/src/primitives/Toolbar.test.tsx +67 -1
  142. package/src/primitives/Toolbar.tsx +72 -6
  143. package/src/primitives/ZoomControl.less +31 -0
  144. package/src/primitives/ZoomControl.tsx +76 -0
  145. package/src/primitives/floating.entry.test.tsx +14 -0
  146. package/src/primitives/index.ts +10 -2
  147. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  148. package/src/primitives/useRovingTabIndex.ts +79 -0
  149. package/src/state/store.ts +11 -3
  150. package/src/state/toolSlot.test.ts +36 -0
  151. package/src/state/types.ts +4 -0
  152. package/src/styles.less +6 -0
  153. package/src/surface/useTiledSurface.ts +14 -10
  154. package/src/test-setup.ts +26 -0
  155. package/src/theme/base.less +19 -2
  156. package/src/theme/interstellar.tokens.json +6 -2
  157. package/src/tools/types.ts +24 -0
  158. package/src/trial/Trial.config.test.tsx +113 -0
  159. package/src/trial/Trial.job.test.tsx +13 -15
  160. package/src/trial/Trial.less +115 -10
  161. package/src/trial/Trial.stories.tsx +1 -0
  162. package/src/trial/Trial.test.tsx +67 -96
  163. package/src/trial/Trial.tsx +80 -39
  164. package/src/trial/TrialChrome.tsx +99 -80
  165. package/src/trial/TrialDragContext.ts +10 -0
  166. package/src/trial/TrialTitleBar.tsx +41 -0
  167. package/src/trial/index.ts +1 -15
  168. package/src/trial/trialChrome.borders.test.ts +38 -0
  169. package/src/ui/layers/LayerStack.less +9 -9
  170. package/src/ui/layers/LayerStack.tsx +1 -13
  171. package/src/ui/properties/CurveField.less +2 -2
  172. package/src/ui/properties/CurveField.test.tsx +11 -11
  173. package/src/ui/properties/PropertyGroup.less +2 -2
  174. package/src/ui/properties/PropertyPanel.less +55 -41
  175. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  176. package/src/ui/properties/storyLayouts.tsx +2 -2
  177. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  178. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  179. package/dist/chunk-574LJAV4.js.map +0 -1
  180. package/dist/chunk-5R2ATYPJ.js +0 -201
  181. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  182. package/dist/chunk-73KA7WBO.js +0 -5992
  183. package/dist/chunk-73KA7WBO.js.map +0 -1
  184. package/dist/chunk-BOHF3PQO.js.map +0 -1
  185. package/dist/chunk-G5TJVQQT.js.map +0 -1
  186. package/dist/chunk-PMAU3SEE.js +0 -126
  187. package/dist/chunk-PMAU3SEE.js.map +0 -1
  188. package/dist/chunk-SFL7NFKN.js.map +0 -1
  189. package/dist/chunk-THBG7FQZ.js.map +0 -1
  190. package/dist/chunk-VUU5UXHE.js.map +0 -1
  191. package/src/instrument/capabilityDetector.test.ts +0 -64
  192. package/src/instrument/capabilityDetector.ts +0 -20
  193. package/src/trial/DefaultSidebar.tsx +0 -29
  194. package/src/trial/DefaultStatusBar.tsx +0 -19
  195. package/src/trial/DefaultToolbar.tsx +0 -92
  196. 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-left: 1px solid var(--wzl-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: 600;
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
- interface SectionProps {
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 }: SectionProps) {
17
- return <span className="lk-status-bar-section">{children}</span>;
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: var(--wzl-space-xs) var(--wzl-space-sm);
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) + 2 * var(--wzl-space-xs));
13
+ min-height: calc(var(--wzl-control-h) + 4px);
9
14
  }
10
15
 
11
16
  .lk-toolbar-title {
12
- font-weight: 600;
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
- return <div className="lk-toolbar">{children}</div>;
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
- interface ButtonProps {
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
- function Button({ children, onClick, disabled, title }: ButtonProps) {
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="lk-toolbar-button"
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
+ });
@@ -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
+ }
@@ -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();