@weasel-js/labkit 1.4.1 → 1.4.3

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 (145) hide show
  1. package/README.md +28 -2
  2. package/dist/_dts/{CanvasStackContext-BIRUVVGH.d.ts → CanvasStackContext-Dde8_1my.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
  4. package/dist/_dts/{frac-X7mWgd7y.d.ts → frac-B5mB8LsJ.d.ts} +33 -13
  5. package/dist/_dts/{index-FEa1MudK.d.ts → index-DelFfeV0.d.ts} +99 -31
  6. package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
  7. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BP2OCcpg.d.ts} +41 -4
  8. package/dist/_dts/types-x92Kfeme.d.ts +62 -0
  9. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-D6Vb3T-g.d.ts} +11 -7
  10. package/dist/canvas/index.d.ts +9 -8
  11. package/dist/canvas/index.js +1 -2
  12. package/dist/chrome/index.d.ts +16 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-MSR7EVS6.js → chunk-2RQPGOQF.js} +6 -5
  15. package/dist/chunk-2RQPGOQF.js.map +1 -0
  16. package/dist/{chunk-LJSIUFYD.js → chunk-5TROQVQ2.js} +46 -21
  17. package/dist/chunk-5TROQVQ2.js.map +1 -0
  18. package/dist/{chunk-53AZ2O2N.js → chunk-ASWKLRKJ.js} +92 -45
  19. package/dist/chunk-ASWKLRKJ.js.map +1 -0
  20. package/dist/{chunk-BMQWLKOL.js → chunk-C42T6DJT.js} +94 -83
  21. package/dist/chunk-C42T6DJT.js.map +1 -0
  22. package/dist/chunk-E44SU6XS.js +50 -0
  23. package/dist/chunk-E44SU6XS.js.map +1 -0
  24. package/dist/{chunk-OFCBEZMU.js → chunk-FQMJUVHJ.js} +3 -3
  25. package/dist/{chunk-OFCBEZMU.js.map → chunk-FQMJUVHJ.js.map} +1 -1
  26. package/dist/{chunk-PP7ZWAMV.js → chunk-I6JCVE24.js} +3 -3
  27. package/dist/chunk-I6JCVE24.js.map +1 -0
  28. package/dist/chunk-ISCOFAUO.js +170 -0
  29. package/dist/chunk-ISCOFAUO.js.map +1 -0
  30. package/dist/chunk-MHAGC6VD.js +7320 -0
  31. package/dist/chunk-MHAGC6VD.js.map +1 -0
  32. package/dist/{chunk-7TRFMIWX.js → chunk-QAGAHJKZ.js} +4 -4
  33. package/dist/{chunk-7TRFMIWX.js.map → chunk-QAGAHJKZ.js.map} +1 -1
  34. package/dist/{chunk-D7QIPGEB.js → chunk-RS3HRQWU.js} +62 -60
  35. package/dist/chunk-RS3HRQWU.js.map +1 -0
  36. package/dist/{chunk-RLOXQZW3.js → chunk-TJ7QY3OC.js} +3 -3
  37. package/dist/{chunk-RLOXQZW3.js.map → chunk-TJ7QY3OC.js.map} +1 -1
  38. package/dist/controls/index.d.ts +5 -4
  39. package/dist/controls/index.js +3 -3
  40. package/dist/dragdrop/index.d.ts +9 -8
  41. package/dist/dragdrop/index.js +1 -2
  42. package/dist/index.d.ts +152 -57
  43. package/dist/index.js +315 -200
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +27 -11
  46. package/dist/layers/index.js +2 -3
  47. package/dist/loupe/index.d.ts +8 -7
  48. package/dist/loupe/index.js +1 -2
  49. package/dist/passthrough/weasel-canvas.d.ts +1 -3
  50. package/dist/passthrough/weasel-canvas.js +1 -1
  51. package/dist/passthrough/weasel-canvas.js.map +1 -1
  52. package/dist/passthrough/weasel-ui.d.ts +171 -22
  53. package/dist/passthrough/weasel-ui.js +1 -2
  54. package/dist/primitives/index.js +3 -4
  55. package/dist/state/index.d.ts +6 -3
  56. package/dist/state/index.js +3 -2
  57. package/dist/state/index.js.map +1 -1
  58. package/dist/styles.css +21 -4
  59. package/dist/surface/index.js +1 -2
  60. package/dist/ui/layers/index.d.ts +22 -9
  61. package/dist/ui/layers/index.js +1 -2
  62. package/dist/undo/index.d.ts +6 -6
  63. package/package.json +7 -7
  64. package/src/annotations/AnnotationTargets.tsx +6 -1
  65. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  66. package/src/annotations/ExportMenu.test.tsx +16 -0
  67. package/src/annotations/ExportMenu.tsx +0 -5
  68. package/src/annotations/types.ts +4 -1
  69. package/src/canvas/useOrbit.test.ts +7 -3
  70. package/src/canvas/usePanZoom.test.ts +7 -3
  71. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  72. package/src/chrome/builtins.test.ts +4 -0
  73. package/src/chrome/builtins.tsx +18 -0
  74. package/src/chrome/index.ts +1 -1
  75. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  76. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  77. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  78. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  79. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  80. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  81. package/src/chrome/regions/regions.test.tsx +45 -1
  82. package/src/chrome/types.ts +21 -3
  83. package/src/config/builder.test.ts +56 -2
  84. package/src/config/builder.ts +73 -13
  85. package/src/config/index.ts +16 -1
  86. package/src/config/path.test.ts +109 -0
  87. package/src/config/path.ts +76 -0
  88. package/src/config/resolve.test.ts +93 -2
  89. package/src/config/resolve.ts +72 -29
  90. package/src/config/types.ts +99 -9
  91. package/src/config/visible.ts +6 -4
  92. package/src/controls/ControlPanel.stories.tsx +38 -4
  93. package/src/controls/ControlPanel.test.tsx +152 -0
  94. package/src/controls/ControlPanel.tsx +134 -40
  95. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  96. package/src/dragdrop/Palette.tsx +4 -3
  97. package/src/dragdrop/dragDrop.test.tsx +35 -12
  98. package/src/index.ts +18 -2
  99. package/src/instrument/serializers.ts +36 -0
  100. package/src/instrument/types.ts +8 -7
  101. package/src/lab/Lab.test.tsx +36 -1
  102. package/src/lab/Lab.tsx +12 -3
  103. package/src/lab/LabContext.ts +5 -1
  104. package/src/lab/LabShell.less +17 -2
  105. package/src/lab/LabShell.test.tsx +8 -0
  106. package/src/lab/LabShell.tsx +3 -1
  107. package/src/layers/AGENTS.md +29 -7
  108. package/src/layers/LayerList.less +16 -0
  109. package/src/layers/LayerList.stories.tsx +66 -0
  110. package/src/layers/LayerList.test.tsx +117 -5
  111. package/src/layers/LayerList.tsx +192 -53
  112. package/src/layers/index.ts +1 -1
  113. package/src/passthrough/weasel-ui.ts +5 -0
  114. package/src/primitives/FloatingPanel.test.tsx +8 -3
  115. package/src/primitives/ZoomControl.less +6 -2
  116. package/src/primitives/ZoomControl.tsx +1 -0
  117. package/src/state/helpers.ts +1 -1
  118. package/src/state/store.test.ts +233 -1
  119. package/src/state/store.ts +57 -24
  120. package/src/state/types.ts +44 -3
  121. package/src/state/useTrialState.ts +1 -1
  122. package/src/trial/Trial.config.test.tsx +44 -0
  123. package/src/trial/Trial.less +4 -0
  124. package/src/trial/Trial.targets.test.tsx +96 -0
  125. package/src/trial/Trial.test.tsx +171 -14
  126. package/src/trial/Trial.tsx +7 -4
  127. package/src/trial/TrialChrome.tsx +32 -7
  128. package/src/trial/TrialTitleBar.tsx +7 -3
  129. package/src/trial/index.ts +1 -0
  130. package/src/trial/trialOps.test.ts +49 -1
  131. package/src/trial/trialOps.ts +24 -6
  132. package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
  133. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
  134. package/dist/chunk-53AZ2O2N.js.map +0 -1
  135. package/dist/chunk-BMQWLKOL.js.map +0 -1
  136. package/dist/chunk-D7QIPGEB.js.map +0 -1
  137. package/dist/chunk-DG2IFMLA.js +0 -29970
  138. package/dist/chunk-DG2IFMLA.js.map +0 -1
  139. package/dist/chunk-J6SBFZYT.js +0 -7121
  140. package/dist/chunk-J6SBFZYT.js.map +0 -1
  141. package/dist/chunk-LJSIUFYD.js.map +0 -1
  142. package/dist/chunk-MIT3ISW4.js +0 -100
  143. package/dist/chunk-MIT3ISW4.js.map +0 -1
  144. package/dist/chunk-MSR7EVS6.js.map +0 -1
  145. package/dist/chunk-PP7ZWAMV.js.map +0 -1
