@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
package/src/lab/index.ts CHANGED
@@ -2,6 +2,8 @@ export type { LabProps } from './Lab';
2
2
  export { Lab } from './Lab';
3
3
  export type { LabContextValue } from './LabContext';
4
4
  export { LabContext, useLabContext } from './LabContext';
5
+ export type { LabPaletteProps } from './LabPalette';
6
+ export { LabPalette } from './LabPalette';
5
7
  export type { LabShellProps } from './LabShell';
6
8
  export { LabShell } from './LabShell';
7
9
  export type { WorkspaceProps } from './Workspace';
@@ -46,7 +46,7 @@ This is intended for legend/HUD layers that should never be toggled off. Visibil
46
46
 
47
47
  ## Drag handle customization
48
48
 
49
- The drag handle is a button with text content `⋮⋮` and class `lk-layer-list__handle`. Its row height is hardcoded to `28px` for delta computation (`moveDrag` in `LayerList.tsx`). If you change row height in CSS, update the `rowHeight` constant to match.
49
+ The drag handle is a button with class `lk-layer-list__handle` holding `<DragHandleGlyph>`, the same grip `LayerStack` uses. Its padding is transparent and cancelled by an equal negative margin, so the grab target is larger than the drawn dots without widening the row. Row pitch height plus row gap is measured from the DOM when a drag starts, so restyling the row does not skew drag distance. It used to be a hardcoded `28`, which had already drifted from the rendered height by the time it was found.
50
50
 
51
51
  Pointer capture is acquired on `pointerdown` and released on `pointerup`, so dragging works across the document without requiring window-level listeners.
52
52
 
@@ -1,8 +1,10 @@
1
+ // No padding of its own: whatever mounts the list insets it. The sidebar
2
+ // section body already does, and a second inset here put the grip 22px off a
3
+ // 161px-wide sidebar's edge.
1
4
  .lk-layer-list {
2
5
  display: flex;
3
6
  flex-direction: column;
4
- gap: 2px;
5
- padding: var(--wzl-space-sm);
7
+ gap: 1px;
6
8
  }
7
9
 
8
10
  .lk-layer-list__empty {
@@ -14,9 +16,9 @@
14
16
  .lk-layer-list__row {
15
17
  display: flex;
16
18
  align-items: center;
17
- gap: var(--wzl-space-sm);
18
- padding: 4px 6px;
19
- border-radius: 3px;
19
+ gap: var(--wzl-space-xs);
20
+ padding: 1px 2px;
21
+ border-radius: var(--wzl-radius-sm);
20
22
  user-select: none;
21
23
  }
22
24
 
@@ -33,10 +35,28 @@
33
35
  opacity: 0.7;
34
36
  }
35
37
 
