@weasel-js/labkit 1.2.0 → 1.3.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 (227) 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/types-1Sdxy_Pv.d.ts +90 -0
  4. package/dist/_dts/types-BttTIent.d.ts +314 -0
  5. package/dist/_dts/types-DCGEcMO_.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 +3 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +9 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/chunk-3XXPU73K.js +138 -0
  13. package/dist/chunk-3XXPU73K.js.map +1 -0
  14. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-6X5RBC6G.js +366 -0
  17. package/dist/chunk-6X5RBC6G.js.map +1 -0
  18. package/dist/{chunk-574LJAV4.js → chunk-BDJXIBRZ.js} +33 -35
  19. package/dist/chunk-BDJXIBRZ.js.map +1 -0
  20. package/dist/{chunk-SFL7NFKN.js → chunk-BMW4TDP5.js} +15 -7
  21. package/dist/chunk-BMW4TDP5.js.map +1 -0
  22. package/dist/chunk-JDROYQM3.js +266 -0
  23. package/dist/chunk-JDROYQM3.js.map +1 -0
  24. package/dist/{chunk-THBG7FQZ.js → chunk-LGKRIUSW.js} +15 -12
  25. package/dist/chunk-LGKRIUSW.js.map +1 -0
  26. package/dist/{chunk-BOHF3PQO.js → chunk-QCICIHEO.js} +8129 -5196
  27. package/dist/chunk-QCICIHEO.js.map +1 -0
  28. package/dist/chunk-QRYPXSGP.js +212 -0
  29. package/dist/chunk-QRYPXSGP.js.map +1 -0
  30. package/dist/chunk-RGT4EJIB.js +6961 -0
  31. package/dist/chunk-RGT4EJIB.js.map +1 -0
  32. package/dist/chunk-SBXNQW4G.js +3 -0
  33. package/dist/chunk-SBXNQW4G.js.map +1 -0
  34. package/dist/chunk-T66HU3HX.js +17 -0
  35. package/dist/chunk-T66HU3HX.js.map +1 -0
  36. package/dist/chunk-WTI26YTM.js +266 -0
  37. package/dist/chunk-WTI26YTM.js.map +1 -0
  38. package/dist/controls/index.d.ts +19 -5
  39. package/dist/controls/index.js +3 -1
  40. package/dist/dragdrop/index.d.ts +46 -3
  41. package/dist/dragdrop/index.js +3 -1
  42. package/dist/index.d.ts +735 -565
  43. package/dist/index.js +661 -1019
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +5 -3
  46. package/dist/layers/index.js +4 -1
  47. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  48. package/dist/passthrough/weasel-canvas.js +1 -1
  49. package/dist/passthrough/weasel-ui.d.ts +3811 -3671
  50. package/dist/passthrough/weasel-ui.js +3 -2
  51. package/dist/primitives/index.d.ts +102 -8
  52. package/dist/primitives/index.js +5 -1
  53. package/dist/state/index.d.ts +3 -2
  54. package/dist/state/index.js +2 -2
  55. package/dist/styles.css +490 -803
  56. package/dist/surface/index.js +2 -1
  57. package/dist/ui/layers/index.d.ts +8 -2
  58. package/dist/ui/layers/index.js +2 -3
  59. package/dist/undo/index.d.ts +4 -2
  60. package/package.json +16 -7
  61. package/src/canvas/AGENTS.md +4 -0
  62. package/src/canvas/CanvasStack.tsx +6 -2
  63. package/src/canvas/useLayerScheduler.ts +22 -15
  64. package/src/canvas/usePanZoom.test.ts +80 -0
  65. package/src/canvas/usePanZoom.ts +16 -2
  66. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  67. package/src/chrome/builtins.test.ts +153 -0
  68. package/src/chrome/builtins.tsx +205 -0
  69. package/src/chrome/index.ts +25 -0
  70. package/src/chrome/merge.test.ts +51 -0
  71. package/src/chrome/merge.ts +52 -0
  72. package/src/chrome/regions/PaletteRegion.less +14 -0
  73. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  74. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  75. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  76. package/src/chrome/regions/StatusRegion.tsx +31 -0
  77. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  78. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  79. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  80. package/src/chrome/regions/ViewportRegion.less +42 -0
  81. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  82. package/src/chrome/regions/regions.test.tsx +81 -0
  83. package/src/chrome/types.ts +127 -0
  84. package/src/config/builder.test.ts +93 -0
  85. package/src/config/builder.ts +220 -0
  86. package/src/config/fromConfigField.test.ts +93 -0
  87. package/src/config/fromConfigField.ts +74 -0
  88. package/src/config/index.ts +22 -0
  89. package/src/config/resolve.test.ts +119 -0
  90. package/src/config/resolve.ts +67 -0
  91. package/src/config/rules.ts +68 -0
  92. package/src/config/types.ts +108 -0
  93. package/src/config/useConfigSchema.ts +30 -0
  94. package/src/config/visible.ts +20 -0
  95. package/src/controls/ControlPanel.less +4 -58
  96. package/src/controls/ControlPanel.stories.tsx +125 -0
  97. package/src/controls/ControlPanel.test.tsx +245 -10
  98. package/src/controls/ControlPanel.tsx +236 -203
  99. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  100. package/src/dragdrop/DragGhost.less +1 -1
  101. package/src/dragdrop/Palette.less +1 -1
  102. package/src/dragdrop/index.ts +11 -0
  103. package/src/index.ts +74 -5
  104. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  105. package/src/instrument/defineInstrument.test.ts +38 -1
  106. package/src/instrument/defineInstrument.ts +31 -4
  107. package/src/instrument/index.ts +1 -0
  108. package/src/instrument/types.ts +27 -4
  109. package/src/instrument/validateConfigSchema.test.ts +13 -3
  110. package/src/instrument/validateConfigSchema.ts +7 -10
  111. package/src/lab/Lab.chrome.test.tsx +65 -0
  112. package/src/lab/Lab.tsx +58 -15
  113. package/src/lab/LabContext.ts +6 -0
  114. package/src/lab/LabFullChrome.stories.tsx +308 -0
  115. package/src/lab/LabHeader.test.tsx +42 -0
  116. package/src/lab/LabHeader.tsx +57 -0
  117. package/src/lab/LabPalette.test.tsx +35 -0
  118. package/src/lab/LabPalette.tsx +34 -0
  119. package/src/lab/LabShell.less +50 -3
  120. package/src/lab/Workspace.less +1 -30
  121. package/src/lab/Workspace.test.tsx +20 -10
  122. package/src/lab/Workspace.tsx +5 -8
  123. package/src/lab/index.ts +3 -1
  124. package/src/layers/AGENTS.md +1 -1
  125. package/src/layers/LayerList.less +31 -7
  126. package/src/layers/LayerList.tsx +17 -4
  127. package/src/passthrough/weasel-ui.ts +28 -0
  128. package/src/primitives/FloatingPanel.less +28 -0
  129. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  130. package/src/primitives/FloatingPanel.test.tsx +240 -0
  131. package/src/primitives/FloatingPanel.tsx +190 -0
  132. package/src/primitives/FpsMeter.test.tsx +44 -0
  133. package/src/primitives/FpsMeter.tsx +20 -9
  134. package/src/primitives/JobProgress.less +69 -0
  135. package/src/primitives/JobProgress.stories.tsx +48 -0
  136. package/src/primitives/JobProgress.tsx +65 -0
  137. package/src/primitives/Legend.less +47 -0
  138. package/src/primitives/Legend.stories.tsx +32 -0
  139. package/src/primitives/Legend.test.tsx +57 -0
  140. package/src/primitives/Legend.tsx +40 -0
  141. package/src/primitives/Sidebar.less +32 -2
  142. package/src/primitives/StatusBar.less +7 -0
  143. package/src/primitives/StatusBar.tsx +10 -3
  144. package/src/primitives/Toolbar.less +50 -3
  145. package/src/primitives/Toolbar.test.tsx +67 -1
  146. package/src/primitives/Toolbar.tsx +72 -6
  147. package/src/primitives/ZoomControl.less +31 -0
  148. package/src/primitives/ZoomControl.tsx +76 -0
  149. package/src/primitives/floating.entry.test.tsx +14 -0
  150. package/src/primitives/index.ts +10 -2
  151. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  152. package/src/primitives/useRovingTabIndex.ts +79 -0
  153. package/src/state/store.ts +11 -3
  154. package/src/state/toolSlot.test.ts +36 -0
  155. package/src/state/types.ts +4 -0
  156. package/src/styles.less +6 -4
  157. package/src/surface/useTiledSurface.ts +14 -10
  158. package/src/test-setup.ts +26 -0
  159. package/src/theme/Interstellar.stories.tsx +1 -1
  160. package/src/theme/base.less +19 -2
  161. package/src/theme/interstellar.tokens.json +6 -2
  162. package/src/tools/types.ts +24 -0
  163. package/src/trial/Trial.config.test.tsx +108 -0
  164. package/src/trial/Trial.job.test.tsx +13 -15
  165. package/src/trial/Trial.less +115 -10
  166. package/src/trial/Trial.stories.tsx +1 -0
  167. package/src/trial/Trial.test.tsx +67 -96
  168. package/src/trial/Trial.tsx +80 -39
  169. package/src/trial/TrialChrome.tsx +99 -80
  170. package/src/trial/TrialDragContext.ts +10 -0
  171. package/src/trial/TrialTitleBar.tsx +41 -0
  172. package/src/trial/index.ts +1 -15
  173. package/src/trial/trialChrome.borders.test.ts +38 -0
  174. package/src/ui/layers/index.ts +5 -1
  175. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  176. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  177. package/dist/chunk-574LJAV4.js.map +0 -1
  178. package/dist/chunk-5R2ATYPJ.js +0 -201
  179. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  180. package/dist/chunk-73KA7WBO.js +0 -5992
  181. package/dist/chunk-73KA7WBO.js.map +0 -1
  182. package/dist/chunk-BOHF3PQO.js.map +0 -1
  183. package/dist/chunk-G5TJVQQT.js +0 -161
  184. package/dist/chunk-G5TJVQQT.js.map +0 -1
  185. package/dist/chunk-PMAU3SEE.js +0 -126
  186. package/dist/chunk-PMAU3SEE.js.map +0 -1
  187. package/dist/chunk-SFL7NFKN.js.map +0 -1
  188. package/dist/chunk-THBG7FQZ.js.map +0 -1
  189. package/dist/chunk-VUU5UXHE.js.map +0 -1
  190. package/src/instrument/capabilityDetector.test.ts +0 -64
  191. package/src/instrument/capabilityDetector.ts +0 -20
  192. package/src/trial/DefaultSidebar.tsx +0 -29
  193. package/src/trial/DefaultStatusBar.tsx +0 -19
  194. package/src/trial/DefaultToolbar.tsx +0 -92
  195. package/src/trial/slotTypes.ts +0 -54
  196. package/src/ui/format.test.ts +0 -33
  197. package/src/ui/format.ts +0 -30
  198. package/src/ui/layers/LayerStack.less +0 -143
  199. package/src/ui/layers/LayerStack.stories.tsx +0 -45
  200. package/src/ui/layers/LayerStack.test.tsx +0 -170
  201. package/src/ui/layers/LayerStack.tsx +0 -220
  202. package/src/ui/properties/CheckboxRow.stories.tsx +0 -27
  203. package/src/ui/properties/ColorRow.stories.tsx +0 -106
  204. package/src/ui/properties/CurveField.less +0 -48
  205. package/src/ui/properties/CurveField.stories.tsx +0 -20
  206. package/src/ui/properties/CurveField.test.tsx +0 -127
  207. package/src/ui/properties/CurveField.tsx +0 -184
  208. package/src/ui/properties/EffectCard.tsx +0 -324
  209. package/src/ui/properties/Gallery.stories.tsx +0 -178
  210. package/src/ui/properties/NumberRow.stories.tsx +0 -65
  211. package/src/ui/properties/PropertyGroup.less +0 -50
  212. package/src/ui/properties/PropertyGroup.stories.tsx +0 -34
  213. package/src/ui/properties/PropertyGroup.test.tsx +0 -33
  214. package/src/ui/properties/PropertyGroup.tsx +0 -45
  215. package/src/ui/properties/PropertyList.stories.tsx +0 -83
  216. package/src/ui/properties/PropertyPanel.less +0 -592
  217. package/src/ui/properties/PropertyPanel.stories.tsx +0 -190
  218. package/src/ui/properties/PropertyPanel.test.tsx +0 -193
  219. package/src/ui/properties/PropertyPanel.tsx +0 -469
  220. package/src/ui/properties/PropertyRow.stories.tsx +0 -53
  221. package/src/ui/properties/SelectRow.stories.tsx +0 -58
  222. package/src/ui/properties/SliderRow.stories.tsx +0 -74
  223. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +0 -396
  224. package/src/ui/properties/TextRow.stories.tsx +0 -62
  225. package/src/ui/properties/ToggleRow.stories.tsx +0 -57
  226. package/src/ui/properties/index.ts +0 -39
  227. package/src/ui/properties/storyLayouts.tsx +0 -36
