@weasel-js/labkit 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +8 -0
  2. package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
  3. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  4. package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-A4R6v4ld.d.ts} +36 -13
  5. package/dist/_dts/{index-B2X21G1c.d.ts → index-CsU9WhjM.d.ts} +90 -30
  6. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
  7. package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
  8. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
  9. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  10. package/dist/canvas/index.d.ts +16 -16
  11. package/dist/canvas/index.js +2 -2
  12. package/dist/chrome/index.d.ts +15 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-7VW3S44V.js → chunk-54ZWZ5FQ.js} +908 -301
  15. package/dist/chunk-54ZWZ5FQ.js.map +1 -0
  16. package/dist/{chunk-Y2HK44TG.js → chunk-D5KQ5OY6.js} +3 -3
  17. package/dist/{chunk-Y2HK44TG.js.map → chunk-D5KQ5OY6.js.map} +1 -1
  18. package/dist/{chunk-VHFQKAUL.js → chunk-FJG4PHTL.js} +83 -75
  19. package/dist/chunk-FJG4PHTL.js.map +1 -0
  20. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  21. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  22. package/dist/{chunk-L5WBGUVR.js → chunk-ISSVF5PT.js} +2753 -2654
  23. package/dist/chunk-ISSVF5PT.js.map +1 -0
  24. package/dist/chunk-NSOVI3AZ.js +170 -0
  25. package/dist/chunk-NSOVI3AZ.js.map +1 -0
  26. package/dist/{chunk-YQGQUBKA.js → chunk-TN7YSJVU.js} +62 -60
  27. package/dist/chunk-TN7YSJVU.js.map +1 -0
  28. package/dist/{chunk-4AMN4MMB.js → chunk-UDXOYZEC.js} +53 -39
  29. package/dist/chunk-UDXOYZEC.js.map +1 -0
  30. package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
  31. package/dist/chunk-ULDW42CR.js.map +1 -0
  32. package/dist/{chunk-QR5V3AFG.js → chunk-UTOEDPNU.js} +5 -5
  33. package/dist/{chunk-QR5V3AFG.js.map → chunk-UTOEDPNU.js.map} +1 -1
  34. package/dist/{chunk-UD7TEHZT.js → chunk-W2FJR5FF.js} +35 -22
  35. package/dist/chunk-W2FJR5FF.js.map +1 -0
  36. package/dist/{chunk-B5VWJBWM.js → chunk-WS6ZRV75.js} +3 -3
  37. package/dist/{chunk-B5VWJBWM.js.map → chunk-WS6ZRV75.js.map} +1 -1
  38. package/dist/{chunk-W3KSUXXR.js → chunk-XKENZTNE.js} +45 -14
  39. package/dist/chunk-XKENZTNE.js.map +1 -0
  40. package/dist/controls/index.d.ts +4 -3
  41. package/dist/controls/index.js +3 -3
  42. package/dist/dragdrop/index.d.ts +8 -8
  43. package/dist/dragdrop/index.js +2 -2
  44. package/dist/index.d.ts +75 -110
  45. package/dist/index.js +148 -115
  46. package/dist/index.js.map +1 -1
  47. package/dist/job/index.js +1 -1
  48. package/dist/layers/index.d.ts +26 -11
  49. package/dist/layers/index.js +3 -3
  50. package/dist/loupe/index.d.ts +7 -13
  51. package/dist/loupe/index.js +2 -2
  52. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  53. package/dist/passthrough/weasel-canvas.js +1 -1
  54. package/dist/passthrough/weasel-ui.d.ts +123 -3088
  55. package/dist/passthrough/weasel-ui.js +2 -2
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +3 -3
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +30 -3
  60. package/dist/surface/index.js +2 -2
  61. package/dist/ui/layers/index.d.ts +22 -9
  62. package/dist/ui/layers/index.js +2 -2
  63. package/dist/undo/index.d.ts +6 -5
  64. package/package.json +8 -8
  65. package/src/annotations/AnnotationTargets.tsx +6 -1
  66. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  67. package/src/annotations/types.ts +4 -1
  68. package/src/canvas/CanvasStack.tsx +8 -13
  69. package/src/canvas/useOrbit.test.ts +97 -1
  70. package/src/canvas/useOrbit.ts +41 -34
  71. package/src/canvas/usePanZoom.test.ts +106 -1
  72. package/src/canvas/usePanZoom.ts +54 -36
  73. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  74. package/src/chrome/builtins.test.ts +4 -0
  75. package/src/chrome/builtins.tsx +18 -0
  76. package/src/chrome/index.ts +1 -1
  77. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  78. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  79. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  80. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  81. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  82. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  83. package/src/chrome/regions/regions.test.tsx +45 -1
  84. package/src/chrome/types.ts +21 -3
  85. package/src/controls/ControlPanel.test.tsx +38 -0
  86. package/src/controls/ControlPanel.tsx +51 -6
  87. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  88. package/src/dragdrop/Palette.tsx +4 -3
  89. package/src/dragdrop/dragDrop.test.tsx +35 -12
  90. package/src/index.ts +2 -1
  91. package/src/instrument/types.ts +4 -5
  92. package/src/job/useJob.ts +1 -1
  93. package/src/lab/Lab.test.tsx +7 -0
  94. package/src/lab/Lab.tsx +2 -2
  95. package/src/lab/LabContext.ts +5 -1
  96. package/src/lab/LabShell.less +17 -2
  97. package/src/lab/Workspace.tsx +1 -1
  98. package/src/layers/AGENTS.md +29 -7
  99. package/src/layers/LayerList.less +16 -0
  100. package/src/layers/LayerList.stories.tsx +66 -0
  101. package/src/layers/LayerList.test.tsx +185 -2
  102. package/src/layers/LayerList.tsx +221 -74
  103. package/src/layers/index.ts +1 -1
  104. package/src/passthrough/weasel-ui.ts +5 -0
  105. package/src/primitives/FloatingPanel.test.tsx +87 -0
  106. package/src/primitives/FloatingPanel.tsx +59 -41
  107. package/src/state/store.test.ts +78 -0
  108. package/src/state/store.ts +27 -0
  109. package/src/state/types.ts +20 -0
  110. package/src/trial/Trial.less +4 -0
  111. package/src/trial/Trial.targets.test.tsx +96 -0
  112. package/src/trial/Trial.test.tsx +171 -14
  113. package/src/trial/Trial.tsx +4 -1
  114. package/src/trial/TrialChrome.tsx +22 -2
  115. package/src/trial/TrialTitleBar.tsx +7 -3
  116. package/src/trial/index.ts +1 -0
  117. package/src/trial/trialOps.test.ts +49 -1
  118. package/src/trial/trialOps.ts +28 -6
  119. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  120. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  121. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  122. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  123. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  124. package/dist/chunk-4AMN4MMB.js.map +0 -1
  125. package/dist/chunk-7VW3S44V.js.map +0 -1
  126. package/dist/chunk-L5WBGUVR.js.map +0 -1
  127. package/dist/chunk-LJSIUFYD.js.map +0 -1
  128. package/dist/chunk-UD7TEHZT.js.map +0 -1
  129. package/dist/chunk-VHFQKAUL.js.map +0 -1
  130. package/dist/chunk-W3KSUXXR.js.map +0 -1
  131. package/dist/chunk-XCBCZEBO.js +0 -94
  132. package/dist/chunk-XCBCZEBO.js.map +0 -1
  133. package/dist/chunk-YQGQUBKA.js.map +0 -1
