@principal-ai/subsystems-react 0.53.6 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +7 -5
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js +1 -2
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +144 -9
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +924 -148
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +22 -1
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +10 -2
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/ComponentDeclaration.d.ts +5 -4
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +12 -4
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
- package/dist/subsystem/ConstructsCatalog.js +10 -7
- package/dist/subsystem/ConstructsCatalog.js.map +1 -1
- package/dist/subsystem/GraphSidebar.d.ts +71 -0
- package/dist/subsystem/GraphSidebar.d.ts.map +1 -0
- package/dist/subsystem/GraphSidebar.js +145 -0
- package/dist/subsystem/GraphSidebar.js.map +1 -0
- package/dist/subsystem/GraphWithSidebar.d.ts +24 -0
- package/dist/subsystem/GraphWithSidebar.d.ts.map +1 -0
- package/dist/subsystem/GraphWithSidebar.js +25 -0
- package/dist/subsystem/GraphWithSidebar.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts +33 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +364 -411
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/TrailStepsNav.d.ts +48 -0
- package/dist/subsystem/TrailStepsNav.d.ts.map +1 -0
- package/dist/subsystem/TrailStepsNav.js +189 -0
- package/dist/subsystem/TrailStepsNav.js.map +1 -0
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +3 -4
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/dist/subsystem/__fixtures__/c4Drilldown.d.ts +151 -0
- package/dist/subsystem/__fixtures__/c4Drilldown.d.ts.map +1 -0
- package/dist/subsystem/__fixtures__/c4Drilldown.js +783 -0
- package/dist/subsystem/__fixtures__/c4Drilldown.js.map +1 -0
- package/dist/subsystem/c4.d.ts +39 -8
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/declarationRef.d.ts +26 -18
- package/dist/subsystem/declarationRef.d.ts.map +1 -1
- package/dist/subsystem/declarationRef.js +9 -4
- package/dist/subsystem/declarationRef.js.map +1 -1
- package/dist/subsystem/model.d.ts +19 -1
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +12 -2
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +24 -4
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/trailBrief.d.ts.map +1 -1
- package/dist/subsystem/trailBrief.js +6 -1
- package/dist/subsystem/trailBrief.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +9 -2
- package/src/pierre/PierreTrailCodeView.tsx +9 -13
- package/src/stories/Subsystem/C4Graph/C4ToComponentDrill.stories.tsx +496 -0
- package/src/stories/Subsystem/ComponentGraph/AnimatedProgression.stories.tsx +7 -7
- package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +19 -25
- package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +8 -10
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +19 -19
- package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +6 -6
- package/src/stories/Subsystem/ComponentGraph/TrailAutoplay.stories.tsx +7 -7
- package/src/stories/Subsystem/ComponentGraph/Trails.stories.tsx +27 -30
- package/src/stories/Subsystem/ComponentGraph/fixtures.ts +1 -2
- package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +10 -13
- package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +3 -4
- package/src/stories/Subsystem/Sidebar/GraphSidebar.stories.tsx +206 -0
- package/src/subsystem/C4Graph.groups.test.ts +96 -2
- package/src/subsystem/C4Graph.tsx +1377 -165
- package/src/subsystem/C4NodeCard.test.ts +1 -2
- package/src/subsystem/C4NodeCard.tsx +24 -2
- package/src/subsystem/ComponentDeclaration.tsx +18 -8
- package/src/subsystem/ConstructsCatalog.tsx +23 -8
- package/src/subsystem/GraphSidebar.test.tsx +152 -0
- package/src/subsystem/GraphSidebar.tsx +300 -0
- package/src/subsystem/GraphWithSidebar.test.tsx +78 -0
- package/src/subsystem/GraphWithSidebar.tsx +60 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +284 -370
- package/src/subsystem/TrailStepsNav.test.tsx +179 -0
- package/src/subsystem/TrailStepsNav.tsx +372 -0
- package/src/subsystem/TrailsPanel.tsx +3 -4
- package/src/subsystem/__fixtures__/c4Drilldown.test.ts +338 -0
- package/src/subsystem/__fixtures__/c4Drilldown.ts +847 -0
- package/src/subsystem/c4.ts +41 -8
- package/src/subsystem/declarationRef.ts +28 -19
- package/src/subsystem/model.test.ts +32 -12
- package/src/subsystem/model.ts +30 -1
- package/src/subsystem/nodes.group.test.tsx +82 -0
- package/src/subsystem/nodes.tsx +30 -2
- package/src/subsystem/trailBrief.test.ts +2 -2
- package/src/subsystem/trailBrief.ts +6 -1
|
@@ -221,7 +221,6 @@ describe('nodeSubtitle', () => {
|
|
|
221
221
|
|
|
222
222
|
test('is empty when nothing is stated — there is no fallback', () => {
|
|
223
223
|
const withTech = node({ technology: 'Bun + Electrobun' });
|
|
224
|
-
const withMembers = node({ constructs: ['function', 'store'], members: ['a', 'b'] });
|
|
225
224
|
const component = node({
|
|
226
225
|
kind: 'component',
|
|
227
226
|
component: {
|
|
@@ -232,7 +231,7 @@ describe('nodeSubtitle', () => {
|
|
|
232
231
|
purl: 'pkg:x#src/subsystem/model.ts',
|
|
233
232
|
},
|
|
234
233
|
});
|
|
235
|
-
for (const n of [withTech,
|
|
234
|
+
for (const n of [withTech, component]) {
|
|
236
235
|
expect(nodeSubtitle(n)).toBe('');
|
|
237
236
|
}
|
|
238
237
|
});
|
|
@@ -91,6 +91,15 @@ const CARD_PAD_Y = 10;
|
|
|
91
91
|
/** The card's flex `gap`, between each of its rows. */
|
|
92
92
|
const CARD_GAP = 3;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Space a component card reserves above and below the constructs nested inside
|
|
96
|
+
* it when it is grown (a component with constructs becomes an ELK parent, the
|
|
97
|
+
* same way an opened container does). A component has no technology row and no
|
|
98
|
+
* collapse affordance, so this is its label slot plus padding.
|
|
99
|
+
*/
|
|
100
|
+
export const COMPONENT_HEADER_PAD = CARD_PAD_Y * 2 + LABEL_SLOT_H + CARD_GAP;
|
|
101
|
+
export const COMPONENT_FOOTER_PAD = CARD_PAD_Y;
|
|
102
|
+
|
|
94
103
|
/** The description's line height, as a multiple of its font size. */
|
|
95
104
|
const DESC_LINE_HEIGHT = 1.35;
|
|
96
105
|
|
|
@@ -416,9 +425,22 @@ export interface C4NodeCardProps {
|
|
|
416
425
|
* the grown card to host its component cards.
|
|
417
426
|
*/
|
|
418
427
|
children?: React.ReactNode;
|
|
428
|
+
/**
|
|
429
|
+
* Delay the width/height transition by this many ms — the drill-down
|
|
430
|
+
* sequences the fade before the move/grow, so the size change waits it out.
|
|
431
|
+
* @default 0
|
|
432
|
+
*/
|
|
433
|
+
transitionDelayMs?: number;
|
|
434
|
+
/**
|
|
435
|
+
* The width/height transition duration, in ms. The graph passes its
|
|
436
|
+
* `MORPH_MS` so the grow and the component reveal (`settled`) share one clock
|
|
437
|
+
* and cannot drift apart.
|
|
438
|
+
* @default 420
|
|
439
|
+
*/
|
|
440
|
+
sizeTransitionMs?: number;
|
|
419
441
|
}
|
|
420
442
|
|
|
421
|
-
export function C4NodeCard({ node, selected = false, handles, onClick, componentCount = 0, onExpand, width, height, showDescription = true, children }: C4NodeCardProps) {
|
|
443
|
+
export function C4NodeCard({ node, selected = false, handles, onClick, componentCount = 0, onExpand, width, height, showDescription = true, children, transitionDelayMs = 0, sizeTransitionMs = 420 }: C4NodeCardProps) {
|
|
422
444
|
const { theme } = useTheme();
|
|
423
445
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
424
446
|
const style = nodeStyle(node, theme, selected);
|
|
@@ -465,7 +487,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
|
|
|
465
487
|
// rather than drawn outside the boundary. The description is clamped above
|
|
466
488
|
// so that clipping is never what you actually see.
|
|
467
489
|
height: reservedHeight,
|
|
468
|
-
transition:
|
|
490
|
+
transition: `width ${sizeTransitionMs}ms ease-out ${transitionDelayMs}ms, height ${sizeTransitionMs}ms ease-out ${transitionDelayMs}ms`,
|
|
469
491
|
overflow: 'hidden',
|
|
470
492
|
boxSizing: 'border-box',
|
|
471
493
|
display: 'flex',
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type SubsystemComponent,
|
|
17
17
|
type SubsystemDeclTokenKind,
|
|
18
18
|
} from './model';
|
|
19
|
-
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
19
|
+
import type { SpanFreshness, SubsystemOpenFileOptions } from './declarationRef';
|
|
20
20
|
import { parseSourceLocation } from './declarationRef';
|
|
21
21
|
import { resolveComponentDeclaration } from './formatDeclaration';
|
|
22
22
|
import { tokenizeComponent } from './tokenizeComponent';
|
|
@@ -101,10 +101,11 @@ export interface ComponentVerificationState {
|
|
|
101
101
|
inlineParameters?: number;
|
|
102
102
|
};
|
|
103
103
|
declaration?: {
|
|
104
|
-
freshness:
|
|
105
|
-
|
|
104
|
+
freshness: SpanFreshness;
|
|
105
|
+
span?: {
|
|
106
106
|
startLine: number;
|
|
107
|
-
|
|
107
|
+
endLine: number;
|
|
108
|
+
contentHash: string;
|
|
108
109
|
};
|
|
109
110
|
};
|
|
110
111
|
}
|
|
@@ -235,7 +236,7 @@ function verificationSummary(
|
|
|
235
236
|
const declFresh = v.declaration?.freshness;
|
|
236
237
|
const declBit =
|
|
237
238
|
declFresh && declFresh !== 'unanchored'
|
|
238
|
-
? ` · declaration ${declFresh}${v.declaration?.
|
|
239
|
+
? ` · declaration ${declFresh}${v.declaration?.span ? ` L${v.declaration.span.startLine}` : ''}`
|
|
239
240
|
: '';
|
|
240
241
|
const anchorBit = `exact → ${v.anchor?.label ?? v.anchor?.nodeId ?? 'node'}${loc}${declBit}`;
|
|
241
242
|
if (v.construct && v.construct.match !== true) {
|
|
@@ -418,10 +419,14 @@ export function ComponentDeclaration({
|
|
|
418
419
|
}, [inspection]);
|
|
419
420
|
|
|
420
421
|
const declarationStartLine =
|
|
421
|
-
component.
|
|
422
|
-
verification?.declaration?.
|
|
422
|
+
component.declarationSpan?.startLine ??
|
|
423
|
+
verification?.declaration?.span?.startLine ??
|
|
423
424
|
parseSourceLocation(verification?.anchor?.source_location) ??
|
|
424
425
|
undefined;
|
|
426
|
+
const declarationEndLine =
|
|
427
|
+
component.declarationSpan?.endLine ??
|
|
428
|
+
verification?.declaration?.span?.endLine ??
|
|
429
|
+
undefined;
|
|
425
430
|
|
|
426
431
|
const externalKind =
|
|
427
432
|
(component.declaration?.kind ?? component.construct) === 'external';
|
|
@@ -434,7 +439,12 @@ export function ComponentDeclaration({
|
|
|
434
439
|
if (!onOpenFile || !component.file) return;
|
|
435
440
|
onOpenFile(
|
|
436
441
|
component.file,
|
|
437
|
-
declarationStartLine != null
|
|
442
|
+
declarationStartLine != null
|
|
443
|
+
? {
|
|
444
|
+
startLine: declarationStartLine,
|
|
445
|
+
...(declarationEndLine != null ? { endLine: declarationEndLine } : {}),
|
|
446
|
+
}
|
|
447
|
+
: undefined,
|
|
438
448
|
);
|
|
439
449
|
};
|
|
440
450
|
|
|
@@ -75,12 +75,23 @@ const FileDrawerContent = memo(function FileDrawerContent({
|
|
|
75
75
|
render,
|
|
76
76
|
file,
|
|
77
77
|
startLine,
|
|
78
|
+
endLine,
|
|
78
79
|
}: {
|
|
79
80
|
render: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
|
|
80
81
|
file: string;
|
|
81
82
|
startLine?: number;
|
|
83
|
+
endLine?: number;
|
|
82
84
|
}) {
|
|
83
|
-
return
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
{render(
|
|
88
|
+
file,
|
|
89
|
+
startLine != null
|
|
90
|
+
? { startLine, ...(endLine != null ? { endLine } : {}) }
|
|
91
|
+
: undefined,
|
|
92
|
+
)}
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
84
95
|
});
|
|
85
96
|
|
|
86
97
|
interface FileTreeNode {
|
|
@@ -236,9 +247,11 @@ export function ConstructsCatalog({
|
|
|
236
247
|
const [purposeOverrides, setPurposeOverrides] = useState<
|
|
237
248
|
Record<string, boolean>
|
|
238
249
|
>({});
|
|
239
|
-
const [drawer, setDrawer] = useState<{
|
|
240
|
-
|
|
241
|
-
|
|
250
|
+
const [drawer, setDrawer] = useState<{
|
|
251
|
+
file: string;
|
|
252
|
+
startLine?: number;
|
|
253
|
+
endLine?: number;
|
|
254
|
+
} | null>(null);
|
|
242
255
|
|
|
243
256
|
const visibleSet = useMemo(() => new Set(visibleAliases), [visibleAliases]);
|
|
244
257
|
const visibleComponents = useMemo(
|
|
@@ -314,7 +327,7 @@ export function ConstructsCatalog({
|
|
|
314
327
|
if (!drawer) return;
|
|
315
328
|
const match =
|
|
316
329
|
orderedComponents.find(
|
|
317
|
-
(c) => c.file === drawer.file && c.
|
|
330
|
+
(c) => c.file === drawer.file && c.declarationSpan?.startLine === drawer.startLine,
|
|
318
331
|
) ?? orderedComponents.find((c) => c.file === drawer.file);
|
|
319
332
|
if (!match) return;
|
|
320
333
|
document
|
|
@@ -508,9 +521,10 @@ export function ConstructsCatalog({
|
|
|
508
521
|
const onOpenFile = useCallback(
|
|
509
522
|
(file: string, opts?: SubsystemOpenFileOptions) => {
|
|
510
523
|
const startLine = opts?.startLine;
|
|
524
|
+
const endLine = opts?.endLine;
|
|
511
525
|
setDrawer((prev) => {
|
|
512
526
|
if (prev?.file === file && prev.startLine === startLine) return null;
|
|
513
|
-
return { file, startLine };
|
|
527
|
+
return { file, startLine, endLine };
|
|
514
528
|
});
|
|
515
529
|
onFileSelect?.(file);
|
|
516
530
|
},
|
|
@@ -1013,12 +1027,12 @@ export function ConstructsCatalog({
|
|
|
1013
1027
|
const lineOpen =
|
|
1014
1028
|
fileOpen &&
|
|
1015
1029
|
drawer.startLine != null &&
|
|
1016
|
-
c.
|
|
1030
|
+
c.declarationSpan?.startLine === drawer.startLine;
|
|
1017
1031
|
// A combined header owns the path, so declarations under
|
|
1018
1032
|
// it drop their own badge; those with no known line just
|
|
1019
1033
|
// render unnumbered. Without a header, keep the badge.
|
|
1020
1034
|
const numbered =
|
|
1021
|
-
hasFile && c.
|
|
1035
|
+
hasFile && c.declarationSpan?.startLine != null;
|
|
1022
1036
|
// The header names the external kind, so the body just
|
|
1023
1037
|
// names the construct.
|
|
1024
1038
|
const isExt = c.construct === 'external';
|
|
@@ -1099,6 +1113,7 @@ export function ConstructsCatalog({
|
|
|
1099
1113
|
render={renderDrawerContent}
|
|
1100
1114
|
file={drawer.file}
|
|
1101
1115
|
startLine={drawer.startLine}
|
|
1116
|
+
endLine={drawer.endLine}
|
|
1102
1117
|
/>
|
|
1103
1118
|
) : null}
|
|
1104
1119
|
</FileDrawer>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createElement } 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 { GraphSidebar } from './GraphSidebar';
|
|
7
|
+
|
|
8
|
+
// Bun's happy-dom test environment isn't registered (no @happy-dom/global-
|
|
9
|
+
// registrator), so wire the DOM primitives React DOM needs before rendering.
|
|
10
|
+
const dom = new Window();
|
|
11
|
+
const globals = globalThis as unknown as Record<string, unknown>;
|
|
12
|
+
globals.window = dom;
|
|
13
|
+
globals.document = dom.document;
|
|
14
|
+
globals.navigator = dom.navigator;
|
|
15
|
+
globals.HTMLElement = dom.HTMLElement;
|
|
16
|
+
globals.Node = dom.Node;
|
|
17
|
+
globals.Element = dom.Element;
|
|
18
|
+
globals.Event = dom.Event;
|
|
19
|
+
globals.getComputedStyle = dom.getComputedStyle.bind(dom);
|
|
20
|
+
globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
|
|
21
|
+
globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
|
|
22
|
+
globals.localStorage = dom.localStorage;
|
|
23
|
+
globals.matchMedia = dom.matchMedia.bind(dom);
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup);
|
|
26
|
+
|
|
27
|
+
function renderSidebar(props: Record<string, unknown>) {
|
|
28
|
+
return render(
|
|
29
|
+
createElement(
|
|
30
|
+
ThemeProvider,
|
|
31
|
+
{ theme: defaultEditorTheme },
|
|
32
|
+
createElement(GraphSidebar as never, props as never),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sidebarColumn = (container: HTMLElement) =>
|
|
38
|
+
container.querySelector('div[style*="border-right"]') as HTMLElement;
|
|
39
|
+
|
|
40
|
+
describe('GraphSidebar chrome', () => {
|
|
41
|
+
test('renders the sidebar column and resize handle', () => {
|
|
42
|
+
const { container, getByText, getByLabelText } = renderSidebar({
|
|
43
|
+
header: createElement('span', null, 'title'),
|
|
44
|
+
sidebarBody: createElement('span', null, 'panel'),
|
|
45
|
+
});
|
|
46
|
+
expect(getByText('title')).toBeTruthy();
|
|
47
|
+
expect(getByText('panel')).toBeTruthy();
|
|
48
|
+
expect(getByLabelText('Resize sidebar')).toBeTruthy();
|
|
49
|
+
// Uncontrolled width defaults to 320px.
|
|
50
|
+
expect(sidebarColumn(container).style.width).toBe('320px');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('hidden renders nothing — no column, no handle', () => {
|
|
54
|
+
const { queryByLabelText, queryByText } = renderSidebar({
|
|
55
|
+
hidden: true,
|
|
56
|
+
header: createElement('span', null, 'title'),
|
|
57
|
+
});
|
|
58
|
+
expect(queryByText('title')).toBeNull();
|
|
59
|
+
expect(queryByLabelText('Resize sidebar')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('tabs render a strip and report the change', () => {
|
|
63
|
+
let changed: string | null = null;
|
|
64
|
+
const { getByRole } = renderSidebar({
|
|
65
|
+
tabs: [
|
|
66
|
+
{ id: 'a', label: 'Alpha' },
|
|
67
|
+
{ id: 'b', label: 'Beta' },
|
|
68
|
+
],
|
|
69
|
+
activeTab: 'a',
|
|
70
|
+
onTabChange: (id: string) => {
|
|
71
|
+
changed = id;
|
|
72
|
+
},
|
|
73
|
+
sidebarBody: createElement('span', null, 'body'),
|
|
74
|
+
});
|
|
75
|
+
expect(getByRole('tablist')).toBeTruthy();
|
|
76
|
+
fireEvent.click(getByRole('tab', { name: 'Beta' }));
|
|
77
|
+
expect(changed).toBe('b');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('no tabs renders a single body without a strip', () => {
|
|
81
|
+
const { queryByRole, getByText } = renderSidebar({
|
|
82
|
+
sidebarBody: createElement('span', null, 'body'),
|
|
83
|
+
});
|
|
84
|
+
expect(getByText('body')).toBeTruthy();
|
|
85
|
+
expect(queryByRole('tablist')).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('bodyHidden hides the lower panel but keeps the header', () => {
|
|
89
|
+
const { getByText, queryByText } = renderSidebar({
|
|
90
|
+
header: createElement('span', null, 'header'),
|
|
91
|
+
sidebarBody: createElement('span', null, 'body'),
|
|
92
|
+
bodyHidden: true,
|
|
93
|
+
});
|
|
94
|
+
expect(getByText('header')).toBeTruthy();
|
|
95
|
+
expect(queryByText('body')).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('controlled width applies to the column and reports drag changes', () => {
|
|
99
|
+
let reports = 0;
|
|
100
|
+
const { container, getByLabelText } = renderSidebar({
|
|
101
|
+
width: 400,
|
|
102
|
+
onWidthChange: () => {
|
|
103
|
+
reports++;
|
|
104
|
+
},
|
|
105
|
+
sidebarBody: createElement('span', null, 'panel'),
|
|
106
|
+
});
|
|
107
|
+
expect(sidebarColumn(container).style.width).toBe('400px');
|
|
108
|
+
const handle = getByLabelText('Resize sidebar');
|
|
109
|
+
fireEvent.mouseDown(handle, { clientX: 100 });
|
|
110
|
+
fireEvent.mouseMove(document, { clientX: 140 });
|
|
111
|
+
fireEvent.mouseUp(document);
|
|
112
|
+
// Controlled: the width only reports — the prop stays authoritative.
|
|
113
|
+
expect(sidebarColumn(container).style.width).toBe('400px');
|
|
114
|
+
expect(reports).toBeGreaterThan(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('an uncontrolled drag resizes the column', () => {
|
|
118
|
+
const { container, getByLabelText } = renderSidebar({
|
|
119
|
+
sidebarBody: createElement('span', null, 'panel'),
|
|
120
|
+
});
|
|
121
|
+
const handle = getByLabelText('Resize sidebar');
|
|
122
|
+
fireEvent.mouseDown(handle, { clientX: 100 });
|
|
123
|
+
fireEvent.mouseMove(document, { clientX: 180 });
|
|
124
|
+
fireEvent.mouseUp(document);
|
|
125
|
+
expect(sidebarColumn(container).style.width).toBe('400px');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a drag ends by persisting the width under persistKey', () => {
|
|
129
|
+
const { getByLabelText } = renderSidebar({
|
|
130
|
+
persistKey: 'gs-test',
|
|
131
|
+
sidebarBody: createElement('span', null, 'panel'),
|
|
132
|
+
});
|
|
133
|
+
const handle = getByLabelText('Resize sidebar');
|
|
134
|
+
fireEvent.mouseDown(handle, { clientX: 100 });
|
|
135
|
+
fireEvent.mouseMove(document, { clientX: 200 });
|
|
136
|
+
fireEvent.mouseUp(document);
|
|
137
|
+
expect(window.localStorage.getItem('principal.subsystems.sidebarWidth.gs-test')).toBe('420');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('reports drag state to the wrapper', () => {
|
|
141
|
+
const states: boolean[] = [];
|
|
142
|
+
const { getByLabelText } = renderSidebar({
|
|
143
|
+
onDraggingChange: (d: boolean) => states.push(d),
|
|
144
|
+
sidebarBody: createElement('span', null, 'panel'),
|
|
145
|
+
});
|
|
146
|
+
const handle = getByLabelText('Resize sidebar');
|
|
147
|
+
fireEvent.mouseDown(handle, { clientX: 100 });
|
|
148
|
+
fireEvent.mouseMove(document, { clientX: 140 });
|
|
149
|
+
fireEvent.mouseUp(document);
|
|
150
|
+
expect(states).toEqual([true, false]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
|
3
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GraphSidebar — the side panel of a graph layout, extracted from
|
|
7
|
+
* SubsystemComponentGraph so the panel itself is its own component. The host
|
|
8
|
+
* supplies everything it shows:
|
|
9
|
+
*
|
|
10
|
+
* - `header` — the upper block (title, actions, description…). Scrollable;
|
|
11
|
+
* takes the flexible share of the column when `stretchHeader` is set so a
|
|
12
|
+
* long description can unpack without squeezing the lower panel to death.
|
|
13
|
+
* - `tabs` + `activeTab`/`onTabChange` — an optional tab strip above the
|
|
14
|
+
* lower panel. Omit for a single-panel sidebar.
|
|
15
|
+
* - `sidebarBody` — the lower panel content (the active tab's body).
|
|
16
|
+
* - `footer` — pinned beneath the scrollable body.
|
|
17
|
+
*
|
|
18
|
+
* The panel is pure chrome — it owns nothing graph-specific (not the selection
|
|
19
|
+
* state, not the trails, not the file tree). That state stays in the graph,
|
|
20
|
+
* because the canvas dims from the same facts the sidebar renders; which is
|
|
21
|
+
* exactly why the chrome — and only the chrome — is what's shared.
|
|
22
|
+
*
|
|
23
|
+
* The right edge is the resize handle. Width is draggable, clamped to
|
|
24
|
+
* `minWidth`/`maxWidth`, and persisted to localStorage under
|
|
25
|
+
* `principal.subsystems.sidebarWidth.<persistKey>` when a `persistKey` is set
|
|
26
|
+
* (controlled widths instead hand every change to `onWidthChange`, and persist
|
|
27
|
+
* nothing).
|
|
28
|
+
*
|
|
29
|
+
* `onDraggingChange` reports drag state so a wrapper (see GraphWithSidebar)
|
|
30
|
+
* can lock its whole row against text selection during a resize.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface GraphSidebarTab {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GraphSidebarProps {
|
|
39
|
+
/** Upper block of the sidebar: title, actions, description — whatever the
|
|
40
|
+
* graph wants above the divider. Scrolls when the sidebar is short. */
|
|
41
|
+
header?: ReactNode;
|
|
42
|
+
/** Tab strip above the lower panel. When set, `sidebarBody` renders as the
|
|
43
|
+
* active tab's body. Omit for a single-panel sidebar. */
|
|
44
|
+
tabs?: ReadonlyArray<GraphSidebarTab>;
|
|
45
|
+
activeTab?: string;
|
|
46
|
+
onTabChange?: (id: string) => void;
|
|
47
|
+
/** Lower panel content — the active tab's body when `tabs` is set. */
|
|
48
|
+
sidebarBody?: ReactNode;
|
|
49
|
+
/** Pinned footer of the lower panel, beneath its scrollable body. */
|
|
50
|
+
footer?: ReactNode;
|
|
51
|
+
/** Hide the sidebar entirely (and the resize handle); nothing renders. */
|
|
52
|
+
hidden?: boolean;
|
|
53
|
+
/** Hide the lower panel so only the header shows. */
|
|
54
|
+
bodyHidden?: boolean;
|
|
55
|
+
/** Give the header the flexible share of the column, pinning the lower
|
|
56
|
+
* panel at half height (e.g. a description that unpacks on demand). */
|
|
57
|
+
stretchHeader?: boolean;
|
|
58
|
+
/** Controlled sidebar width (px). */
|
|
59
|
+
width?: number;
|
|
60
|
+
onWidthChange?: (width: number) => void;
|
|
61
|
+
/** Uncontrolled starting width; a stored width for `persistKey` wins. */
|
|
62
|
+
defaultWidth?: number;
|
|
63
|
+
minWidth?: number;
|
|
64
|
+
maxWidth?: number;
|
|
65
|
+
/** Fired when a drag-resize gesture ends (hosts re-fit their canvas here). */
|
|
66
|
+
onResizeEnd?: () => void;
|
|
67
|
+
/** localStorage key the sidebar width persists under. */
|
|
68
|
+
persistKey?: string;
|
|
69
|
+
/** Aria-label for the tab strip. */
|
|
70
|
+
tabListLabel?: string;
|
|
71
|
+
/** Reports drag state so a wrapper can lock its row against selection
|
|
72
|
+
* while the sidebar is being resized. */
|
|
73
|
+
onDraggingChange?: (dragging: boolean) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DEFAULT_MIN_WIDTH = 240;
|
|
77
|
+
const DEFAULT_MAX_WIDTH = 600;
|
|
78
|
+
const DEFAULT_WIDTH = 320;
|
|
79
|
+
const SIDEBAR_WIDTH_STORAGE_PREFIX = 'principal.subsystems.sidebarWidth.';
|
|
80
|
+
|
|
81
|
+
function readStoredWidth(persistKey: string | undefined): number | null {
|
|
82
|
+
if (!persistKey || typeof window === 'undefined') return null;
|
|
83
|
+
try {
|
|
84
|
+
const raw = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_PREFIX + persistKey);
|
|
85
|
+
if (!raw) return null;
|
|
86
|
+
const parsed = Number(raw);
|
|
87
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function GraphSidebar({
|
|
94
|
+
header,
|
|
95
|
+
tabs,
|
|
96
|
+
activeTab,
|
|
97
|
+
onTabChange,
|
|
98
|
+
sidebarBody,
|
|
99
|
+
footer,
|
|
100
|
+
hidden = false,
|
|
101
|
+
bodyHidden = false,
|
|
102
|
+
stretchHeader = false,
|
|
103
|
+
width,
|
|
104
|
+
onWidthChange,
|
|
105
|
+
defaultWidth = DEFAULT_WIDTH,
|
|
106
|
+
minWidth = DEFAULT_MIN_WIDTH,
|
|
107
|
+
maxWidth = DEFAULT_MAX_WIDTH,
|
|
108
|
+
onResizeEnd,
|
|
109
|
+
persistKey,
|
|
110
|
+
tabListLabel = 'Sidebar sections',
|
|
111
|
+
onDraggingChange,
|
|
112
|
+
}: GraphSidebarProps) {
|
|
113
|
+
const { theme } = useTheme();
|
|
114
|
+
const controlled = width !== undefined;
|
|
115
|
+
const hasTabs = (tabs?.length ?? 0) > 0;
|
|
116
|
+
const sidebarVisible = !hidden;
|
|
117
|
+
|
|
118
|
+
// Uncontrolled width: seeded from storage when a persistKey is set, else the
|
|
119
|
+
// default. A new persistKey (a different model) restarts the panel at that
|
|
120
|
+
// model's stored width.
|
|
121
|
+
const [intWidth, setIntWidth] = useState(() => readStoredWidth(persistKey) ?? defaultWidth);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (controlled) return;
|
|
124
|
+
setIntWidth(readStoredWidth(persistKey) ?? defaultWidth);
|
|
125
|
+
}, [persistKey, defaultWidth, controlled]);
|
|
126
|
+
|
|
127
|
+
const effectiveWidth = controlled ? (width as number) : intWidth;
|
|
128
|
+
|
|
129
|
+
// Drag-resize: capture the grab, track on the document, release on mouseup,
|
|
130
|
+
// persisting only then — not on every mousemove.
|
|
131
|
+
const [dragging, setDragging] = useState(false);
|
|
132
|
+
const dragStartX = useRef(0);
|
|
133
|
+
// Mirror of the current width for the mouseup handler (a closure over the
|
|
134
|
+
// drag-start render would persist the stale start width).
|
|
135
|
+
const currentWidthRef = useRef(effectiveWidth);
|
|
136
|
+
currentWidthRef.current = effectiveWidth;
|
|
137
|
+
|
|
138
|
+
const onResizeStart = useCallback(
|
|
139
|
+
(e: ReactMouseEvent) => {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
dragStartX.current = e.clientX;
|
|
142
|
+
currentWidthRef.current = effectiveWidth;
|
|
143
|
+
setDragging(true);
|
|
144
|
+
onDraggingChange?.(true);
|
|
145
|
+
},
|
|
146
|
+
[effectiveWidth, onDraggingChange],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!dragging) return;
|
|
151
|
+
const onMove = (e: MouseEvent) => {
|
|
152
|
+
const next = Math.min(
|
|
153
|
+
Math.max(currentWidthRef.current + (e.clientX - dragStartX.current), minWidth),
|
|
154
|
+
maxWidth,
|
|
155
|
+
);
|
|
156
|
+
if (controlled) {
|
|
157
|
+
onWidthChange?.(next);
|
|
158
|
+
} else {
|
|
159
|
+
setIntWidth(next);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const onUp = () => {
|
|
163
|
+
setDragging(false);
|
|
164
|
+
onDraggingChange?.(false);
|
|
165
|
+
if (persistKey && typeof window !== 'undefined') {
|
|
166
|
+
try {
|
|
167
|
+
window.localStorage.setItem(
|
|
168
|
+
SIDEBAR_WIDTH_STORAGE_PREFIX + persistKey,
|
|
169
|
+
String(currentWidthRef.current),
|
|
170
|
+
);
|
|
171
|
+
} catch {
|
|
172
|
+
// Storage unavailable — the width just isn't remembered.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
onResizeEnd?.();
|
|
176
|
+
};
|
|
177
|
+
document.addEventListener('mousemove', onMove);
|
|
178
|
+
document.addEventListener('mouseup', onUp);
|
|
179
|
+
return () => {
|
|
180
|
+
document.removeEventListener('mousemove', onMove);
|
|
181
|
+
document.removeEventListener('mouseup', onUp);
|
|
182
|
+
};
|
|
183
|
+
}, [dragging, minWidth, maxWidth, controlled, onWidthChange, onResizeEnd, persistKey, onDraggingChange]);
|
|
184
|
+
|
|
185
|
+
if (!sidebarVisible) return null;
|
|
186
|
+
|
|
187
|
+
const clampedWidth = Math.min(Math.max(effectiveWidth, minWidth), maxWidth);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
<div
|
|
192
|
+
style={{
|
|
193
|
+
width: clampedWidth,
|
|
194
|
+
minWidth: clampedWidth,
|
|
195
|
+
borderRight: `1px solid ${theme.colors.border}`,
|
|
196
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
197
|
+
display: 'flex',
|
|
198
|
+
flexDirection: 'column',
|
|
199
|
+
overflow: 'hidden',
|
|
200
|
+
userSelect: dragging ? 'none' : undefined,
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
{header != null && (
|
|
204
|
+
<div
|
|
205
|
+
style={{
|
|
206
|
+
flex: stretchHeader ? 1 : '0 0 auto',
|
|
207
|
+
minHeight: 0,
|
|
208
|
+
overflowY: 'auto',
|
|
209
|
+
padding: 16,
|
|
210
|
+
display: 'flex',
|
|
211
|
+
flexDirection: 'column',
|
|
212
|
+
gap: 12,
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
{header}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
{!bodyHidden && (hasTabs || sidebarBody != null || footer != null) && (
|
|
219
|
+
<div
|
|
220
|
+
style={{
|
|
221
|
+
...(stretchHeader ? { height: '50%' as const } : { flex: 1, minHeight: 0 }),
|
|
222
|
+
minHeight: 160,
|
|
223
|
+
flexShrink: 0,
|
|
224
|
+
borderTop: header != null ? `1px solid ${theme.colors.border}` : undefined,
|
|
225
|
+
display: 'flex',
|
|
226
|
+
flexDirection: 'column',
|
|
227
|
+
overflow: 'hidden',
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
{hasTabs && (
|
|
231
|
+
<div
|
|
232
|
+
role="tablist"
|
|
233
|
+
aria-label={tabListLabel}
|
|
234
|
+
style={{
|
|
235
|
+
display: 'flex',
|
|
236
|
+
width: '100%',
|
|
237
|
+
flexShrink: 0,
|
|
238
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
239
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
{tabs!.map((tab) => (
|
|
243
|
+
<button
|
|
244
|
+
key={tab.id}
|
|
245
|
+
type="button"
|
|
246
|
+
role="tab"
|
|
247
|
+
aria-selected={activeTab === tab.id}
|
|
248
|
+
onClick={() => onTabChange?.(tab.id)}
|
|
249
|
+
style={{
|
|
250
|
+
flex: 1,
|
|
251
|
+
minWidth: 0,
|
|
252
|
+
padding: '8px 8px',
|
|
253
|
+
border: 'none',
|
|
254
|
+
borderRadius: 0,
|
|
255
|
+
background: activeTab === tab.id ? theme.colors.background : 'transparent',
|
|
256
|
+
color:
|
|
257
|
+
activeTab === tab.id ? theme.colors.text : theme.colors.textSecondary,
|
|
258
|
+
fontSize: theme.fontSizes[1],
|
|
259
|
+
fontFamily: theme.fonts.monospace,
|
|
260
|
+
textTransform: 'capitalize',
|
|
261
|
+
cursor: 'pointer',
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{tab.label}
|
|
265
|
+
</button>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
<div
|
|
270
|
+
style={{
|
|
271
|
+
flex: 1,
|
|
272
|
+
minHeight: 0,
|
|
273
|
+
display: 'flex',
|
|
274
|
+
flexDirection: 'column',
|
|
275
|
+
overflow: 'hidden',
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
{sidebarBody}
|
|
279
|
+
</div>
|
|
280
|
+
{footer}
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div
|
|
286
|
+
onMouseDown={onResizeStart}
|
|
287
|
+
aria-label="Resize sidebar"
|
|
288
|
+
title="Drag to resize"
|
|
289
|
+
style={{
|
|
290
|
+
width: 3,
|
|
291
|
+
flexShrink: 0,
|
|
292
|
+
cursor: 'col-resize',
|
|
293
|
+
background: theme.colors.border,
|
|
294
|
+
transition: 'background 120ms ease',
|
|
295
|
+
zIndex: 1,
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
298
|
+
</>
|
|
299
|
+
);
|
|
300
|
+
}
|