@@ -6,22 +6,26 @@ The `src/layers/` directory implements the `<LayerList>` UI used by the `layers`
6
6
 
7
7
  | File | Role |
8
8
  |---|---|
9
- | `LayerList.tsx` | Layer toggle/reorder list (sidebar widget) |
10
- | `LayerList.less` | Row styling, drag handle, pinned-row variant |
9
+ | `LayerList.tsx` | Layer toggle/reorder list, flat or nested (sidebar widget) |
10
+ | `LayerList.less` | Row styling, drag handle, pinned-row variant, subtree indent |
11
+ | `LayerList.stories.tsx` | Flat and nested renderings |
11
12
 
12
13
  ## Props
13
14
 
14
15
  ```ts
15
16
  interface LayerListProps {
16
- layers: LayerDescriptor[]; // { id, label, alwaysOn? }
17
+ layers: LayerTreeNode[]; // LayerDescriptor + { children?, defaultCollapsed? }
17
18
  visibility: Record<string, boolean>; // missing key → visible
18
- onReorder: (newOrder: LayerDescriptor[]) => void;
19
+ onReorder: (newOrder: LayerTreeNode[]) => void;
19
20
  onToggle: (id: string, visible: boolean) => void;
21
+ collapsedIds?: readonly string[]; // supplying it makes collapse controlled
22
+ onCollapsedChange?: (ids: string[]) => void;
20
23
  className?: string;
21
24
  }
22
25
  ```