36
- .lk-layer-list__handle {
38
+ // Prefixed with `.lk-root` to outrank base.less's `:where(button)` default,
39
+ // which would otherwise wrap this grip in a 24px chrome box with a border, an
40
+ // elevated fill and 12px of side padding — around a glyph a third that size.
41
+ .lk-root .lk-layer-list__handle {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ height: auto;
45
+ // Transparent, so the grab target is comfortable while nothing but the dots
46
+ // is drawn. The negative margin keeps it from widening the row.
47
+ padding: 4px;
48
+ margin: -4px;
49
+ border: 0;
50
+ border-radius: 0;
51
+ background: transparent;
52
+ backdrop-filter: none;
53
+ -webkit-backdrop-filter: none;
37
54
  cursor: grab;
38
55
  color: var(--wzl-fg-muted);
39
- font-family: monospace;
56
+ }
57
+
58
+ .lk-root .lk-layer-list__handle:hover {
59
+ color: var(--wzl-fg);
40
60
  }
41
61
 
42
62
  .lk-layer-list__handle:active {
@@ -51,3 +71,7 @@
51
71
  flex: 1;
52
72
  font-size: var(--wzl-font-size-sm);
53
73
  }
74
+
75
+ .lk-layer-list input[type='checkbox'] {
76
+ accent-color: var(--wzl-accent);
77
+ }
@@ -1,5 +1,6 @@
1
1
  import { type PointerEvent, useRef, useState } from 'react';
2
2
  import type { LayerDescriptor } from '../instrument/types';
3
+ import { DragHandleGlyph } from '../primitives/DragHandleGlyph';
3
4
 
4
5
  /** Props for `<LayerList>`. */
5
6
  export interface LayerListProps {
@@ -14,6 +15,8 @@ interface DragState {
14
15
  pointerId: number;
15
16
  fromIndex: number;
16
17
  startY: number;
18
+ /** Row height plus row gap, measured when the drag starts. */
19
+ pitch: number;
17
20
  }
18
21
 
19
22
  /** A reorderable list of layers with per-layer visibility toggles. Layers
@@ -34,15 +37,25 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
34
37
 
35
38
  const startDrag = (e: PointerEvent<HTMLElement>, index: number) => {
36
39
  e.currentTarget.setPointerCapture(e.pointerId);
37
- dragRef.current = { pointerId: e.pointerId, fromIndex: index, startY: e.clientY };
40
+ // Measured at grab time rather than hardcoded: a restyle that changes row
41
+ // height or gap would otherwise silently skew every drag distance.
42
+ const row = e.currentTarget.closest('.lk-layer-list__row');
43
+ const list = row?.parentElement;
44
+ const gap = list ? Number.parseFloat(getComputedStyle(list).rowGap) || 0 : 0;
45
+ const pitch = row ? row.getBoundingClientRect().height + gap : 0;
46
+ dragRef.current = {
47
+ pointerId: e.pointerId,
48
+ fromIndex: index,
49
+ startY: e.clientY,
50
+ pitch: pitch > 0 ? pitch : 1,
51
+ };
38
52
  setDragIndex(index);
39
53
  };
40
54
 
41
55
  const moveDrag = (e: PointerEvent<HTMLElement>) => {
42
56
  const drag = dragRef.current;
43
57
  if (!drag || drag.pointerId !== e.pointerId) return;
44
- const rowHeight = 28;
45
- const delta = Math.round((e.clientY - drag.startY) / rowHeight);
58
+ const delta = Math.round((e.clientY - drag.startY) / drag.pitch);
46
59
  const target = Math.min(reorderable.length - 1, Math.max(0, drag.fromIndex + delta));
47
60
  setDragIndex(target);
48
61
  };
@@ -82,7 +95,7 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
82
95
  onPointerMove={moveDrag}
83
96
  onPointerUp={endDrag}
84
97
  >
85
- ⋮⋮
98
+ <DragHandleGlyph size={13} />
86
99
  </button>
87
100
  <input
88
101
  className="lk-layer-list__check"
@@ -114,6 +114,29 @@ export {
114
114
  Select,
115
115
  SelectItem,
116
116
  type SelectItemProps,
117
+ type BuiltinPref,
118
+ isPrefLeaf,
119
+ type PrefBoolean,
120
+ type PrefBooleanControl,
121
+ type PrefColor,
122
+ type PrefCustom,
123
+ type PrefEnum,
124
+ type PrefEnumControl,
125
+ type PrefEnumEncoding,
126
+ type PrefGroup,
127
+ type PrefKind,
128
+ type PrefLeaf,
129
+ type PrefNumber,
130
+ type PrefNumberControl,
131
+ type PrefNumberUnit,
132
+ type PrefObject,
133
+ type PrefPaint,
134
+ type PrefRenderContext,
135
+ type PrefRenderer,
136
+ type PrefString,
137
+ type PrefStringControl,
138
+ prefValueAtPath,
139
+ visiblePrefSubtree,
117
140
  type SelectOption,
118
141
  type SelectProps,
119
142
  Sidebar,
@@ -0,0 +1,29 @@
1
+ /** Props for `<DragHandleGlyph>`. */
2
+ export interface DragHandleGlyphProps {
3
+ /** Height in px; width scales with it. Default 16. */
4
+ size?: number;
5
+ }
6
+
7
+ /** The two-column dot grip used on anything draggable by a handle.
8
+ *
9
+ * Deliberately not part of `@weasel-js/ui`'s icon register: that register is
10
+ * outline strokes at a fixed weight, and a grip is filled dots. Forcing it in
11
+ * would either break the register's rule or produce a worse glyph. */
12
+ export function DragHandleGlyph({ size = 16 }: DragHandleGlyphProps) {
13
+ return (
14
+ <svg
15
+ width={(size * 8) / 16}
16
+ height={size}
17
+ viewBox="0 0 8 16"
18
+ fill="currentColor"
19
+ aria-hidden="true"
20
+ >
21
+ <circle cx="2" cy="3" r="1.1" />
22
+ <circle cx="6" cy="3" r="1.1" />
23
+ <circle cx="2" cy="8" r="1.1" />
24
+ <circle cx="6" cy="8" r="1.1" />
25
+ <circle cx="2" cy="13" r="1.1" />
26
+ <circle cx="6" cy="13" r="1.1" />
27
+ </svg>
28
+ );
29
+ }
@@ -0,0 +1,28 @@
1
+ .lk-floating-panel {
2
+ cursor: grab;
3
+ touch-action: none;
4
+ user-select: none;
5
+ background: var(--wzl-surface);
6
+ border: var(--wzl-border-w) solid var(--wzl-border);
7
+ border-radius: var(--wzl-radius-md);
8
+ padding: var(--wzl-space-sm);
9
+ box-shadow: 0 6px 18px rgb(0 0 0 / 40%);
10
+ }
11
+
12
+ // An item with no measured size is withheld from layout, so the first paint
13
+ // would otherwise land at the top-left before jumping to its anchor.
14
+ .lk-floating-panel:not([data-placed='true']) {
15
+ visibility: hidden;
16
+ }
17
+
18
+ .lk-floating-panel[data-dragging='true'] {
19
+ cursor: grabbing;
20
+ }
21
+
22
+ .lk-floating-panel-story-host {
23
+ position: relative;
24
+ width: 520px;
25
+ height: 340px;
26
+ background: var(--wzl-surface-sunken);
27
+ border: var(--wzl-border-w) solid var(--wzl-border);
28
+ }
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { FloatingPanel } from './FloatingPanel';
3
+ import { Legend } from './Legend';
4
+
5
+ // The panel positions against its offset parent, so a story needs a sized,
6
+ // positioned host or there is nothing for it to float in.
7
+ const meta: Meta<typeof FloatingPanel> = {
8
+ title: 'labkit/Primitives/FloatingPanel',
9
+ component: FloatingPanel,
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="lk-floating-panel-story-host">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ args: { children: 'drag me' },
18
+ };
19
+ export default meta;
20
+
21
+ type Story = StoryObj<typeof FloatingPanel>;
22
+
23
+ export const BottomLeft: Story = {};
24
+
25
+ export const TopRight: Story = { args: { anchor: 'top-right' } };
26
+
27
+ export const TwoCornersOnly: Story = {
28
+ args: { anchor: 'top-left', snapCorners: ['top-left', 'bottom-right'] },
29
+ };
30
+
31
+ export const HoldingALegend: Story = {
32
+ args: {
33
+ anchor: 'bottom-right',
34
+ children: (
35
+ <Legend
36
+ entries={[
37
+ { key: 'contour', label: 'contour', color: '#7d7f86' },
38
+ { key: 'floor', label: 'bend floor', color: '#9a9ca3', mark: 'dash' },
39
+ { key: 'authored', label: 'authored', color: '#2aa87a', mark: 'dot' },
40
+ ]}
41
+ />
42
+ ),
43
+ },
44
+ };
45
+
46
+ export const Remembered: Story = { args: { storageKey: 'labkit.story.panel' } };
@@ -0,0 +1,240 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { FloatingPanel } from './FloatingPanel';
4
+
5
+ // The shared stub reports one size for every element, which makes the panel and
6
+ // its container the same box. Report per-target sizes instead so placement math
7
+ // has something real to chew on. Keyed by class, because `observe()` fires
8
+ // synchronously inside the effect — before a test could register an element.
9
+ const sizes = { host: { w: 400, h: 300 }, panel: { w: 100, h: 40 } };
10
+ const realRO = globalThis.ResizeObserver;
11
+
12
+ beforeEach(() => {
13
+ localStorage.clear();
14
+ sizes.host = { w: 400, h: 300 };
15
+ sizes.panel = { w: 100, h: 40 };
16
+ globalThis.ResizeObserver = class {
17
+ #cb: ResizeObserverCallback;
18
+ constructor(cb: ResizeObserverCallback) {
19
+ this.#cb = cb;
20
+ }
21
+ observe(target: Element) {
22
+ const { w, h } = target.classList.contains('lk-floating-panel') ? sizes.panel : sizes.host;
23
+ const contentRect = { width: w, height: h, x: 0, y: 0, top: 0, left: 0 };
24
+ this.#cb([{ target, contentRect } as ResizeObserverEntry], this);
25
+ }
26
+ unobserve() {}
27
+ disconnect() {}
28
+ } as unknown as typeof ResizeObserver;
29
+ });
30
+
31
+ afterEach(() => {
32
+ globalThis.ResizeObserver = realRO;
33
+ });
34
+
35
+ /** Renders a panel in a 400x300 host, the panel itself measuring 100x40. */
36
+ function renderPanel(ui: React.ReactElement) {
37
+ const result = render(<div>{ui}</div>);
38
+ return { ...result, panel: result.container.querySelector('.lk-floating-panel') as HTMLElement };
39
+ }
40
+
41
+ describe('FloatingPanel', () => {
42
+ it('renders its children', () => {
43
+ render(<FloatingPanel>hello</FloatingPanel>);
44
+ expect(screen.getByText('hello')).toBeInTheDocument();
45
+ });
46
+
47
+ it('is absolutely positioned so it floats over whatever it sits in', () => {
48
+ const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
49
+ expect(panel.style.position).toBe('absolute');
50
+ });
51
+
52
+ it('accepts an extra class name', () => {
53
+ const { container } = render(<FloatingPanel className="is-mine">x</FloatingPanel>);
54
+ expect(container.querySelector('.lk-floating-panel')?.className).toContain('is-mine');
55
+ });
56
+
57
+ it('rests in the bottom-left corner by default, one inset in', () => {
58
+ const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
59
+ expect(panel.style.left).toBe('12px');
60
+ expect(panel.style.top).toBe('248px'); // 300 - 40 - 12
61
+ });
62
+
63
+ it('honors the anchor it is given', () => {
64
+ const { panel } = renderPanel(<FloatingPanel anchor="top-right">x</FloatingPanel>);
65
+ expect(panel.style.left).toBe('288px'); // 400 - 100 - 12
66
+ expect(panel.style.top).toBe('12px');
67
+ });
68
+
69
+ it('honors a custom inset', () => {
70
+ const { panel } = renderPanel(
71
+ <FloatingPanel anchor="top-left" inset={30}>
72
+ x
73
+ </FloatingPanel>,
74
+ );
75
+ expect(panel.style.left).toBe('30px');
76
+ expect(panel.style.top).toBe('30px');
77
+ });
78
+
79
+ it('stays hidden until it has been measured and placed', () => {
80
+ sizes.panel = { w: 0, h: 0 };
81
+ const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
82
+ // The strategy withholds an item with no size, so there is no rect to paint.
83
+ expect(panel.getAttribute('data-placed')).toBeNull();
84
+ });
85
+
86
+ it('measures its border box, so padding does not push it past a corner', () => {
87
+ // Same content box, but 8px padding and a 1px border on every side.
88
+ const realObserve = globalThis.ResizeObserver;
89
+ globalThis.ResizeObserver = class {
90
+ #cb: ResizeObserverCallback;
91
+ constructor(cb: ResizeObserverCallback) {
92
+ this.#cb = cb;
93
+ }
94
+ observe(target: Element) {
95
+ const isPanel = target.classList.contains('lk-floating-panel');
96
+ const { w, h } = isPanel ? sizes.panel : sizes.host;
97
+ const contentRect = { width: w, height: h, x: 0, y: 0, top: 0, left: 0 };
98
+ const entry = {
99
+ target,
100
+ contentRect,
101
+ ...(isPanel ? { borderBoxSize: [{ inlineSize: w + 18, blockSize: h + 18 }] } : {}),
102
+ };
103
+ this.#cb([entry as unknown as ResizeObserverEntry], this);
104
+ }
105
+ unobserve() {}
106
+ disconnect() {}
107
+ } as unknown as typeof ResizeObserver;
108
+ const { panel } = renderPanel(<FloatingPanel anchor="top-right">x</FloatingPanel>);
109
+ globalThis.ResizeObserver = realObserve;
110
+ expect(panel.style.left).toBe('270px'); // 400 - (100 + 18) - 12
111
+ });
112
+
113
+ it('marks itself placed once it has a rect', () => {
114
+ const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
115
+ expect(panel.dataset.placed).toBe('true');
116
+ });
117
+ });
118
+
119
+ describe('FloatingPanel dragging', () => {
120
+ it('marks itself dragging between pointerdown and pointerup', () => {
121
+ const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
122
+ fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
123
+ expect(panel.dataset.dragging).toBe('true');
124
+ fireEvent.pointerUp(panel);
125
+ expect(panel.dataset.dragging).toBeUndefined();
126
+ });
127
+
128
+ it('moves with the pointer, accumulating across moves', () => {
129
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
130
+ expect(panel.style.left).toBe('12px');
131
+ fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
132
+ fireEvent.pointerMove(panel, { clientX: 200, clientY: 180 });
133
+ expect(panel.style.left).toBe('100px');
134
+ expect(panel.style.top).toBe('80px');
135
+ fireEvent.pointerMove(panel, { clientX: 250, clientY: 180 });
136
+ expect(panel.style.left).toBe('150px');
137
+ expect(panel.style.top).toBe('80px');
138
+ });
139
+
140
+ it('snaps to a corner when dropped near one', () => {
141
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
142
+ fireEvent.pointerDown(panel, { clientX: 0, clientY: 0 });
143
+ // Aim at the bottom-right corner; the snap threshold captures it.
144
+ fireEvent.pointerMove(panel, { clientX: 400, clientY: 300 });
145
+ expect(panel.style.left).toBe('288px'); // 400 - 100 - 12
146
+ expect(panel.style.top).toBe('248px'); // 300 - 40 - 12
147
+ });
148
+
149
+ it('refuses a corner it was not given', () => {
150
+ const { panel } = renderPanel(
151
+ <FloatingPanel anchor="top-left" snapCorners={['top-left']}>
152
+ x
153
+ </FloatingPanel>,
154
+ );
155
+ fireEvent.pointerDown(panel, { clientX: 0, clientY: 0 });
156
+ fireEvent.pointerMove(panel, { clientX: 400, clientY: 300 });
157
+ // Clamped inside the container, but not snapped to the bottom-right inset.
158
+ expect(panel.style.left).toBe('300px');
159
+ expect(panel.style.top).toBe('260px');
160
+ });
161
+
162
+ it('does not start a drag from an interactive child', () => {
163
+ const { panel } = renderPanel(
164
+ <FloatingPanel>
165
+ <button type="button">press</button>
166
+ </FloatingPanel>,
167
+ );
168
+ fireEvent.pointerDown(screen.getByRole('button'), { clientX: 10, clientY: 10 });
169
+ expect(panel.dataset.dragging).toBeUndefined();
170
+ });
171
+
172
+ it('does not start a drag from a child opting out', () => {
173
+ const { panel } = renderPanel(
174
+ <FloatingPanel>
175
+ <span data-no-drag="">nope</span>
176
+ </FloatingPanel>,
177
+ );
178
+ fireEvent.pointerDown(screen.getByText('nope'), { clientX: 10, clientY: 10 });
179
+ expect(panel.dataset.dragging).toBeUndefined();
180
+ });
181
+
182
+ it('stops the pointerdown reaching a pan/zoom surface underneath', () => {
183
+ const onDown = vi.fn();
184
+ const { container } = render(
185
+ <div onPointerDown={onDown}>
186
+ <FloatingPanel>x</FloatingPanel>
187
+ </div>,
188
+ );
189
+ fireEvent.pointerDown(container.querySelector('.lk-floating-panel') as HTMLElement, {
190
+ clientX: 5,
191
+ clientY: 5,
192
+ });
193
+ expect(onDown).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it('lets a pointerdown on an interactive child through to the surface', () => {
197
+ const onDown = vi.fn();
198
+ render(
199
+ <div onPointerDown={onDown}>
200
+ <FloatingPanel>
201
+ <button type="button">press</button>
202
+ </FloatingPanel>
203
+ </div>,
204
+ );
205
+ fireEvent.pointerDown(screen.getByRole('button'), { clientX: 5, clientY: 5 });
206
+ expect(onDown).toHaveBeenCalledTimes(1);
207
+ });
208
+ });
209
+
210
+ describe('FloatingPanel persistence', () => {
211
+ it('writes its placement under the given key once dragged', () => {
212
+ const { panel } = renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>);
213
+ fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
214
+ fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
215
+ fireEvent.pointerUp(panel);
216
+ const stored = JSON.parse(localStorage.getItem('k') ?? 'null');
217
+ expect(stored).toMatchObject({ x: expect.any(Number), y: expect.any(Number) });
218
+ expect(stored).toHaveProperty('anchor');
219
+ });
220
+
221
+ it('restores what it stored on a later mount', () => {
222
+ localStorage.setItem('k', JSON.stringify({ x: 140, y: 90, anchor: null }));
223
+ const { panel } = renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>);
224
+ expect(panel.style.left).toBe('140px');
225
+ expect(panel.style.top).toBe('90px');
226
+ });
227
+
228
+ it('writes nothing when no key is given', () => {
229
+ const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
230
+ fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
231
+ fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
232
+ fireEvent.pointerUp(panel);
233
+ expect(localStorage.length).toBe(0);
234
+ });
235
+
236
+ it('survives a corrupt stored value rather than throwing', () => {
237
+ localStorage.setItem('k', '{{{');
238
+ expect(() => renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>)).not.toThrow();
239
+ });
240
+ });