@principal-ai/subsystems-react 0.56.1 → 0.56.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.
- package/dist/subsystem/FileDrawer.d.ts +11 -11
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +92 -26
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/GraphSidebar.d.ts.map +1 -1
- package/dist/subsystem/GraphSidebar.js +3 -1
- package/dist/subsystem/GraphSidebar.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +4 -3
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +32 -30
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Canvases/Subsystem/LayoutDirection.stories.tsx +55 -3
- package/src/subsystem/FileDrawer.test.tsx +233 -0
- package/src/subsystem/FileDrawer.tsx +108 -30
- package/src/subsystem/GraphSidebar.test.tsx +56 -1
- package/src/subsystem/GraphSidebar.tsx +3 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +66 -57
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ComponentProps } from 'react';
|
|
3
|
+
import { Window } from 'happy-dom';
|
|
4
|
+
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
|
|
5
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
6
|
+
import { FileDrawer } from './FileDrawer';
|
|
7
|
+
|
|
8
|
+
const dom = new Window();
|
|
9
|
+
const globals = globalThis as unknown as Record<string, unknown>;
|
|
10
|
+
globals.window = dom;
|
|
11
|
+
globals.document = dom.document;
|
|
12
|
+
globals.navigator = dom.navigator;
|
|
13
|
+
globals.HTMLElement = dom.HTMLElement;
|
|
14
|
+
globals.Node = dom.Node;
|
|
15
|
+
globals.Element = dom.Element;
|
|
16
|
+
globals.Event = dom.Event;
|
|
17
|
+
globals.MouseEvent = dom.MouseEvent;
|
|
18
|
+
globals.KeyboardEvent = dom.KeyboardEvent;
|
|
19
|
+
globals.localStorage = dom.localStorage;
|
|
20
|
+
globals.sessionStorage = dom.sessionStorage;
|
|
21
|
+
globals.matchMedia = dom.matchMedia.bind(dom);
|
|
22
|
+
globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
|
|
23
|
+
globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup);
|
|
26
|
+
|
|
27
|
+
function drawer(props: Partial<ComponentProps<typeof FileDrawer>> = {}) {
|
|
28
|
+
return (
|
|
29
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
30
|
+
<FileDrawer title="Trail code" onClose={() => {}} {...props}>
|
|
31
|
+
<input aria-label="Code state" defaultValue="selected step" />
|
|
32
|
+
</FileDrawer>
|
|
33
|
+
</ThemeProvider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parts(container: HTMLElement) {
|
|
38
|
+
return {
|
|
39
|
+
spacer: container.children[0] as HTMLElement,
|
|
40
|
+
panel: container.children[1] as HTMLElement,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setDrawerGeometry(container: HTMLElement, width = 450, containerWidth = 1000) {
|
|
45
|
+
parts(container).panel.getBoundingClientRect = () => ({ width } as DOMRect);
|
|
46
|
+
container.getBoundingClientRect = () => ({ width: containerWidth } as DOMRect);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('FileDrawer docking', () => {
|
|
50
|
+
test('keeps the default bottom drawer and reserves height', () => {
|
|
51
|
+
const { container } = render(drawer());
|
|
52
|
+
const { spacer, panel } = parts(container);
|
|
53
|
+
expect(spacer.style.height).toBe('45%');
|
|
54
|
+
expect(spacer.style.width).toBe('');
|
|
55
|
+
expect(panel.style.height).toBe('45%');
|
|
56
|
+
expect(panel.style.left).toBe('0px');
|
|
57
|
+
expect(panel.style.right).toBe('0px');
|
|
58
|
+
expect(panel.style.top).toBe('');
|
|
59
|
+
expect(panel.style.borderTopStyle).toBe('solid');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('docks on the left and reserves width before the canvas', () => {
|
|
63
|
+
const { container } = render(drawer({ placement: 'left' }));
|
|
64
|
+
const { spacer, panel } = parts(container);
|
|
65
|
+
expect(spacer.style.width).toBe('45%');
|
|
66
|
+
expect(spacer.style.height).toBe('');
|
|
67
|
+
expect(spacer.style.order).toBe('-1');
|
|
68
|
+
expect(panel.style.width).toBe('45%');
|
|
69
|
+
expect(panel.style.height).toBe('100%');
|
|
70
|
+
expect(panel.style.left).toBe('0px');
|
|
71
|
+
expect(panel.style.right).toBe('');
|
|
72
|
+
expect(panel.style.top).toBe('0px');
|
|
73
|
+
expect(panel.style.borderRightStyle).toBe('solid');
|
|
74
|
+
expect(panel.style.borderTopStyle).toBe('none');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('preserves mounted content when switching dock axes', () => {
|
|
78
|
+
const { container, getByLabelText, rerender } = render(drawer());
|
|
79
|
+
const input = getByLabelText('Code state') as HTMLInputElement;
|
|
80
|
+
fireEvent.change(input, { target: { value: 'step 3' } });
|
|
81
|
+
rerender(drawer({ placement: 'left' }));
|
|
82
|
+
expect(getByLabelText('Code state')).toBe(input);
|
|
83
|
+
expect(input.value).toBe('step 3');
|
|
84
|
+
expect(parts(container).spacer.style.height).toBe('');
|
|
85
|
+
rerender(drawer());
|
|
86
|
+
expect(getByLabelText('Code state')).toBe(input);
|
|
87
|
+
expect(parts(container).spacer.style.width).toBe('');
|
|
88
|
+
expect(parts(container).panel.style.width).toBe('');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('maximizes and restores along the left dock axis', () => {
|
|
92
|
+
const { container, getByLabelText } = render(drawer({ placement: 'left' }));
|
|
93
|
+
fireEvent.click(getByLabelText('Maximize code view'));
|
|
94
|
+
expect(parts(container).panel.style.width).toBe('100%');
|
|
95
|
+
expect(parts(container).spacer.style.width).toBe('0px');
|
|
96
|
+
fireEvent.click(getByLabelText('Minimize code view'));
|
|
97
|
+
expect(parts(container).panel.style.width).toBe('45%');
|
|
98
|
+
expect(parts(container).spacer.style.width).toBe('45%');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('fills the graph area for full-file reading', () => {
|
|
102
|
+
const { container } = render(drawer({ placement: 'left', fillHeight: true }));
|
|
103
|
+
expect(parts(container).panel.style.width).toBe('100%');
|
|
104
|
+
expect(parts(container).spacer.style.width).toBe('0px');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('closed and hidden left drawers reserve no width', () => {
|
|
108
|
+
const { container, rerender } = render(drawer({ placement: 'left', title: null }));
|
|
109
|
+
expect(parts(container).panel.style.width).toBe('0px');
|
|
110
|
+
expect(parts(container).spacer.style.width).toBe('0px');
|
|
111
|
+
rerender(drawer({ placement: 'left', hidden: true }));
|
|
112
|
+
expect(parts(container).panel.style.width).toBe('0px');
|
|
113
|
+
expect(parts(container).spacer.style.width).toBe('0px');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('closing resets maximize state before reopening', () => {
|
|
117
|
+
const { container, getByLabelText, rerender } = render(drawer({ placement: 'left' }));
|
|
118
|
+
fireEvent.click(getByLabelText('Maximize code view'));
|
|
119
|
+
rerender(drawer({ placement: 'left', title: null }));
|
|
120
|
+
rerender(drawer({ placement: 'left' }));
|
|
121
|
+
expect(parts(container).panel.style.width).toBe('45%');
|
|
122
|
+
expect(getByLabelText('Maximize code view')).not.toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('Escape closes a left drawer unless a full-file overlay handles it', () => {
|
|
126
|
+
let closes = 0;
|
|
127
|
+
const onClose = () => { closes++; };
|
|
128
|
+
const { rerender } = render(drawer({ placement: 'left', onClose }));
|
|
129
|
+
fireEvent.keyDown(window, { key: 'Escape' });
|
|
130
|
+
expect(closes).toBe(1);
|
|
131
|
+
rerender(drawer({ placement: 'left', onClose, suppressEscape: true }));
|
|
132
|
+
fireEvent.keyDown(window, { key: 'Escape' });
|
|
133
|
+
expect(closes).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('width dragging tracks the initial width and resizes the canvas spacer together', () => {
|
|
137
|
+
let ended = 0;
|
|
138
|
+
const { container, getByLabelText } = render(drawer({
|
|
139
|
+
placement: 'left',
|
|
140
|
+
onResizeEnd: () => { ended++; },
|
|
141
|
+
}));
|
|
142
|
+
setDrawerGeometry(container);
|
|
143
|
+
const input = getByLabelText('Code state');
|
|
144
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
145
|
+
expect(document.body.style.userSelect).toBe('none');
|
|
146
|
+
expect(parts(container).panel.style.transition).toBe('none');
|
|
147
|
+
fireEvent.mouseMove(document, { clientX: 500 });
|
|
148
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
149
|
+
expect(parts(container).spacer.style.width).toBe('50%');
|
|
150
|
+
fireEvent.mouseMove(document, { clientX: 520 });
|
|
151
|
+
expect(parts(container).panel.style.width).toBe('52%');
|
|
152
|
+
fireEvent.mouseMove(document, { clientX: 460 });
|
|
153
|
+
expect(parts(container).panel.style.width).toBe('46%');
|
|
154
|
+
expect(getByLabelText('Code state')).toBe(input);
|
|
155
|
+
fireEvent.mouseUp(document);
|
|
156
|
+
expect(ended).toBe(1);
|
|
157
|
+
expect(document.body.style.userSelect).toBe('');
|
|
158
|
+
expect(document.body.style.cursor).toBe('');
|
|
159
|
+
fireEvent.mouseMove(document, { clientX: 600 });
|
|
160
|
+
expect(parts(container).panel.style.width).toBe('46%');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('width dragging keeps space for both code and graph at either limit', () => {
|
|
164
|
+
const { container, getByLabelText } = render(drawer({ placement: 'left' }));
|
|
165
|
+
setDrawerGeometry(container);
|
|
166
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
167
|
+
fireEvent.mouseMove(document, { clientX: 1000 });
|
|
168
|
+
expect(parts(container).panel.style.width).toBe('76%');
|
|
169
|
+
fireEvent.mouseMove(document, { clientX: 0 });
|
|
170
|
+
expect(parts(container).panel.style.width).toBe('24%');
|
|
171
|
+
fireEvent.mouseMove(document, { clientX: 500 });
|
|
172
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
173
|
+
fireEvent.mouseUp(document);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('narrow containers do not produce inverted width limits', () => {
|
|
177
|
+
const { container, getByLabelText } = render(drawer({ placement: 'left' }));
|
|
178
|
+
setDrawerGeometry(container, 180, 400);
|
|
179
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 180 });
|
|
180
|
+
fireEvent.mouseMove(document, { clientX: 350 });
|
|
181
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
182
|
+
fireEvent.mouseUp(document);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('remembers dragged width across maximize, closing, and layout changes', () => {
|
|
186
|
+
const { container, getByLabelText, queryByLabelText, rerender } = render(drawer({ placement: 'left' }));
|
|
187
|
+
setDrawerGeometry(container);
|
|
188
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
189
|
+
fireEvent.mouseMove(document, { clientX: 500 });
|
|
190
|
+
fireEvent.mouseUp(document);
|
|
191
|
+
fireEvent.click(getByLabelText('Maximize code view'));
|
|
192
|
+
expect(parts(container).panel.style.width).toBe('100%');
|
|
193
|
+
expect(queryByLabelText('Resize code view')).toBeNull();
|
|
194
|
+
fireEvent.click(getByLabelText('Minimize code view'));
|
|
195
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
196
|
+
rerender(drawer({ placement: 'left', title: null }));
|
|
197
|
+
rerender(drawer({ placement: 'left' }));
|
|
198
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
199
|
+
rerender(drawer());
|
|
200
|
+
expect(parts(container).panel.style.height).toBe('45%');
|
|
201
|
+
expect(queryByLabelText('Resize code view')).toBeNull();
|
|
202
|
+
rerender(drawer({ placement: 'left' }));
|
|
203
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('closing mid-drag removes listeners and restores document styles', () => {
|
|
207
|
+
const { container, getByLabelText, rerender } = render(drawer({ placement: 'left' }));
|
|
208
|
+
setDrawerGeometry(container);
|
|
209
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
210
|
+
rerender(drawer({ placement: 'left', title: null }));
|
|
211
|
+
expect(document.body.style.userSelect).toBe('');
|
|
212
|
+
fireEvent.mouseMove(document, { clientX: 700 });
|
|
213
|
+
rerender(drawer({ placement: 'left' }));
|
|
214
|
+
expect(parts(container).panel.style.width).toBe('45%');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('losing window focus finishes the drag and restores existing styles', () => {
|
|
218
|
+
document.body.style.userSelect = 'text';
|
|
219
|
+
document.body.style.cursor = 'crosshair';
|
|
220
|
+
const { container, getByLabelText, unmount } = render(drawer({ placement: 'left' }));
|
|
221
|
+
setDrawerGeometry(container);
|
|
222
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
223
|
+
fireEvent(window, new dom.Event('blur') as unknown as Event);
|
|
224
|
+
expect(document.body.style.userSelect).toBe('text');
|
|
225
|
+
expect(document.body.style.cursor).toBe('crosshair');
|
|
226
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
227
|
+
unmount();
|
|
228
|
+
expect(document.body.style.userSelect).toBe('text');
|
|
229
|
+
expect(document.body.style.cursor).toBe('crosshair');
|
|
230
|
+
document.body.style.userSelect = '';
|
|
231
|
+
document.body.style.cursor = '';
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -1,27 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FileDrawer — bottom
|
|
3
|
-
*
|
|
2
|
+
* FileDrawer — bottom or left panel in the graph area
|
|
3
|
+
* showing file / trail code. Opened by sidebar file-tree clicks,
|
|
4
4
|
* declaration links, and trail step focus; content is injected as
|
|
5
5
|
* children by the graph component.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { useEffect, useState } from "react";
|
|
9
|
-
import type { ReactNode } from "react";
|
|
8
|
+
import { useEffect, useRef, useState } from "react";
|
|
9
|
+
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
|
10
10
|
import { useTheme } from "@principal-ade/industry-theme";
|
|
11
11
|
import { Minimize2, Maximize2, X } from "lucide-react";
|
|
12
12
|
|
|
13
|
-
/** Matches
|
|
14
|
-
* should wait at least this long so the canvas has its reduced height. */
|
|
13
|
+
/** Matches the drawer's size transition; wait this long before fitting the canvas. */
|
|
15
14
|
export const FILE_DRAWER_HEIGHT_MS = 200;
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
* footprint so the canvas shrinks while open (nothing covered). Stays mounted
|
|
20
|
-
* so open/close/maximize animate. The header's maximize button (or a
|
|
21
|
-
* double-click) grows it over the whole graph column for whole-file reading. */
|
|
16
|
+
/** An in-flow spacer reserves the panel's footprint so it never covers the
|
|
17
|
+
* canvas. Stays mounted across open/close, maximize, and docking changes. */
|
|
22
18
|
export function FileDrawer({
|
|
23
19
|
title,
|
|
24
20
|
onClose,
|
|
21
|
+
placement = "bottom",
|
|
22
|
+
onResizeEnd,
|
|
25
23
|
fillHeight = false,
|
|
26
24
|
suppressEscape = false,
|
|
27
25
|
hidden = false,
|
|
@@ -32,7 +30,11 @@ export function FileDrawer({
|
|
|
32
30
|
/** Drawer chrome title; `null` closes the drawer. */
|
|
33
31
|
title: string | null;
|
|
34
32
|
onClose: () => void;
|
|
35
|
-
/**
|
|
33
|
+
/** Left docking requires a row container; bottom docking requires a column. */
|
|
34
|
+
placement?: "bottom" | "left";
|
|
35
|
+
/** Fired when the user finishes dragging the side-docked code view's width. */
|
|
36
|
+
onResizeEnd?: () => void;
|
|
37
|
+
/** When true and open, fill the graph area instead of ~45% of its dock axis. */
|
|
36
38
|
fillHeight?: boolean;
|
|
37
39
|
/** Skip Escape handling (e.g. while a full-file overlay is on top). */
|
|
38
40
|
suppressEscape?: boolean;
|
|
@@ -54,18 +56,72 @@ export function FileDrawer({
|
|
|
54
56
|
const showProgress = progress != null && progress.total > 1;
|
|
55
57
|
const [closeHover, setCloseHover] = useState(false);
|
|
56
58
|
const [maxHover, setMaxHover] = useState(false);
|
|
57
|
-
//
|
|
58
|
-
// The panel is always bottom-anchored with an animated height, and a spacer
|
|
59
|
-
// in normal flow reserves its footprint, so maximizing grows it upward over
|
|
60
|
-
// the still-mounted graph instead of snapping to full.
|
|
59
|
+
// Maximize grows over the still-mounted graph along the current dock axis.
|
|
61
60
|
const [maximized, setMaximized] = useState(false);
|
|
62
|
-
// A closed drawer reopens at the default
|
|
61
|
+
// A closed drawer reopens at the default size, not maximized.
|
|
63
62
|
useEffect(() => {
|
|
64
63
|
if (!open) setMaximized(false);
|
|
65
64
|
}, [open]);
|
|
66
|
-
const
|
|
65
|
+
const fullSize = open && (maximized || fillHeight);
|
|
66
|
+
const sideDocked = placement === "left";
|
|
67
|
+
const sizeAxis = sideDocked ? "width" : "height";
|
|
68
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
69
|
+
const [widthPercent, setWidthPercent] = useState(45);
|
|
70
|
+
const [resizing, setResizing] = useState(false);
|
|
71
|
+
const dragStart = useRef({ x: 0, width: 0, containerWidth: 0 });
|
|
72
|
+
const canResize = open && sideDocked && !fullSize;
|
|
73
|
+
const dockSize = sideDocked ? `${widthPercent}%` : "45%";
|
|
67
74
|
// Space the panel takes out of the canvas's flow (0 when it overlays fully).
|
|
68
|
-
const
|
|
75
|
+
const reservedSize = fullSize ? 0 : open ? dockSize : 0;
|
|
76
|
+
const sizeTransition = resizing ? "none" : `${sizeAxis} ${FILE_DRAWER_HEIGHT_MS}ms ease`;
|
|
77
|
+
|
|
78
|
+
const onResizeStart = (event: ReactMouseEvent) => {
|
|
79
|
+
if (!canResize || event.button !== 0) return;
|
|
80
|
+
const panel = panelRef.current;
|
|
81
|
+
const containerWidth = panel?.parentElement?.getBoundingClientRect().width ?? 0;
|
|
82
|
+
if (panel == null || containerWidth <= 0) return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
dragStart.current = {
|
|
85
|
+
x: event.clientX,
|
|
86
|
+
width: panel.getBoundingClientRect().width,
|
|
87
|
+
containerWidth,
|
|
88
|
+
};
|
|
89
|
+
setResizing(true);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!canResize) setResizing(false);
|
|
94
|
+
}, [canResize]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!resizing || !canResize) return;
|
|
98
|
+
const { x, width, containerWidth } = dragStart.current;
|
|
99
|
+
// Keep 240px for both code and graph when space permits; split narrow
|
|
100
|
+
// containers evenly rather than giving either pane a negative width.
|
|
101
|
+
const minWidth = Math.min(240, containerWidth / 2);
|
|
102
|
+
const maxWidth = containerWidth - minWidth;
|
|
103
|
+
const onMove = (event: MouseEvent) => {
|
|
104
|
+
const next = Math.min(Math.max(width + event.clientX - x, minWidth), maxWidth);
|
|
105
|
+
setWidthPercent(next / containerWidth * 100);
|
|
106
|
+
};
|
|
107
|
+
const onUp = () => {
|
|
108
|
+
setResizing(false);
|
|
109
|
+
onResizeEnd?.();
|
|
110
|
+
};
|
|
111
|
+
const { userSelect, cursor } = document.body.style;
|
|
112
|
+
document.body.style.userSelect = "none";
|
|
113
|
+
document.body.style.cursor = "col-resize";
|
|
114
|
+
document.addEventListener("mousemove", onMove);
|
|
115
|
+
document.addEventListener("mouseup", onUp);
|
|
116
|
+
window.addEventListener("blur", onUp);
|
|
117
|
+
return () => {
|
|
118
|
+
document.body.style.userSelect = userSelect;
|
|
119
|
+
document.body.style.cursor = cursor;
|
|
120
|
+
document.removeEventListener("mousemove", onMove);
|
|
121
|
+
document.removeEventListener("mouseup", onUp);
|
|
122
|
+
window.removeEventListener("blur", onUp);
|
|
123
|
+
};
|
|
124
|
+
}, [resizing, canResize, onResizeEnd]);
|
|
69
125
|
|
|
70
126
|
useEffect(() => {
|
|
71
127
|
if (!open || suppressEscape) return;
|
|
@@ -78,36 +134,39 @@ export function FileDrawer({
|
|
|
78
134
|
|
|
79
135
|
return (
|
|
80
136
|
<>
|
|
81
|
-
{/*
|
|
82
|
-
while the drawer is open. Animates with the panel, so the boundary
|
|
83
|
-
between graph and code slides smoothly. */}
|
|
137
|
+
{/* Reserve the same size along the parent's matching flex axis. */}
|
|
84
138
|
<div
|
|
85
139
|
aria-hidden="true"
|
|
86
140
|
style={{
|
|
87
141
|
flexShrink: 0,
|
|
88
|
-
|
|
89
|
-
|
|
142
|
+
order: sideDocked ? -1 : undefined,
|
|
143
|
+
[sizeAxis]: reservedSize,
|
|
144
|
+
transition: sizeTransition,
|
|
90
145
|
}}
|
|
91
146
|
/>
|
|
92
|
-
{/* The panel — bottom-anchored and grown via `height`, so maximize/fill
|
|
93
|
-
reads as dragging up over the diagram. */}
|
|
94
147
|
<div
|
|
148
|
+
ref={panelRef}
|
|
95
149
|
style={{
|
|
96
150
|
position: "absolute",
|
|
97
151
|
left: 0,
|
|
98
|
-
|
|
152
|
+
top: sideDocked ? 0 : undefined,
|
|
153
|
+
right: sideDocked ? undefined : 0,
|
|
99
154
|
bottom: 0,
|
|
100
155
|
// Above the edge-label overlay (zIndex 5), React Flow, and overlays
|
|
101
156
|
// (zIndex 6–8); 30 lifts it over the whole graph area when maximized.
|
|
102
157
|
zIndex: maximized ? 30 : 6,
|
|
103
|
-
|
|
158
|
+
width: sideDocked ? (open ? (fullSize ? "100%" : dockSize) : 0) : undefined,
|
|
159
|
+
height: sideDocked ? "100%" : open ? (fullSize ? "100%" : "45%") : 0,
|
|
160
|
+
minWidth: 0,
|
|
104
161
|
minHeight: 0,
|
|
162
|
+
boxSizing: "border-box",
|
|
105
163
|
overflow: "hidden",
|
|
106
164
|
display: "flex",
|
|
107
165
|
flexDirection: "column",
|
|
108
166
|
background: theme.colors.background,
|
|
109
|
-
borderTop: open ? `1px solid ${theme.colors.border}` : "none",
|
|
110
|
-
|
|
167
|
+
borderTop: open && !sideDocked ? `1px solid ${theme.colors.border}` : "none",
|
|
168
|
+
borderRight: open && sideDocked ? `1px solid ${theme.colors.border}` : "none",
|
|
169
|
+
transition: sizeTransition,
|
|
111
170
|
}}
|
|
112
171
|
>
|
|
113
172
|
<div
|
|
@@ -232,6 +291,25 @@ export function FileDrawer({
|
|
|
232
291
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
|
233
292
|
{children}
|
|
234
293
|
</div>
|
|
294
|
+
{canResize && (
|
|
295
|
+
<div
|
|
296
|
+
role="separator"
|
|
297
|
+
aria-orientation="vertical"
|
|
298
|
+
aria-label="Resize code view"
|
|
299
|
+
title="Drag to resize code view"
|
|
300
|
+
onMouseDown={onResizeStart}
|
|
301
|
+
style={{
|
|
302
|
+
position: "absolute",
|
|
303
|
+
top: 0,
|
|
304
|
+
right: 0,
|
|
305
|
+
bottom: 0,
|
|
306
|
+
width: 5,
|
|
307
|
+
cursor: "col-resize",
|
|
308
|
+
background: theme.colors.border,
|
|
309
|
+
zIndex: 2,
|
|
310
|
+
}}
|
|
311
|
+
/>
|
|
312
|
+
)}
|
|
235
313
|
</div>
|
|
236
314
|
</>
|
|
237
315
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { createElement } from 'react';
|
|
2
|
+
import { createElement, useState } from 'react';
|
|
3
3
|
import { Window } from 'happy-dom';
|
|
4
4
|
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
|
|
5
5
|
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
@@ -125,6 +125,59 @@ describe('GraphSidebar chrome', () => {
|
|
|
125
125
|
expect(sidebarColumn(container).style.width).toBe('400px');
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
test('multiple moves track displacement from drag start without accumulating', () => {
|
|
129
|
+
const { container, getByLabelText } = renderSidebar({});
|
|
130
|
+
const handle = getByLabelText('Resize sidebar');
|
|
131
|
+
fireEvent.mouseDown(handle, { clientX: 100 });
|
|
132
|
+
fireEvent.mouseMove(document, { clientX: 120 });
|
|
133
|
+
expect(sidebarColumn(container).style.width).toBe('340px');
|
|
134
|
+
fireEvent.mouseMove(document, { clientX: 140 });
|
|
135
|
+
expect(sidebarColumn(container).style.width).toBe('360px');
|
|
136
|
+
fireEvent.mouseMove(document, { clientX: 110 });
|
|
137
|
+
expect(sidebarColumn(container).style.width).toBe('330px');
|
|
138
|
+
fireEvent.mouseUp(document);
|
|
139
|
+
|
|
140
|
+
// Each new gesture starts from the width the previous gesture left.
|
|
141
|
+
fireEvent.mouseDown(handle, { clientX: 110 });
|
|
142
|
+
fireEvent.mouseMove(document, { clientX: 130 });
|
|
143
|
+
fireEvent.mouseUp(document);
|
|
144
|
+
expect(sidebarColumn(container).style.width).toBe('350px');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('clamps at both limits and follows the pointer back from them', () => {
|
|
148
|
+
const { container, getByLabelText } = renderSidebar({});
|
|
149
|
+
fireEvent.mouseDown(getByLabelText('Resize sidebar'), { clientX: 100 });
|
|
150
|
+
fireEvent.mouseMove(document, { clientX: 500 });
|
|
151
|
+
expect(sidebarColumn(container).style.width).toBe('600px');
|
|
152
|
+
fireEvent.mouseMove(document, { clientX: 150 });
|
|
153
|
+
expect(sidebarColumn(container).style.width).toBe('370px');
|
|
154
|
+
fireEvent.mouseMove(document, { clientX: -100 });
|
|
155
|
+
expect(sidebarColumn(container).style.width).toBe('240px');
|
|
156
|
+
fireEvent.mouseMove(document, { clientX: 100 });
|
|
157
|
+
expect(sidebarColumn(container).style.width).toBe('320px');
|
|
158
|
+
fireEvent.mouseUp(document);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('controlled host updates do not change the drag baseline', () => {
|
|
162
|
+
function ControlledSidebar() {
|
|
163
|
+
const [width, setWidth] = useState(400);
|
|
164
|
+
return createElement(
|
|
165
|
+
ThemeProvider,
|
|
166
|
+
{ theme: defaultEditorTheme },
|
|
167
|
+
createElement(GraphSidebar, { width, onWidthChange: setWidth }),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const { container, getByLabelText } = render(createElement(ControlledSidebar));
|
|
171
|
+
fireEvent.mouseDown(getByLabelText('Resize sidebar'), { clientX: 100 });
|
|
172
|
+
fireEvent.mouseMove(document, { clientX: 140 });
|
|
173
|
+
expect(sidebarColumn(container).style.width).toBe('440px');
|
|
174
|
+
fireEvent.mouseMove(document, { clientX: 160 });
|
|
175
|
+
expect(sidebarColumn(container).style.width).toBe('460px');
|
|
176
|
+
fireEvent.mouseMove(document, { clientX: 100 });
|
|
177
|
+
expect(sidebarColumn(container).style.width).toBe('400px');
|
|
178
|
+
fireEvent.mouseUp(document);
|
|
179
|
+
});
|
|
180
|
+
|
|
128
181
|
test('a drag ends by persisting the width under persistKey', () => {
|
|
129
182
|
const { getByLabelText } = renderSidebar({
|
|
130
183
|
persistKey: 'gs-test',
|
|
@@ -132,6 +185,8 @@ describe('GraphSidebar chrome', () => {
|
|
|
132
185
|
});
|
|
133
186
|
const handle = getByLabelText('Resize sidebar');
|
|
134
187
|
fireEvent.mouseDown(handle, { clientX: 100 });
|
|
188
|
+
fireEvent.mouseMove(document, { clientX: 140 });
|
|
189
|
+
fireEvent.mouseMove(document, { clientX: 180 });
|
|
135
190
|
fireEvent.mouseMove(document, { clientX: 200 });
|
|
136
191
|
fireEvent.mouseUp(document);
|
|
137
192
|
expect(window.localStorage.getItem('principal.subsystems.sidebarWidth.gs-test')).toBe('420');
|
|
@@ -130,6 +130,7 @@ export function GraphSidebar({
|
|
|
130
130
|
// persisting only then — not on every mousemove.
|
|
131
131
|
const [dragging, setDragging] = useState(false);
|
|
132
132
|
const dragStartX = useRef(0);
|
|
133
|
+
const dragStartWidth = useRef(0);
|
|
133
134
|
// Mirror of the current width for the mouseup handler (a closure over the
|
|
134
135
|
// drag-start render would persist the stale start width).
|
|
135
136
|
const currentWidthRef = useRef(effectiveWidth);
|
|
@@ -139,6 +140,7 @@ export function GraphSidebar({
|
|
|
139
140
|
(e: ReactMouseEvent) => {
|
|
140
141
|
e.preventDefault();
|
|
141
142
|
dragStartX.current = e.clientX;
|
|
143
|
+
dragStartWidth.current = effectiveWidth;
|
|
142
144
|
currentWidthRef.current = effectiveWidth;
|
|
143
145
|
setDragging(true);
|
|
144
146
|
onDraggingChange?.(true);
|
|
@@ -150,7 +152,7 @@ export function GraphSidebar({
|
|
|
150
152
|
if (!dragging) return;
|
|
151
153
|
const onMove = (e: MouseEvent) => {
|
|
152
154
|
const next = Math.min(
|
|
153
|
-
Math.max(
|
|
155
|
+
Math.max(dragStartWidth.current + (e.clientX - dragStartX.current), minWidth),
|
|
154
156
|
maxWidth,
|
|
155
157
|
);
|
|
156
158
|
if (controlled) {
|