23
26
 
24
- `LayerDescriptor` lives in `src/instrument/types.ts`.
27
+ `LayerDescriptor` lives in `src/instrument/types.ts`; `LayerTreeNode` extends it
28
+ in `LayerList.tsx`, so a plain `LayerDescriptor[]` is still a valid `layers`.
25
29
 
26
30
  ## How it integrates
27
31
 
@@ -44,6 +48,24 @@ Setting `alwaysOn: true` on a `LayerDescriptor` does two things:
44
48
 
45
49
  This is intended for legend/HUD layers that should never be toggled off. Visibility for pinned rows is **not** read from the `visibility` prop; they always render.
46
50
 
51
+ ## Nesting
52
+
53
+ A node with `children` renders an expandable subtree. The expand control is
54
+ `<Disclosure>` from `@weasel-js/ui` — the same twisty every other collapsible
55
+ surface uses.
56
+
57
+ Reordering is scoped to siblings: a drag moves a row within its own parent's
58
+ child list and never reparents it. `onReorder` still receives the whole tree,
59
+ with only that sibling group's order changed.
60
+
61
+ The twisty column appears only when some node in the tree has children — a flat
62
+ list renders exactly the rows it always did. Within a tree, childless rows get
63
+ `.lk-layer-list__twisty-gap` so labels stay aligned down a level.
64
+
65
+ Collapse is uncontrolled by default, seeded once from each node's
66
+ `defaultCollapsed`. Pass `collapsedIds` to own it; `onCollapsedChange` fires
67
+ either way.
68
+
47
69
  ## Drag handle customization
48
70
 
49
71
  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.
@@ -59,7 +81,7 @@ If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No laye
59
81
  Fork this component if you need:
60
82
  - Multi-select reorder (current implementation is single-row)
61
83
  - Right-click context menu