package/src/lab/index.ts CHANGED
@@ -2,7 +2,9 @@ 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
- export type { WorkspaceProps } from './Workspace';
9
+ export type { TrialLayout, WorkspaceProps } from './Workspace';
8
10
  export { Workspace } 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>` (from `@weasel-js/ui`), 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 '../passthrough/weasel-ui';
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"
@@ -24,6 +24,7 @@ export {
24
24
  type BadgeVariant,
25
25
  type BoundsCtx,
26
26
  type BuiltInEdgeName,
27
+ type BuiltinPref,
27
28
  Button,
28
29
  type ButtonProps,
29
30
  type ButtonSize,
@@ -47,6 +48,8 @@ export {
47
48
  type DataGridProps,
48
49
  Dialog,
49
50
  type DialogProps,
51
+ DragHandleGlyph,
52
+ type DragHandleGlyphProps,
50
53
  detectPlatform,
51
54
  dlog,
52
55
  EDGE_PROFILES,
@@ -68,6 +71,7 @@ export {
68
71
  type InterpolationMode,
69
72
  inferKeycapKind,
70
73
  isDebugEnabled,
74
+ isPrefLeaf,
71
75
  KeyCap,
72
76
  type KeyCapProps,
73
77
  type KeyCapVariant,
@@ -79,6 +83,9 @@ export {
79
83
  keySpecFromKey,
80
84
  keySpecsFromMods,
81
85
  type LayerListItem,
86
+ LayerStack,
87
+ type LayerStackItem,
88
+ type LayerStackProps,
82
89
  type LogicalMod,
83
90
  type LogicalModSpec,
84
91
  MINUS_SIGN,
@@ -100,7 +107,27 @@ export {
100
107
  Powerline,
101
108
  type PowerlineProps,
102
109
  type PowerlineSegment,
110
+ type PrefBoolean,
111
+ type PrefBooleanControl,
112
+ type PrefColor,
113
+ type PrefCustom,
114
+ type PrefEnum,
115
+ type PrefEnumControl,
116
+ type PrefEnumEncoding,
117
+ type PrefGroup,
118
+ type PrefKind,
119
+ type PrefLeaf,
120
+ type PrefNumber,
121
+ type PrefNumberControl,
122
+ type PrefNumberUnit,
123
+ type PrefObject,
124
+ type PrefPaint,
125
+ type PrefRenderContext,
126
+ type PrefRenderer,
127
+ type PrefString,
128
+ type PrefStringControl,
103
129
  paintGradientTrack,
130
+ prefValueAtPath,
104
131
  Radio,
105
132
  RadioGroup,
106
133
  type RadioGroupProps,
@@ -151,4 +178,5 @@ export {
151
178
  type UseRovingTabIndexOptions,
152
179
  useReorderDragList,
153
180
  useRovingTabIndex,
181
+ visiblePrefSubtree,
154
182
  } from '@weasel-js/ui';
@@ -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
+ });