@principal-ai/subsystems-react 0.24.23 → 0.24.25
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/ConstructsCatalog.d.ts.map +1 -1
- package/dist/subsystem/ConstructsCatalog.js +187 -17
- package/dist/subsystem/ConstructsCatalog.js.map +1 -1
- package/dist/subsystem/model.d.ts +3 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +18 -0
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +21 -2
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +1 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +5 -1
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/subsystem/ConstructsCatalog.tsx +287 -15
- package/src/subsystem/model.test.ts +2 -0
- package/src/subsystem/model.ts +22 -0
- package/src/subsystem/nodes.test.ts +6 -2
- package/src/subsystem/nodes.tsx +28 -0
- package/src/utils/elkLayout.ts +6 -2
|
@@ -11,7 +11,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
11
11
|
import type { ReactNode } from 'react';
|
|
12
12
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
13
13
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
14
|
-
import { ChevronDown, ChevronUp } from 'lucide-react';
|
|
14
|
+
import { ChevronDown, ChevronRight, ChevronUp, FileText, Folder } from 'lucide-react';
|
|
15
15
|
import {
|
|
16
16
|
constructBadgeColor,
|
|
17
17
|
constructBadgeLabel,
|
|
@@ -55,6 +55,55 @@ const FileDrawerContent = memo(function FileDrawerContent({
|
|
|
55
55
|
return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
interface FileTreeNode {
|
|
59
|
+
name: string;
|
|
60
|
+
/** Full path for files; path prefix for folders. */
|
|
61
|
+
path: string;
|
|
62
|
+
isFile: boolean;
|
|
63
|
+
children: FileTreeNode[];
|
|
64
|
+
/** Components declared in this file (file nodes only). */
|
|
65
|
+
components: SubsystemComponent[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build a folder/file tree from component `file` paths. */
|
|
69
|
+
function buildFileTree(components: SubsystemComponent[]): FileTreeNode {
|
|
70
|
+
const root: FileTreeNode = {
|
|
71
|
+
name: '',
|
|
72
|
+
path: '',
|
|
73
|
+
isFile: false,
|
|
74
|
+
children: [],
|
|
75
|
+
components: [],
|
|
76
|
+
};
|
|
77
|
+
for (const c of components) {
|
|
78
|
+
if (!c.file) continue;
|
|
79
|
+
const parts = c.file.split('/').filter(Boolean);
|
|
80
|
+
let node = root;
|
|
81
|
+
let prefix = '';
|
|
82
|
+
parts.forEach((name, i) => {
|
|
83
|
+
const isFile = i === parts.length - 1;
|
|
84
|
+
prefix = prefix ? `${prefix}/${name}` : name;
|
|
85
|
+
let child = node.children.find(
|
|
86
|
+
(x) => x.name === name && x.isFile === isFile,
|
|
87
|
+
);
|
|
88
|
+
if (!child) {
|
|
89
|
+
child = { name, path: prefix, isFile, children: [], components: [] };
|
|
90
|
+
node.children.push(child);
|
|
91
|
+
}
|
|
92
|
+
node = child;
|
|
93
|
+
});
|
|
94
|
+
node.components.push(c);
|
|
95
|
+
}
|
|
96
|
+
const sortNode = (n: FileTreeNode) => {
|
|
97
|
+
n.children.sort((a, b) => {
|
|
98
|
+
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
|
99
|
+
return a.name.localeCompare(b.name);
|
|
100
|
+
});
|
|
101
|
+
n.children.forEach(sortNode);
|
|
102
|
+
};
|
|
103
|
+
sortNode(root);
|
|
104
|
+
return root;
|
|
105
|
+
}
|
|
106
|
+
|
|
58
107
|
function resolveRelated(
|
|
59
108
|
components: SubsystemComponent[],
|
|
60
109
|
ref: string,
|
|
@@ -69,9 +118,7 @@ function resolveRelated(
|
|
|
69
118
|
);
|
|
70
119
|
}
|
|
71
120
|
|
|
72
|
-
const GH_PURL = /^pkg:github\/([^/]+)\/([^/#?]+)
|
|
73
|
-
|
|
74
|
-
/** Repo/app label for a purl group header. */
|
|
121
|
+
const GH_PURL = /^pkg:github\/([^/]+)\/([^/#?]+)/;/** Repo/app label for a purl group header. */
|
|
75
122
|
function repoLabel(c: SubsystemComponent): string {
|
|
76
123
|
const m = GH_PURL.exec(c.purl ?? '');
|
|
77
124
|
if (m) return m[2]!;
|
|
@@ -131,6 +178,11 @@ export function ConstructsCatalog({
|
|
|
131
178
|
);
|
|
132
179
|
const [descriptionVisible, setDescriptionVisible] = useState(false);
|
|
133
180
|
const [descToggleHover, setDescToggleHover] = useState(false);
|
|
181
|
+
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
|
182
|
+
const [sidebarTab, setSidebarTab] = useState<'constructs' | 'files'>('constructs');
|
|
183
|
+
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
|
|
184
|
+
new Set(),
|
|
185
|
+
);
|
|
134
186
|
const [drawer, setDrawer] = useState<{ file: string; startLine?: number } | null>(
|
|
135
187
|
null,
|
|
136
188
|
);
|
|
@@ -143,6 +195,12 @@ export function ConstructsCatalog({
|
|
|
143
195
|
[orderedComponents, visibleSet],
|
|
144
196
|
);
|
|
145
197
|
|
|
198
|
+
const fileTree = useMemo(() => buildFileTree(orderedComponents), [orderedComponents]);
|
|
199
|
+
const hasFiles = useMemo(
|
|
200
|
+
() => orderedComponents.some((c) => !!c.file),
|
|
201
|
+
[orderedComponents],
|
|
202
|
+
);
|
|
203
|
+
|
|
146
204
|
// Group visible declarations by purl so the repo/app identity (logo + name)
|
|
147
205
|
// renders once per group instead of on every card.
|
|
148
206
|
const repoGroups = useMemo(() => {
|
|
@@ -235,6 +293,169 @@ export function ConstructsCatalog({
|
|
|
235
293
|
[],
|
|
236
294
|
);
|
|
237
295
|
|
|
296
|
+
const toggleFolder = useCallback((path: string) => {
|
|
297
|
+
setCollapsedFolders((prev) => {
|
|
298
|
+
const next = new Set(prev);
|
|
299
|
+
if (next.has(path)) next.delete(path);
|
|
300
|
+
else next.add(path);
|
|
301
|
+
return next;
|
|
302
|
+
});
|
|
303
|
+
}, []);
|
|
304
|
+
|
|
305
|
+
/** File-tree click: show every construct declared in the file, or hide them
|
|
306
|
+
* all when they're already shown — mirroring a construct row toggle. */
|
|
307
|
+
const toggleFile = useCallback(
|
|
308
|
+
(comps: SubsystemComponent[]) => {
|
|
309
|
+
const ids = comps.map((c) => c.id);
|
|
310
|
+
setVisibleIds((prev) => {
|
|
311
|
+
const set = new Set(prev);
|
|
312
|
+
const allVisible = ids.every((id) => set.has(id));
|
|
313
|
+
ids.forEach((id) => (allVisible ? set.delete(id) : set.add(id)));
|
|
314
|
+
return orderedComponents.filter((c) => set.has(c.id)).map((c) => c.id);
|
|
315
|
+
});
|
|
316
|
+
const first = comps[0];
|
|
317
|
+
if (first) {
|
|
318
|
+
setFocusedId(first.id);
|
|
319
|
+
onSelect?.(first.id);
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
[orderedComponents, onSelect],
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const renderTree = useCallback(
|
|
326
|
+
(node: FileTreeNode, depth: number): ReactNode => {
|
|
327
|
+
return node.children.map((child) => {
|
|
328
|
+
if (!child.isFile) {
|
|
329
|
+
const collapsed = collapsedFolders.has(child.path);
|
|
330
|
+
const hovered = hoveredRow === child.path;
|
|
331
|
+
return (
|
|
332
|
+
<li key={child.path} role="presentation">
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
onClick={() => toggleFolder(child.path)}
|
|
336
|
+
onMouseEnter={() => setHoveredRow(child.path)}
|
|
337
|
+
onMouseLeave={() =>
|
|
338
|
+
setHoveredRow((h) => (h === child.path ? null : h))
|
|
339
|
+
}
|
|
340
|
+
style={{
|
|
341
|
+
display: 'flex',
|
|
342
|
+
alignItems: 'center',
|
|
343
|
+
gap: 4,
|
|
344
|
+
width: '100%',
|
|
345
|
+
padding: '4px 8px',
|
|
346
|
+
paddingLeft: 8 + depth * 12,
|
|
347
|
+
border: 'none',
|
|
348
|
+
borderRadius: 6,
|
|
349
|
+
background: hovered ? theme.colors.border : 'transparent',
|
|
350
|
+
color:
|
|
351
|
+
hovered
|
|
352
|
+
? theme.colors.text
|
|
353
|
+
: theme.colors.textSecondary ?? muted,
|
|
354
|
+
fontFamily: theme.fonts.monospace,
|
|
355
|
+
fontSize: theme.fontSizes[0],
|
|
356
|
+
textAlign: 'left',
|
|
357
|
+
cursor: 'pointer',
|
|
358
|
+
transition: 'background 100ms ease',
|
|
359
|
+
}}
|
|
360
|
+
>
|
|
361
|
+
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
362
|
+
<Folder size={12} />
|
|
363
|
+
<span
|
|
364
|
+
style={{
|
|
365
|
+
minWidth: 0,
|
|
366
|
+
overflow: 'hidden',
|
|
367
|
+
textOverflow: 'ellipsis',
|
|
368
|
+
whiteSpace: 'nowrap',
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
{child.name}
|
|
372
|
+
</span>
|
|
373
|
+
</button>
|
|
374
|
+
{!collapsed && (
|
|
375
|
+
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
376
|
+
{renderTree(child, depth + 1)}
|
|
377
|
+
</ul>
|
|
378
|
+
)}
|
|
379
|
+
</li>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const ids = child.components.map((c) => c.id);
|
|
383
|
+
const allVisible = ids.length > 0 && ids.every((id) => visibleSet.has(id));
|
|
384
|
+
const hovered = hoveredRow === child.path;
|
|
385
|
+
const color = componentColor(child.components[0]!, pierreTheme);
|
|
386
|
+
return (
|
|
387
|
+
<li key={child.path} role="presentation">
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
data-testid={`file-row-${child.path}`}
|
|
391
|
+
aria-pressed={allVisible}
|
|
392
|
+
onClick={() => toggleFile(child.components)}
|
|
393
|
+
onMouseEnter={() => setHoveredRow(child.path)}
|
|
394
|
+
onMouseLeave={() =>
|
|
395
|
+
setHoveredRow((h) => (h === child.path ? null : h))
|
|
396
|
+
}
|
|
397
|
+
style={{
|
|
398
|
+
display: 'flex',
|
|
399
|
+
alignItems: 'center',
|
|
400
|
+
gap: 6,
|
|
401
|
+
width: '100%',
|
|
402
|
+
padding: '4px 8px',
|
|
403
|
+
paddingLeft: 8 + depth * 12 + 16,
|
|
404
|
+
border: 'none',
|
|
405
|
+
borderRadius: 6,
|
|
406
|
+
background: hovered ? theme.colors.border : 'transparent',
|
|
407
|
+
color:
|
|
408
|
+
allVisible
|
|
409
|
+
? color
|
|
410
|
+
: hovered
|
|
411
|
+
? theme.colors.text
|
|
412
|
+
: theme.colors.textSecondary ?? muted,
|
|
413
|
+
fontFamily: theme.fonts.monospace,
|
|
414
|
+
fontSize: theme.fontSizes[0],
|
|
415
|
+
textAlign: 'left',
|
|
416
|
+
cursor: 'pointer',
|
|
417
|
+
transition: 'background 100ms ease',
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
<FileText size={12} style={{ flexShrink: 0 }} />
|
|
421
|
+
<span
|
|
422
|
+
style={{
|
|
423
|
+
flex: 1,
|
|
424
|
+
minWidth: 0,
|
|
425
|
+
overflow: 'hidden',
|
|
426
|
+
textOverflow: 'ellipsis',
|
|
427
|
+
whiteSpace: 'nowrap',
|
|
428
|
+
fontWeight: allVisible ? 600 : 400,
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
{child.name}
|
|
432
|
+
</span>
|
|
433
|
+
<span
|
|
434
|
+
style={{
|
|
435
|
+
flexShrink: 0,
|
|
436
|
+
fontSize: theme.fontSizes[0] * 0.85,
|
|
437
|
+
color: allVisible ? color : muted,
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{ids.length}
|
|
441
|
+
</span>
|
|
442
|
+
</button>
|
|
443
|
+
</li>
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
[
|
|
448
|
+
collapsedFolders,
|
|
449
|
+
hoveredRow,
|
|
450
|
+
visibleSet,
|
|
451
|
+
toggleFolder,
|
|
452
|
+
toggleFile,
|
|
453
|
+
theme,
|
|
454
|
+
muted,
|
|
455
|
+
pierreTheme,
|
|
456
|
+
],
|
|
457
|
+
);
|
|
458
|
+
|
|
238
459
|
const onOpenFile = useCallback(
|
|
239
460
|
(file: string, opts?: SubsystemOpenFileOptions) => {
|
|
240
461
|
const startLine = opts?.startLine;
|
|
@@ -458,18 +679,45 @@ export function ConstructsCatalog({
|
|
|
458
679
|
</div>
|
|
459
680
|
)}
|
|
460
681
|
<div
|
|
682
|
+
role="tablist"
|
|
683
|
+
aria-label="Sidebar view"
|
|
461
684
|
style={{
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
letterSpacing: 0.4,
|
|
466
|
-
textTransform: 'uppercase',
|
|
467
|
-
color: muted,
|
|
685
|
+
display: 'flex',
|
|
686
|
+
gap: 4,
|
|
687
|
+
padding: '8px 12px 6px',
|
|
468
688
|
flexShrink: 0,
|
|
689
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
469
690
|
}}
|
|
470
691
|
>
|
|
471
|
-
|
|
692
|
+
{(['constructs', 'files'] as const).map((tab) => {
|
|
693
|
+
if (tab === 'files' && !hasFiles) return null;
|
|
694
|
+
const active = sidebarTab === tab;
|
|
695
|
+
return (
|
|
696
|
+
<button
|
|
697
|
+
key={tab}
|
|
698
|
+
type="button"
|
|
699
|
+
role="tab"
|
|
700
|
+
aria-selected={active}
|
|
701
|
+
onClick={() => setSidebarTab(tab)}
|
|
702
|
+
style={{
|
|
703
|
+
padding: '3px 10px',
|
|
704
|
+
border: 'none',
|
|
705
|
+
borderRadius: 6,
|
|
706
|
+
background: active ? theme.colors.border : 'transparent',
|
|
707
|
+
color: active ? theme.colors.text : muted,
|
|
708
|
+
fontSize: theme.fontSizes[0],
|
|
709
|
+
fontFamily: theme.fonts.monospace,
|
|
710
|
+
letterSpacing: 0.4,
|
|
711
|
+
textTransform: 'uppercase',
|
|
712
|
+
cursor: 'pointer',
|
|
713
|
+
}}
|
|
714
|
+
>
|
|
715
|
+
{tab === 'constructs' ? 'Constructs' : 'Files'}
|
|
716
|
+
</button>
|
|
717
|
+
);
|
|
718
|
+
})}
|
|
472
719
|
</div>
|
|
720
|
+
{sidebarTab === 'constructs' ? (
|
|
473
721
|
<ul
|
|
474
722
|
role="listbox"
|
|
475
723
|
aria-label="Constructs"
|
|
@@ -503,6 +751,7 @@ export function ConstructsCatalog({
|
|
|
503
751
|
!!c.file &&
|
|
504
752
|
c.file === drawer.file &&
|
|
505
753
|
c.declarationRef?.startLine === drawer.startLine;
|
|
754
|
+
const hovered = hoveredRow === c.id;
|
|
506
755
|
return (
|
|
507
756
|
<li key={c.id} role="presentation">
|
|
508
757
|
<button
|
|
@@ -512,6 +761,10 @@ export function ConstructsCatalog({
|
|
|
512
761
|
aria-selected={visible}
|
|
513
762
|
data-testid={`construct-row-${c.id}`}
|
|
514
763
|
onClick={() => toggle(c.id)}
|
|
764
|
+
onMouseEnter={() => setHoveredRow(c.id)}
|
|
765
|
+
onMouseLeave={() =>
|
|
766
|
+
setHoveredRow((h) => (h === c.id ? null : h))
|
|
767
|
+
}
|
|
515
768
|
style={{
|
|
516
769
|
display: 'flex',
|
|
517
770
|
flexDirection: 'column',
|
|
@@ -523,10 +776,13 @@ export function ConstructsCatalog({
|
|
|
523
776
|
textAlign: 'left',
|
|
524
777
|
border: 'none',
|
|
525
778
|
borderRadius: 6,
|
|
526
|
-
background:
|
|
527
|
-
? theme.colors.
|
|
528
|
-
:
|
|
779
|
+
background: hovered
|
|
780
|
+
? theme.colors.border
|
|
781
|
+
: visible
|
|
782
|
+
? theme.colors.background
|
|
783
|
+
: 'transparent',
|
|
529
784
|
cursor: 'pointer',
|
|
785
|
+
transition: 'background 100ms ease, opacity 100ms ease',
|
|
530
786
|
boxShadow: [
|
|
531
787
|
visible ? `inset 0 0 0 1px ${theme.colors.border}` : null,
|
|
532
788
|
rowOpen ? `inset 2px 0 0 ${accentColor}` : null,
|
|
@@ -535,7 +791,7 @@ export function ConstructsCatalog({
|
|
|
535
791
|
.join(', ') || undefined,
|
|
536
792
|
outline: focused && !visible ? `1px dotted ${muted}` : undefined,
|
|
537
793
|
outlineOffset: -1,
|
|
538
|
-
opacity: visible ? 1 : 0.72,
|
|
794
|
+
opacity: visible ? 1 : hovered ? 1 : 0.72,
|
|
539
795
|
}}
|
|
540
796
|
>
|
|
541
797
|
<span
|
|
@@ -593,6 +849,22 @@ export function ConstructsCatalog({
|
|
|
593
849
|
);
|
|
594
850
|
})}
|
|
595
851
|
</ul>
|
|
852
|
+
) : (
|
|
853
|
+
<ul
|
|
854
|
+
role="tree"
|
|
855
|
+
aria-label="Files"
|
|
856
|
+
style={{
|
|
857
|
+
listStyle: 'none',
|
|
858
|
+
margin: 0,
|
|
859
|
+
padding: '6px 8px 12px',
|
|
860
|
+
overflowY: 'auto',
|
|
861
|
+
flex: 1,
|
|
862
|
+
minHeight: 0,
|
|
863
|
+
}}
|
|
864
|
+
>
|
|
865
|
+
{renderTree(fileTree, 0)}
|
|
866
|
+
</ul>
|
|
867
|
+
)}
|
|
596
868
|
</aside>
|
|
597
869
|
<div
|
|
598
870
|
style={{
|
package/src/subsystem/model.ts
CHANGED
|
@@ -502,6 +502,27 @@ export function moduleGroupNodeId(moduleKey: string): string {
|
|
|
502
502
|
return `module:${moduleKey}`;
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
export const MODULE_BADGE_INSET = 12;
|
|
506
|
+
const MODULE_BADGE_CHAR_WIDTH = 12.5;
|
|
507
|
+
const MODULE_BADGE_CHROME = 16;
|
|
508
|
+
const MODULE_FRAME_BORDER = 4;
|
|
509
|
+
|
|
510
|
+
export function moduleBadgeLabel(path: string, availableWidth = 0): string {
|
|
511
|
+
if (path.length * MODULE_BADGE_CHAR_WIDTH + MODULE_BADGE_CHROME <= availableWidth) {
|
|
512
|
+
return path;
|
|
513
|
+
}
|
|
514
|
+
const parts = path.split(/[\\/]/).filter((part) => part !== '' && part !== '.');
|
|
515
|
+
if (parts.length <= 2) return path;
|
|
516
|
+
return `${parts[0]}/…/${parts[parts.length - 1]}`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export function moduleMinWidthForBadge(path: string): number {
|
|
520
|
+
return Math.ceil(
|
|
521
|
+
moduleBadgeLabel(path).length * MODULE_BADGE_CHAR_WIDTH +
|
|
522
|
+
MODULE_BADGE_CHROME + MODULE_BADGE_INSET * 2 + MODULE_FRAME_BORDER,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
505
526
|
/** React Flow id for a package (repo) boundary group node. */
|
|
506
527
|
export function packageGroupNodeId(packageKey: string): string {
|
|
507
528
|
return `package:${packageKey}`;
|
|
@@ -1363,6 +1384,7 @@ export async function buildSubsystemGraph(
|
|
|
1363
1384
|
id: g.id,
|
|
1364
1385
|
memberIds: g.memberIds,
|
|
1365
1386
|
parentId: g.parentId,
|
|
1387
|
+
minWidth: g.region.kind === 'module' ? moduleMinWidthForBadge(g.region.label) : undefined,
|
|
1366
1388
|
})),
|
|
1367
1389
|
});
|
|
1368
1390
|
const builtGroupIds = new Set(result.groupBounds.keys());
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { createElement, type ComponentProps } from 'react';
|
|
3
|
+
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
|
|
4
|
+
import { ThemeProvider } from '@principal-ade/industry-theme';
|
|
5
|
+
import { Window } from 'happy-dom';
|
|
6
|
+
import { EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility, hexWithAlpha, SubsystemGroupNode } from './nodes';
|
|
3
7
|
|
|
4
8
|
describe('hexWithAlpha', () => {
|
|
5
9
|
test('appends a two-digit alpha to #rrggbb', () => {
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
deriveNameFromSymbol,
|
|
30
30
|
BADGE_EDGE_INSET,
|
|
31
31
|
nodeMinWidthForBadges,
|
|
32
|
+
MODULE_BADGE_INSET,
|
|
33
|
+
moduleBadgeLabel,
|
|
32
34
|
packageColor,
|
|
33
35
|
type SubsystemGraphNodeData,
|
|
34
36
|
type SubsystemGroupNodeData,
|
|
@@ -352,6 +354,12 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
352
354
|
// Kind is carried by frame chrome (square = process; rounded = module/package)
|
|
353
355
|
// — don't prefix `module ·` / `package ·` or the path reads twice.
|
|
354
356
|
const label = region?.label ?? '';
|
|
357
|
+
const [expandedLabel, setExpandedLabel] = useState<string | null>(null);
|
|
358
|
+
const isModule = region?.kind === 'module';
|
|
359
|
+
const expanded = isModule && expandedLabel === label;
|
|
360
|
+
const availableBadgeWidth = Math.max(0, (width ?? 400) - MODULE_BADGE_INSET * 2 - 4);
|
|
361
|
+
const collapsedLabel = isModule ? moduleBadgeLabel(label, availableBadgeWidth) : label;
|
|
362
|
+
const canExpand = isModule && collapsedLabel !== label;
|
|
355
363
|
// Process frames match external nodes — square corners (outside the soft
|
|
356
364
|
// component language). Module / package frames keep the rounded look.
|
|
357
365
|
const isProcess = region?.kind === 'process';
|
|
@@ -382,6 +390,26 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
382
390
|
}}
|
|
383
391
|
>
|
|
384
392
|
<div
|
|
393
|
+
className={isModule ? 'nodrag nopan' : undefined}
|
|
394
|
+
role={canExpand ? 'button' : undefined}
|
|
395
|
+
tabIndex={canExpand && !hidden ? 0 : undefined}
|
|
396
|
+
aria-expanded={canExpand ? expanded : undefined}
|
|
397
|
+
aria-label={canExpand ? `${expanded ? 'Collapse' : 'Expand'} module path: ${label}` : undefined}
|
|
398
|
+
title={isModule ? label : undefined}
|
|
399
|
+
onClick={canExpand ? (event) => {
|
|
400
|
+
event.stopPropagation();
|
|
401
|
+
setExpandedLabel(expanded ? null : label);
|
|
402
|
+
} : undefined}
|
|
403
|
+
onKeyDown={canExpand ? (event) => {
|
|
404
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
405
|
+
event.preventDefault();
|
|
406
|
+
event.stopPropagation();
|
|
407
|
+
setExpandedLabel(expanded ? null : label);
|
|
408
|
+
} else if (event.key === 'Escape' && expanded) {
|
|
409
|
+
event.stopPropagation();
|
|
410
|
+
setExpandedLabel(null);
|
|
411
|
+
}
|
|
412
|
+
} : undefined}
|
|
385
413
|
style={{
|
|
386
414
|
position: 'absolute',
|
|
387
415
|
// Process: badge fill starts with the process fill (inside the
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -79,7 +79,7 @@ export interface ElkLayoutOptions {
|
|
|
79
79
|
* nests this group under another group; omit for a root-level frame.
|
|
80
80
|
* Members reference their immediate parent via React Flow `parentId`.
|
|
81
81
|
*/
|
|
82
|
-
groups?: Array<{ id: string; memberIds: string[]; parentId?: string }>;
|
|
82
|
+
groups?: Array<{ id: string; memberIds: string[]; parentId?: string; minWidth?: number }>;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/** Result of ELK layout computation */
|
|
@@ -587,7 +587,11 @@ export async function computeElkLayout(
|
|
|
587
587
|
builtGroups.set(g.id, {
|
|
588
588
|
id: g.id,
|
|
589
589
|
children: childNodes,
|
|
590
|
-
layoutOptions: compoundLayoutOptions
|
|
590
|
+
layoutOptions: g.minWidth == null ? compoundLayoutOptions : {
|
|
591
|
+
...compoundLayoutOptions,
|
|
592
|
+
'elk.nodeSize.constraints': 'MINIMUM_SIZE',
|
|
593
|
+
'elk.nodeSize.minimum': `(${g.minWidth},0)`,
|
|
594
|
+
},
|
|
591
595
|
});
|
|
592
596
|
}
|
|
593
597
|
if (!progress) {
|