62
- - Layer groups or nesting
84
+ - Reparenting by drag (nesting renders, but a drag stays within one parent)
63
85
  - Solo/mute UI common in DAW-style apps
64
86
 
65
- The implementation is small (~100 lines) and self-contained — only depends on `LayerDescriptor` from instrument types.
87
+ It is self-contained: `LayerDescriptor` from instrument types and `Disclosure`/`DragHandleGlyph` from `@weasel-js/ui`.
@@ -75,3 +75,19 @@
75
75
  .lk-layer-list input[type='checkbox'] {
76
76
  accent-color: var(--wzl-accent);
77
77
  }
78
+
79
+ .lk-layer-list__children {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 1px;
83
+ // Indents by the twisty column, so a child's own twisty starts where its
84
+ // parent's label does.
85
+ padding-left: var(--wzl-space-md);
86
+ }
87
+
88
+ // Holds the twisty column open on rows that have nothing to expand, so labels
89
+ // line up down the level.
90
+ .lk-layer-list__twisty-gap {
91
+ flex: 0 0 auto;
92
+ width: var(--wzl-disclosure-target, 20px);
93
+ }
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { LayerList, type LayerTreeNode } from './LayerList';
4
+
5
+ const meta: Meta<typeof LayerList> = {
6
+ title: 'labkit/layers/LayerList',
7
+ component: LayerList,
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof LayerList>;
11
+
12
+ const FLAT: LayerTreeNode[] = [
13
+ { id: 'plants', label: 'Plants' },
14
+ { id: 'grid', label: 'Grid' },
15
+ { id: 'legend', label: 'Legend', alwaysOn: true },
16
+ ];
17
+
18
+ const NESTED: LayerTreeNode[] = [
19
+ {
20
+ id: 'ingest',
21
+ label: 'Ingest',
22
+ children: [
23
+ { id: 'ingest.fetch', label: 'Fetch' },
24
+ { id: 'ingest.parse', label: 'Parse' },
25
+ ],
26
+ },
27
+ {
28
+ id: 'solve',
29
+ label: 'Solve',
30
+ defaultCollapsed: true,
31
+ children: [
32
+ { id: 'solve.seed', label: 'Seed' },
33
+ {
34
+ id: 'solve.relax',
35
+ label: 'Relax',
36
+ children: [
37
+ { id: 'solve.relax.sweep', label: 'Sweep' },
38
+ { id: 'solve.relax.check', label: 'Check' },
39
+ ],
40
+ },
41
+ ],
42
+ },
43
+ { id: 'report', label: 'Report', alwaysOn: true },
44
+ ];
45
+
46
+ function Demo({ seed }: { seed: LayerTreeNode[] }) {
47
+ const [layers, setLayers] = useState(seed);
48
+ const [visibility, setVisibility] = useState<Record<string, boolean>>({});
49
+ return (
50
+ <div style={{ width: 220 }}>
51
+ <LayerList
52
+ layers={layers}
53
+ visibility={visibility}
54
+ onReorder={setLayers}
55
+ onToggle={(id, visible) => setVisibility((v) => ({ ...v, [id]: visible }))}
56
+ />
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /** No node has children, so no row spends width on a twisty column. */
62
+ export const Flat: Story = { render: () => <Demo seed={FLAT} /> };
63
+
64
+ /** Dragging a row reorders it among its own siblings — a drag never moves a
65
+ * layer to a different parent. */
66
+ export const Tree: Story = { render: () => <Demo seed={NESTED} /> };
@@ -1,7 +1,7 @@
1
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', () => {
@@ -73,6 +73,12 @@ function renderDraggable(onReorder = vi.fn()) {
73
73
  return { ...result, onReorder, handle: screen.getByLabelText('Reorder A') };
74
74
  }
75
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
+
76
82
  describe('<LayerList> reordering', () => {
77
83
  it('reorders from a release that lands off the handle', () => {
78
84
  const { onReorder, handle } = renderDraggable();
@@ -83,13 +89,19 @@ describe('<LayerList> reordering', () => {
83
89
  expect(onReorder.mock.calls[0][0].map((l: LayerDescriptor) => l.id)).toEqual(['b', 'a']);
84
90
  });
85
91
 
86
- it('abandons the reorder when capture is lost mid-drag', () => {
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', () => {
87
95
  const { onReorder, handle, container } = renderDraggable();
88
96
  fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
89
- dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
97
+ // Chrome releases capture a beat before it delivers pointerup, so ending
98
+ // here throws away a release that has already been dispatched.
90
99
  dispatchOn(handle, 'lostpointercapture', {});
91
- expect(onReorder).not.toHaveBeenCalled();
92
- expect(container.querySelector('.lk-layer-list__row--dragging')).toBeNull();
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']);
93
105
  });
94
106
 
95
107
  it('reads a move with nothing held as the release that never arrived', () => {
@@ -118,3 +130,103 @@ describe('<LayerList> reordering', () => {
118
130
  expect(onReorder).not.toHaveBeenCalled();
119
131
  });
120
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,18 +1,44 @@
1
1
  import { openPointerSession, type PointerSession } from '@weasel-js/core';
2
- import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react';
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';
3
12
  import type { LayerDescriptor } from '../instrument/types';
4
13
  import { DragHandleGlyph } from '../passthrough/weasel-ui';
5
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
+
6
24
  /** Props for `<LayerList>`. */
7
25
  export interface LayerListProps {
8
- layers: LayerDescriptor[];
26
+ layers: LayerTreeNode[];
9
27
  visibility: Record<string, boolean>;
10
- 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;
11
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;
12
36
  className?: string;
13
37
  }
14
38
 
15
39
  interface DragState {
40
+ /** Id of the dragged row's parent, or null at the top level. */
41
+ parentId: string | null;
16
42
  fromIndex: number;
17
43
  toIndex: number;
18
44
  startY: number;
@@ -20,21 +46,87 @@ interface DragState {
20
46
  pitch: number;
21
47
  }
22
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
+
23
94
  /** A reorderable list of layers with per-layer visibility toggles. Layers
24
- * marked `alwaysOn` are pinned and cannot be reordered or hidden. */
25
- export function LayerList({ layers, visibility, onReorder, onToggle, className }: LayerListProps) {
26
- const reorderable = layers.filter((l) => !l.alwaysOn);
27
- const pinned = layers.filter((l) => l.alwaysOn);
28
- 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
+ );
29
110
  const dragRef = useRef<DragState | null>(null);
30
111
  const sessionRef = useRef<PointerSession | null>(null);
112
+ const uid = useId();
31
113
  // The session's callbacks outlive the render that opened them, so the commit
32
114
  // reads its inputs here rather than from that render's closure.
33
- const commitRef = useRef({ reorderable, pinned, onReorder });
34
- commitRef.current = { reorderable, pinned, onReorder };
115
+ const commitRef = useRef({ layers, onReorder });
116
+ commitRef.current = { layers, onReorder };
35
117
 
36
118
  useEffect(() => () => sessionRef.current?.cancel(), []);
37
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
+ };
129
+
38
130
  if (layers.length === 0) {
39
131
  return (
40
132
  <div className={className ? `lk-layer-list ${className}` : 'lk-layer-list'}>
@@ -46,10 +138,10 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
46
138
  const clearDrag = () => {
47
139
  dragRef.current = null;
48
140
  sessionRef.current = null;
49
- setDragIndex(null);
141
+ setDragMark(null);
50
142
  };
51
143
 
52
- const startDrag = (e: ReactPointerEvent<HTMLElement>, index: number) => {
144
+ const startDrag = (e: ReactPointerEvent<HTMLElement>, parentId: string | null, index: number) => {
53
145
  // Measured at grab time rather than hardcoded: a restyle that changes row
54
146
  // height or gap would otherwise silently skew every drag distance.
55
147
  const row = e.currentTarget.closest('.lk-layer-list__row');
@@ -57,73 +149,120 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
57
149
  const gap = list ? Number.parseFloat(getComputedStyle(list).rowGap) || 0 : 0;
58
150
  const pitch = row ? row.getBoundingClientRect().height + gap : 0;
59
151
  const drag: DragState = {
152
+ parentId,
60
153
  fromIndex: index,
61
154
  toIndex: index,
62
155
  startY: e.clientY,
63
156
  pitch: pitch > 0 ? pitch : 1,
64
157
  };
65
158
  dragRef.current = drag;
66
- setDragIndex(index);
159
+ setDragMark({ parentId, index });
67
160
  sessionRef.current = openPointerSession(e.currentTarget, e, {
68
161
  onMove: (ev) => {
69
162
  const delta = Math.round((ev.clientY - drag.startY) / drag.pitch);
70
- const last = commitRef.current.reorderable.length - 1;
163
+ const siblings = siblingsOf(commitRef.current.layers, parentId);
164
+ const last = siblings.filter((l) => !l.alwaysOn).length - 1;
71
165
  drag.toIndex = Math.min(last, Math.max(0, drag.fromIndex + delta));
72
- setDragIndex(drag.toIndex);
166
+ setDragMark({ parentId, index: drag.toIndex });
73
167
  },
74
168
  onEnd: () => {
75
- const { reorderable: rows, pinned: locked, onReorder: commit } = commitRef.current;
169
+ const { layers: tree, onReorder: commit } = commitRef.current;
76
170
  const { fromIndex, toIndex } = drag;
77
171
  clearDrag();
78
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);
79
176
  const next = [...rows];
80
177
  const [moved] = next.splice(fromIndex, 1);
81
178
  if (moved) next.splice(toIndex, 0, moved);
82
- commit([...next, ...locked]);
179
+ commit(withChildren(tree, parentId, [...next, ...locked]));
83
180
  },
84
181
  // An interrupted drag never named a destination, so it reorders nothing.
85
182
  onCancel: clearDrag,
86
183
  });
87
184
  };
88
185
 
89
- return (
90
- <div className={className ? `lk-layer-list ${className}` : 'lk-layer-list'}>
91
- {reorderable.map((layer, i) => {
92
- const isDragging = dragIndex === i;
93
- return (
94
- <div
95
- key={layer.id}
96
- className={
97
- isDragging ? 'lk-layer-list__row lk-layer-list__row--dragging' : 'lk-layer-list__row'
98
- }
99
- >
100
- <button
101
- type="button"
102
- className="lk-layer-list__handle"
103
- aria-label={`Reorder ${layer.label}`}
104
- onPointerDown={(e) => startDrag(e, i)}
105
- >
106
- <DragHandleGlyph size={13} />
107
- </button>
108
- <input
109
- className="lk-layer-list__check"
110
- type="checkbox"
111
- checked={visibility[layer.id] !== false}
112
- onChange={(e) => onToggle(layer.id, e.target.checked)}
113
- aria-label={`Toggle ${layer.label}`}
114
- />
115
- <span className="lk-layer-list__label">{layer.label}</span>
116
- </div>
117
- );
118
- })}
119
- {pinned.map((layer) => (
120
- <div key={layer.id} className="lk-layer-list__row lk-layer-list__row--pinned">
121
- <span className="lk-layer-list__lock" role="img" aria-label="Always on">
122
- 🔒
123
- </span>
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);
189
+
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
+ )}
124
241
  <span className="lk-layer-list__label">{layer.label}</span>
125
242
  </div>
126
- ))}
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
+ }
262
+
263
+ return (
264
+ <div className={className ? `lk-layer-list ${className}` : 'lk-layer-list'}>
265
+ {renderLevel(layers, null, '')}
127
266
  </div>
128
267
  );
129
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,
@@ -332,14 +332,19 @@ describe('FloatingPanel drag lifecycle', () => {
332
332
  expect(panel.style.left).toBe('100px');
333
333
  });
334
334
 
335
- it('ends the drag when capture is lost mid-gesture', () => {
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', () => {
336
338
  const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
337
339
  fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
338
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.
339
344
  fireEvent(panel, new PointerEvent('lostpointercapture', { pointerId: 1, bubbles: true }));
340
- expect(panel.dataset.dragging).toBeUndefined();
345
+ expect(panel.dataset.dragging).toBe('true');
341
346
  fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
342
- expect(panel.style.left).toBe('100px');
347
+ expect(panel.style.left).toBe('200px');
343
348
  });
344
349
 
345
350
  it('reads a move with nothing held as the release that never arrived', () => {