@@ -1,7 +1,7 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
  import type { LayerDescriptor } from '../instrument/types';
4
- import { LayerList } from './LayerList';
4
+ import { LayerList, type LayerListProps, type LayerTreeNode } from './LayerList';
5
5
 
6
6
  describe('<LayerList>', () => {
7
7
  it('renders one row per layer', () => {
@@ -47,3 +47,186 @@ describe('<LayerList>', () => {
47
47
  expect(onToggle).toHaveBeenCalledWith('a', false);
48
48
  });
49
49
  });
50
+
51
+ const twoLayers: LayerDescriptor[] = [
52
+ { id: 'a', label: 'A' },
53
+ { id: 'b', label: 'B' },
54
+ ];
55
+
56
+ function dispatchOn(target: EventTarget, type: string, init: PointerEventInit) {
57
+ act(() => {
58
+ target.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
59
+ });
60
+ }
61
+
62
+ /** jsdom measures every row at zero height, so `startDrag` falls back to a
63
+ * pitch of 1: one pixel of travel is one row. */
64
+ function renderDraggable(onReorder = vi.fn()) {
65
+ const result = render(
66
+ <LayerList
67
+ layers={twoLayers}
68
+ visibility={{ a: true, b: true }}
69
+ onReorder={onReorder}
70
+ onToggle={vi.fn()}
71
+ />,
72
+ );
73
+ return { ...result, onReorder, handle: screen.getByLabelText('Reorder A') };
74
+ }
75
+
76
+ /** Index of the row the drag currently marks, or -1 when nothing is dragging. */
77
+ function draggingRow(container: HTMLElement): number {
78
+ const rows = Array.from(container.querySelectorAll('.lk-layer-list__row'));
79
+ return rows.findIndex((r) => r.classList.contains('lk-layer-list__row--dragging'));
80
+ }
81
+
82
+ describe('<LayerList> reordering', () => {
83
+ it('reorders from a release that lands off the handle', () => {
84
+ const { onReorder, handle } = renderDraggable();
85
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
86
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
87
+ dispatchOn(document, 'pointerup', { clientY: 1 });
88
+ expect(onReorder).toHaveBeenCalledTimes(1);
89
+ expect(onReorder.mock.calls[0][0].map((l: LayerDescriptor) => l.id)).toEqual(['b', 'a']);
90
+ });
91
+
92
+ // The other half of the rule — a detached origin does cancel — is owned by
93
+ // pointerSession.test.ts, which exercises it without React in the way.
94
+ it('keeps dragging when capture is lost and the handle is still in the document', () => {
95
+ const { onReorder, handle, container } = renderDraggable();
96
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
97
+ // Chrome releases capture a beat before it delivers pointerup, so ending
98
+ // here throws away a release that has already been dispatched.
99
+ dispatchOn(handle, 'lostpointercapture', {});
100
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
101
+ expect(draggingRow(container)).toBe(1);
102
+ dispatchOn(document, 'pointerup', { clientY: 1 });
103
+ expect(onReorder).toHaveBeenCalledTimes(1);
104
+ expect(onReorder.mock.calls[0][0].map((l: LayerDescriptor) => l.id)).toEqual(['b', 'a']);
105
+ });
106
+
107
+ it('reads a move with nothing held as the release that never arrived', () => {
108
+ const { onReorder, handle } = renderDraggable();
109
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
110
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
111
+ dispatchOn(document, 'pointermove', { buttons: 0, clientY: 40 });
112
+ expect(onReorder).toHaveBeenCalledTimes(1);
113
+ expect(onReorder.mock.calls[0][0].map((l: LayerDescriptor) => l.id)).toEqual(['b', 'a']);
114
+ });
115
+
116
+ it('drops its listeners when the list unmounts mid-drag', () => {
117
+ const { onReorder, handle, unmount } = renderDraggable();
118
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
119
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
120
+ unmount();
121
+ dispatchOn(document, 'pointerup', { clientY: 1 });
122
+ expect(onReorder).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('does not capture a second pointer into the same drag', () => {
126
+ const { onReorder, handle } = renderDraggable();
127
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
128
+ dispatchOn(document, 'pointermove', { pointerId: 2, buttons: 1, clientY: 40 });
129
+ dispatchOn(document, 'pointerup', { pointerId: 1, clientY: 0 });
130
+ expect(onReorder).not.toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ const tree: LayerTreeNode[] = [
135
+ {
136
+ id: 'a',
137
+ label: 'A',
138
+ children: [
139
+ { id: 'a1', label: 'A1' },
140
+ { id: 'a2', label: 'A2' },
141
+ ],
142
+ },
143
+ { id: 'b', label: 'B' },
144
+ ];
145
+
146
+ function renderTree(props: Partial<LayerListProps> = {}) {
147
+ return render(
148
+ <LayerList layers={tree} visibility={{}} onReorder={vi.fn()} onToggle={vi.fn()} {...props} />,
149
+ );
150
+ }
151
+
152
+ describe('<LayerList> nesting', () => {
153
+ it('renders a row for each descendant of an expanded node', () => {
154
+ const { container } = renderTree();
155
+ expect(container.querySelectorAll('.lk-layer-list__row')).toHaveLength(4);
156
+ expect(screen.getByText('A1')).toBeInTheDocument();
157
+ });
158
+
159
+ it('hides the children of a collapsed node', () => {
160
+ const { container } = renderTree();
161
+ fireEvent.click(screen.getByLabelText('A sublayers'));
162
+ expect(container.querySelectorAll('.lk-layer-list__row')).toHaveLength(2);
163
+ expect(screen.queryByText('A1')).toBeNull();
164
+ });
165
+
166
+ it('starts collapsed when the node says so', () => {
167
+ render(
168
+ <LayerList
169
+ layers={[{ ...tree[0], defaultCollapsed: true }, tree[1]]}
170
+ visibility={{}}
171
+ onReorder={vi.fn()}
172
+ onToggle={vi.fn()}
173
+ />,
174
+ );
175
+ expect(screen.queryByText('A1')).toBeNull();
176
+ });
177
+
178
+ it('takes collapse from collapsedIds and reports the next set', () => {
179
+ const onCollapsedChange = vi.fn();
180
+ renderTree({ collapsedIds: ['a'], onCollapsedChange });
181
+ expect(screen.queryByText('A1')).toBeNull();
182
+ fireEvent.click(screen.getByLabelText('A sublayers'));
183
+ expect(onCollapsedChange).toHaveBeenCalledWith([]);
184
+ // Controlled: the click alone does not open it.
185
+ expect(screen.queryByText('A1')).toBeNull();
186
+ });
187
+
188
+ it('ties the twisty to the subtree it reveals', () => {
189
+ const { container } = renderTree();
190
+ const controls = screen.getByLabelText('A sublayers').getAttribute('aria-controls');
191
+ expect(controls).toBeTruthy();
192
+ expect(container.querySelector(`[id="${controls}"]`)).toHaveClass('lk-layer-list__children');
193
+ });
194
+
195
+ it('spends no twisty column on a flat list', () => {
196
+ const { container } = render(
197
+ <LayerList layers={twoLayers} visibility={{}} onReorder={vi.fn()} onToggle={vi.fn()} />,
198
+ );
199
+ expect(container.querySelector('.lk-layer-list__twisty-gap')).toBeNull();
200
+ expect(screen.queryByLabelText(/sublayers$/)).toBeNull();
201
+ });
202
+
203
+ it('holds the twisty column open on childless siblings', () => {
204
+ const { container } = renderTree();
205
+ expect(container.querySelectorAll('.lk-layer-list__twisty-gap')).toHaveLength(3);
206
+ });
207
+
208
+ it('reorders within a subtree and leaves the rest of the tree alone', () => {
209
+ const onReorder = vi.fn();
210
+ renderTree({ onReorder });
211
+ const handle = screen.getByLabelText('Reorder A1');
212
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
213
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
214
+ dispatchOn(document, 'pointerup', { clientY: 1 });
215
+ expect(onReorder).toHaveBeenCalledTimes(1);
216
+ const next = onReorder.mock.calls[0][0] as LayerTreeNode[];
217
+ expect(next.map((l) => l.id)).toEqual(['a', 'b']);
218
+ expect(next[0].children?.map((l) => l.id)).toEqual(['a2', 'a1']);
219
+ });
220
+
221
+ it('reorders the top level without disturbing a subtree', () => {
222
+ const onReorder = vi.fn();
223
+ renderTree({ onReorder });
224
+ const handle = screen.getByLabelText('Reorder A');
225
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
226
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
227
+ dispatchOn(document, 'pointerup', { clientY: 1 });
228
+ const next = onReorder.mock.calls[0][0] as LayerTreeNode[];
229
+ expect(next.map((l) => l.id)).toEqual(['b', 'a']);
230
+ expect(next[1].children?.map((l) => l.id)).toEqual(['a1', 'a2']);
231
+ });
232
+ });
@@ -1,31 +1,131 @@
1
- import { type PointerEvent, useRef, useState } from 'react';
1
+ import { openPointerSession, type PointerSession } from '@weasel-js/core';
2
+ import { Disclosure } from '@weasel-js/ui';
3
+ import {
4
+ Fragment,
5
+ type ReactNode,
6
+ type PointerEvent as ReactPointerEvent,
7
+ useEffect,
8
+ useId,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
2
12
  import type { LayerDescriptor } from '../instrument/types';
3
13
  import { DragHandleGlyph } from '../passthrough/weasel-ui';
4
14
 
15
+ /** A layer that may itself contain layers. A plain `LayerDescriptor` is one
16
+ * of these with no children, so a flat list needs no change. */
17
+ export interface LayerTreeNode extends LayerDescriptor {
18
+ children?: LayerTreeNode[];
19
+ /** Render this node's children hidden on first paint. Ignored once
20
+ * `collapsedIds` is supplied. */
21
+ defaultCollapsed?: boolean;
22
+ }
23
+
5
24
  /** Props for `<LayerList>`. */
6
25
  export interface LayerListProps {
7
- layers: LayerDescriptor[];
26
+ layers: LayerTreeNode[];
8
27
  visibility: Record<string, boolean>;
9
- onReorder: (newOrder: LayerDescriptor[]) => void;
28
+ /** The whole tree, with the dragged node moved. Reordering is scoped to a
29
+ * node's siblings: a drag never changes which parent a layer sits under. */
30
+ onReorder: (newOrder: LayerTreeNode[]) => void;
10
31
  onToggle: (id: string, visible: boolean) => void;
32
+ /** Ids whose children are hidden. Supplying it makes collapse controlled;
33
+ * without it the list keeps its own, seeded from `defaultCollapsed`. */
34
+ collapsedIds?: readonly string[];
35
+ onCollapsedChange?: (ids: string[]) => void;
11
36
  className?: string;
12
37
  }
13
38
 
14
39
  interface DragState {
15
- pointerId: number;
40
+ /** Id of the dragged row's parent, or null at the top level. */
41
+ parentId: string | null;
16
42
  fromIndex: number;
43
+ toIndex: number;
17
44
  startY: number;
18
45
  /** Row height plus row gap, measured when the drag starts. */
19
46
  pitch: number;
20
47
  }
21
48
 
49
+ /** Which row the drag currently marks. */
50
+ interface DragMark {
51
+ parentId: string | null;
52
+ index: number;
53
+ }
54
+
55
+ function findNode(nodes: LayerTreeNode[], id: string): LayerTreeNode | null {
56
+ for (const node of nodes) {
57
+ if (node.id === id) return node;
58
+ const hit = node.children ? findNode(node.children, id) : null;
59
+ if (hit) return hit;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function siblingsOf(layers: LayerTreeNode[], parentId: string | null): LayerTreeNode[] {
65
+ if (parentId === null) return layers;
66
+ return findNode(layers, parentId)?.children ?? [];
67
+ }
68
+
69
+ function withChildren(
70
+ nodes: LayerTreeNode[],
71
+ parentId: string | null,
72
+ next: LayerTreeNode[],
73
+ ): LayerTreeNode[] {
74
+ if (parentId === null) return next;
75
+ return nodes.map((node) => {
76
+ if (node.id === parentId) return { ...node, children: next };
77
+ if (node.children) return { ...node, children: withChildren(node.children, parentId, next) };
78
+ return node;
79
+ });
80
+ }
81
+
82
+ function collectCollapsed(nodes: LayerTreeNode[], into: Set<string>): Set<string> {
83
+ for (const node of nodes) {
84
+ if (node.defaultCollapsed) into.add(node.id);
85
+ if (node.children) collectCollapsed(node.children, into);
86
+ }
87
+ return into;
88
+ }
89
+
90
+ function anyNested(nodes: LayerTreeNode[]): boolean {
91
+ return nodes.some((n) => (n.children?.length ?? 0) > 0);
92
+ }
93
+
22
94
  /** A reorderable list of layers with per-layer visibility toggles. Layers
23
- * marked `alwaysOn` are pinned and cannot be reordered or hidden. */
24
- export function LayerList({ layers, visibility, onReorder, onToggle, className }: LayerListProps) {
25
- const reorderable = layers.filter((l) => !l.alwaysOn);
26
- const pinned = layers.filter((l) => l.alwaysOn);
27
- const [dragIndex, setDragIndex] = useState<number | null>(null);
95
+ * marked `alwaysOn` are pinned and cannot be reordered or hidden. Layers with
96
+ * `children` render as an expandable subtree. */
97
+ export function LayerList({
98
+ layers,
99
+ visibility,
100
+ onReorder,
101
+ onToggle,
102
+ collapsedIds,
103
+ onCollapsedChange,
104
+ className,
105
+ }: LayerListProps) {
106
+ const [dragMark, setDragMark] = useState<DragMark | null>(null);
107
+ const [ownCollapsed, setOwnCollapsed] = useState<Set<string>>(() =>
108
+ collectCollapsed(layers, new Set()),
109
+ );
28
110
  const dragRef = useRef<DragState | null>(null);
111
+ const sessionRef = useRef<PointerSession | null>(null);
112
+ const uid = useId();
113
+ // The session's callbacks outlive the render that opened them, so the commit
114
+ // reads its inputs here rather than from that render's closure.
115
+ const commitRef = useRef({ layers, onReorder });
116
+ commitRef.current = { layers, onReorder };
117
+
118
+ useEffect(() => () => sessionRef.current?.cancel(), []);
119
+
120
+ const collapsed = collapsedIds ? new Set(collapsedIds) : ownCollapsed;
121
+
122
+ const toggleCollapsed = (id: string) => {
123
+ const next = new Set(collapsed);
124
+ if (next.has(id)) next.delete(id);
125
+ else next.add(id);
126
+ if (!collapsedIds) setOwnCollapsed(next);
127
+ onCollapsedChange?.([...next]);
128
+ };
29
129
 
30
130
  if (layers.length === 0) {
31
131
  return (
@@ -35,87 +135,134 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
35
135
  );
36
136
  }
37
137
 
38
- const startDrag = (e: PointerEvent<HTMLElement>, index: number) => {
39
- e.currentTarget.setPointerCapture(e.pointerId);
138
+ const clearDrag = () => {
139
+ dragRef.current = null;
140
+ sessionRef.current = null;
141
+ setDragMark(null);
142
+ };
143
+
144
+ const startDrag = (e: ReactPointerEvent<HTMLElement>, parentId: string | null, index: number) => {
40
145
  // Measured at grab time rather than hardcoded: a restyle that changes row
41
146
  // height or gap would otherwise silently skew every drag distance.
42
147
  const row = e.currentTarget.closest('.lk-layer-list__row');
43
148
  const list = row?.parentElement;
44
149
  const gap = list ? Number.parseFloat(getComputedStyle(list).rowGap) || 0 : 0;
45
150
  const pitch = row ? row.getBoundingClientRect().height + gap : 0;
46
- dragRef.current = {
47
- pointerId: e.pointerId,
151
+ const drag: DragState = {
152
+ parentId,
48
153
  fromIndex: index,
154
+ toIndex: index,
49
155
  startY: e.clientY,
50
156
  pitch: pitch > 0 ? pitch : 1,
51
157
  };
52
- setDragIndex(index);
158
+ dragRef.current = drag;
159
+ setDragMark({ parentId, index });
160
+ sessionRef.current = openPointerSession(e.currentTarget, e, {
161
+ onMove: (ev) => {
162
+ const delta = Math.round((ev.clientY - drag.startY) / drag.pitch);
163
+ const siblings = siblingsOf(commitRef.current.layers, parentId);
164
+ const last = siblings.filter((l) => !l.alwaysOn).length - 1;
165
+ drag.toIndex = Math.min(last, Math.max(0, drag.fromIndex + delta));
166
+ setDragMark({ parentId, index: drag.toIndex });
167
+ },
168
+ onEnd: () => {
169
+ const { layers: tree, onReorder: commit } = commitRef.current;
170
+ const { fromIndex, toIndex } = drag;
171
+ clearDrag();
172
+ if (toIndex === fromIndex) return;
173
+ const siblings = siblingsOf(tree, parentId);
174
+ const rows = siblings.filter((l) => !l.alwaysOn);
175
+ const locked = siblings.filter((l) => l.alwaysOn);
176
+ const next = [...rows];
177
+ const [moved] = next.splice(fromIndex, 1);
178
+ if (moved) next.splice(toIndex, 0, moved);
179
+ commit(withChildren(tree, parentId, [...next, ...locked]));
180
+ },
181
+ // An interrupted drag never named a destination, so it reorders nothing.
182
+ onCancel: clearDrag,
183
+ });
53
184
  };
54
185
 
55
- const moveDrag = (e: PointerEvent<HTMLElement>) => {
56
- const drag = dragRef.current;
57
- if (!drag || drag.pointerId !== e.pointerId) return;
58
- const delta = Math.round((e.clientY - drag.startY) / drag.pitch);
59
- const target = Math.min(reorderable.length - 1, Math.max(0, drag.fromIndex + delta));
60
- setDragIndex(target);
61
- };
186
+ // A twisty column on every row keeps labels aligned across depths, but a
187
+ // flat list would only be paying 20px for it.
188
+ const nested = anyNested(layers);
62
189
 
63
- const endDrag = (e: PointerEvent<HTMLElement>) => {
64
- const drag = dragRef.current;
65
- if (!drag || drag.pointerId !== e.pointerId) return;
66
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
67
- e.currentTarget.releasePointerCapture(e.pointerId);
68
- }
69
- if (dragIndex !== null && dragIndex !== drag.fromIndex) {
70
- const next = [...reorderable];
71
- const [moved] = next.splice(drag.fromIndex, 1);
72
- if (moved) next.splice(dragIndex, 0, moved);
73
- onReorder([...next, ...pinned]);
74
- }
75
- dragRef.current = null;
76
- setDragIndex(null);
77
- };
190
+ function renderRow(
191
+ layer: LayerTreeNode,
192
+ parentId: string | null,
193
+ index: number,
194
+ path: string,
195
+ pinned: boolean,
196
+ ): ReactNode {
197
+ const kids = layer.children ?? [];
198
+ const open = kids.length > 0 && !collapsed.has(layer.id);
199
+ const bodyId = `${uid}${path}`;
200
+ const dragging = !pinned && dragMark?.parentId === parentId && dragMark.index === index;
201
+ const cls = ['lk-layer-list__row'];
202
+ if (dragging) cls.push('lk-layer-list__row--dragging');
203
+ if (pinned) cls.push('lk-layer-list__row--pinned');
204
+ return (
205
+ <Fragment key={layer.id}>
206
+ <div className={cls.join(' ')}>
207
+ {nested &&
208
+ (kids.length > 0 ? (
209
+ <Disclosure
210
+ open={open}
211
+ onToggle={() => toggleCollapsed(layer.id)}
212
+ label={`${layer.label} sublayers`}
213
+ controls={bodyId}
214
+ />
215
+ ) : (
216
+ <span className="lk-layer-list__twisty-gap" />
217
+ ))}
218
+ {pinned ? (
219
+ <span className="lk-layer-list__lock" role="img" aria-label="Always on">
220
+ 🔒
221
+ </span>
222
+ ) : (
223
+ <>
224
+ <button
225
+ type="button"
226
+ className="lk-layer-list__handle"
227
+ aria-label={`Reorder ${layer.label}`}
228
+ onPointerDown={(e) => startDrag(e, parentId, index)}
229
+ >
230
+ <DragHandleGlyph size={13} />
231
+ </button>
232
+ <input
233
+ className="lk-layer-list__check"
234
+ type="checkbox"
235
+ checked={visibility[layer.id] !== false}
236
+ onChange={(e) => onToggle(layer.id, e.target.checked)}
237
+ aria-label={`Toggle ${layer.label}`}
238
+ />
239
+ </>
240
+ )}
241
+ <span className="lk-layer-list__label">{layer.label}</span>
242
+ </div>
243
+ {open && (
244
+ <div id={bodyId} className="lk-layer-list__children">
245
+ {renderLevel(kids, layer.id, path)}
246
+ </div>
247
+ )}
248
+ </Fragment>
249
+ );
250
+ }
251
+
252
+ function renderLevel(nodes: LayerTreeNode[], parentId: string | null, path: string): ReactNode {
253
+ const reorderable = nodes.filter((l) => !l.alwaysOn);
254
+ const pinned = nodes.filter((l) => l.alwaysOn);
255
+ return (
256
+ <>
257
+ {reorderable.map((layer, i) => renderRow(layer, parentId, i, `${path}-${i}`, false))}
258
+ {pinned.map((layer, i) => renderRow(layer, parentId, i, `${path}-p${i}`, true))}
259
+ </>
260
+ );
261
+ }
78
262
 
79
263
  return (
80
264
  <div className={className ? `lk-layer-list ${className}` : 'lk-layer-list'}>
81
- {reorderable.map((layer, i) => {
82
- const isDragging = dragIndex === i;
83
- return (
84
- <div
85
- key={layer.id}
86
- className={
87
- isDragging ? 'lk-layer-list__row lk-layer-list__row--dragging' : 'lk-layer-list__row'
88
- }
89
- >
90
- <button
91
- type="button"
92
- className="lk-layer-list__handle"
93
- aria-label={`Reorder ${layer.label}`}
94
- onPointerDown={(e) => startDrag(e, i)}
95
- onPointerMove={moveDrag}
96
- onPointerUp={endDrag}
97
- >
98
- <DragHandleGlyph size={13} />
99
- </button>
100
- <input
101
- className="lk-layer-list__check"
102
- type="checkbox"
103
- checked={visibility[layer.id] !== false}
104
- onChange={(e) => onToggle(layer.id, e.target.checked)}
105
- aria-label={`Toggle ${layer.label}`}
106
- />
107
- <span className="lk-layer-list__label">{layer.label}</span>
108
- </div>
109
- );
110
- })}
111
- {pinned.map((layer) => (
112
- <div key={layer.id} className="lk-layer-list__row lk-layer-list__row--pinned">
113
- <span className="lk-layer-list__lock" role="img" aria-label="Always on">
114
- 🔒
115
- </span>
116
- <span className="lk-layer-list__label">{layer.label}</span>
117
- </div>
118
- ))}
265
+ {renderLevel(layers, null, '')}
119
266
  </div>
120
267
  );
121
268
  }
@@ -1,3 +1,3 @@
1
1
  export type { LayerCapability, LayerDescriptor } from '../instrument/types';
2
- export type { LayerListProps } from './LayerList';
2
+ export type { LayerListProps, LayerTreeNode } from './LayerList';
3
3
  export { LayerList } from './LayerList';
@@ -51,6 +51,11 @@ export {
51
51
  type DataGridProps,
52
52
  Dialog,
53
53
  type DialogProps,
54
+ Disclosure,
55
+ type DisclosureDirection,
56
+ type DisclosureProps,
57
+ DisclosureRow,
58
+ type DisclosureRowProps,
54
59
  DragHandleGlyph,
55
60
  type DragHandleGlyphProps,
56
61
  detectPlatform,
@@ -313,3 +313,90 @@ describe('a clickable row nested in a list, the shape a floating panel usually h
313
313
  expect(panel.dataset.dragging).toBe('true');
314
314
  });
315
315
  });
316
+
317
+ describe('FloatingPanel drag lifecycle', () => {
318
+ const realCapture = Element.prototype.setPointerCapture;
319
+ afterEach(() => {
320
+ Element.prototype.setPointerCapture = realCapture;
321
+ });
322
+
323
+ it('keeps dragging from moves the panel never sees, and ends on a release off it', () => {
324
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
325
+ fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
326
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
327
+ expect(panel.dataset.dragging).toBe('true');
328
+ expect(panel.style.left).toBe('100px');
329
+ fireEvent.pointerUp(document.body, { pointerId: 1, clientX: 200, clientY: 180 });
330
+ expect(panel.dataset.dragging).toBeUndefined();
331
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
332
+ expect(panel.style.left).toBe('100px');
333
+ });
334
+
335
+ // The other half of the rule — a detached origin does cancel — is owned by
336
+ // pointerSession.test.ts, which exercises it without React in the way.
337
+ it('keeps dragging when capture is lost and the panel is still in the document', () => {
338
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
339
+ fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
340
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
341
+ expect(panel.style.left).toBe('100px');
342
+ // Chrome releases capture a beat before it delivers pointerup, so ending
343
+ // here throws away a release that has already been dispatched.
344
+ fireEvent(panel, new PointerEvent('lostpointercapture', { pointerId: 1, bubbles: true }));
345
+ expect(panel.dataset.dragging).toBe('true');
346
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
347
+ expect(panel.style.left).toBe('200px');
348
+ });
349
+
350
+ it('reads a move with nothing held as the release that never arrived', () => {
351
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
352
+ fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
353
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
354
+ expect(panel.dataset.dragging).toBe('true');
355
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 0, clientX: 300, clientY: 180 });
356
+ expect(panel.dataset.dragging).toBeUndefined();
357
+ expect(panel.style.left).toBe('100px');
358
+ });
359
+
360
+ it('captures the pointer only once the press crosses the drag threshold', () => {
361
+ // A proxy for the real symptom: jsdom's setPointerCapture never retargets,
362
+ // so the child click a capture-on-press would swallow cannot be observed.
363
+ const captured: number[] = [];
364
+ Element.prototype.setPointerCapture = (id: number) => {
365
+ captured.push(id);
366
+ };
367
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
368
+ fireEvent.pointerDown(panel, { pointerId: 7, buttons: 1, clientX: 100, clientY: 100 });
369
+ expect(captured).toEqual([]);
370
+ fireEvent.pointerMove(document.body, { pointerId: 7, buttons: 1, clientX: 140, clientY: 100 });
371
+ expect(captured).toEqual([7]);
372
+ });
373
+
374
+ it('drops its document listeners when the panel unmounts mid-drag', () => {
375
+ const added: string[] = [];
376
+ const removed: string[] = [];
377
+ const realAdd = document.addEventListener.bind(document);
378
+ const realRemove = document.removeEventListener.bind(document);
379
+ const addSpy = vi
380
+ .spyOn(document, 'addEventListener')
381
+ .mockImplementation((type, listener, opts) => {
382
+ added.push(String(type));
383
+ realAdd(type as never, listener as never, opts as never);
384
+ });
385
+ const removeSpy = vi
386
+ .spyOn(document, 'removeEventListener')
387
+ .mockImplementation((type, listener, opts) => {
388
+ removed.push(String(type));
389
+ realRemove(type as never, listener as never, opts as never);
390
+ });
391
+ try {
392
+ const { panel, unmount } = renderPanel(<FloatingPanel>x</FloatingPanel>);
393
+ fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
394
+ expect(added).toContain('pointermove');
395
+ unmount();
396
+ expect(removed).toContain('pointermove');
397
+ } finally {
398
+ addSpy.mockRestore();
399
+ removeSpy.mockRestore();
400
+ }
401
+ });
402
+ });