@eventcatalog/core 2.22.0 → 2.23.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/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-6HZQETSS.js → chunk-CLNIEHVG.js} +1 -1
- package/dist/{chunk-O3MRQUYC.js → chunk-F55C3RGO.js} +1 -1
- package/dist/{chunk-NQSIMY3C.js → chunk-WNRZ5O5C.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.config.d.cts +4 -0
- package/dist/eventcatalog.config.d.ts +4 -0
- package/dist/eventcatalog.js +3 -3
- package/eventcatalog/src/components/SideNav/CatalogResourcesSideBar/getCatalogResources.ts +65 -0
- package/eventcatalog/src/components/{SideBars → SideNav}/CatalogResourcesSideBar/index.tsx +1 -1
- package/eventcatalog/src/components/SideNav/SideNav.astro +31 -0
- package/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts +189 -0
- package/eventcatalog/src/components/SideNav/TreeView/index.tsx +94 -0
- package/eventcatalog/src/components/TreeView/index.tsx +328 -0
- package/eventcatalog/src/components/TreeView/styles.module.css +264 -0
- package/eventcatalog/src/components/TreeView/useSlots.ts +95 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +5 -51
- package/package.json +1 -1
- /package/eventcatalog/src/components/{SideBars → SideNav}/CatalogResourcesSideBar/styles.css +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
log_build_default
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
5
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-F55C3RGO.js";
|
|
4
|
+
import "../chunk-CLNIEHVG.js";
|
|
5
|
+
import "../chunk-WNRZ5O5C.js";
|
|
6
6
|
import "../chunk-E7TXTI7G.js";
|
|
7
7
|
export {
|
|
8
8
|
log_build_default as default
|
package/dist/constants.cjs
CHANGED
package/dist/constants.js
CHANGED
package/dist/eventcatalog.cjs
CHANGED
package/dist/eventcatalog.js
CHANGED
|
@@ -6,14 +6,14 @@ import {
|
|
|
6
6
|
} from "./chunk-OW2FQPYP.js";
|
|
7
7
|
import {
|
|
8
8
|
log_build_default
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import "./chunk-
|
|
9
|
+
} from "./chunk-F55C3RGO.js";
|
|
10
|
+
import "./chunk-CLNIEHVG.js";
|
|
11
11
|
import {
|
|
12
12
|
catalogToAstro
|
|
13
13
|
} from "./chunk-CXKIF3EI.js";
|
|
14
14
|
import {
|
|
15
15
|
VERSION
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-WNRZ5O5C.js";
|
|
17
17
|
import {
|
|
18
18
|
isBackstagePluginEnabled
|
|
19
19
|
} from "./chunk-XMDPVKIJ.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isCollectionVisibleInCatalog } from '@eventcatalog';
|
|
2
|
+
import { buildUrl } from '@utils/url-builder';
|
|
3
|
+
import { getChannels } from '@utils/channels';
|
|
4
|
+
import { getDomains } from '@utils/collections/domains';
|
|
5
|
+
import { getFlows } from '@utils/collections/flows';
|
|
6
|
+
import { getServices } from '@utils/collections/services';
|
|
7
|
+
import { getCommands } from '@utils/commands';
|
|
8
|
+
import { getEvents } from '@utils/events';
|
|
9
|
+
import { getQueries } from '@utils/queries';
|
|
10
|
+
import { getTeams } from '@utils/teams';
|
|
11
|
+
import { getUsers } from '@utils/users';
|
|
12
|
+
|
|
13
|
+
export async function getCatalogResources({ currentPath }: { currentPath: string }) {
|
|
14
|
+
const events = await getEvents({ getAllVersions: false });
|
|
15
|
+
const commands = await getCommands({ getAllVersions: false });
|
|
16
|
+
const queries = await getQueries({ getAllVersions: false });
|
|
17
|
+
const services = await getServices({ getAllVersions: false });
|
|
18
|
+
const domains = await getDomains({ getAllVersions: false });
|
|
19
|
+
const channels = await getChannels({ getAllVersions: false });
|
|
20
|
+
const flows = await getFlows({ getAllVersions: false });
|
|
21
|
+
|
|
22
|
+
const messages = [...events, ...commands, ...queries];
|
|
23
|
+
|
|
24
|
+
// @ts-ignore for large catalogs https://github.com/event-catalog/eventcatalog/issues/552
|
|
25
|
+
const allData = [...domains, ...services, ...messages, ...channels, ...flows];
|
|
26
|
+
|
|
27
|
+
const allDataAsSideNav = allData.reduce((acc, item) => {
|
|
28
|
+
const title = item.collection;
|
|
29
|
+
const group = acc[title] || [];
|
|
30
|
+
const route = currentPath.includes('visualiser') ? 'visualiser' : 'docs';
|
|
31
|
+
|
|
32
|
+
const navigationItem = {
|
|
33
|
+
label: item.data.name,
|
|
34
|
+
version: item.data.version,
|
|
35
|
+
// items: item.collection === 'users' ? [] : item.headings,
|
|
36
|
+
visible: isCollectionVisibleInCatalog(item.collection),
|
|
37
|
+
// @ts-ignore
|
|
38
|
+
href: item.data.version
|
|
39
|
+
? // @ts-ignore
|
|
40
|
+
buildUrl(`/${route}/${item.collection}/${item.data.id}/${item.data.version}`)
|
|
41
|
+
: buildUrl(`/${route}/${item.collection}/${item.data.id}`),
|
|
42
|
+
collection: item.collection,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
group.push(navigationItem);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
...acc,
|
|
49
|
+
[title]: group,
|
|
50
|
+
};
|
|
51
|
+
}, {} as any);
|
|
52
|
+
|
|
53
|
+
const sideNav = {
|
|
54
|
+
...(currentPath.includes('visualiser')
|
|
55
|
+
? {
|
|
56
|
+
'bounded context map': [
|
|
57
|
+
{ label: 'Domain map', href: buildUrl('/visualiser/context-map'), collection: 'bounded-context-map' },
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
: {}),
|
|
61
|
+
...allDataAsSideNav,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return sideNav;
|
|
65
|
+
}
|
|
@@ -75,7 +75,7 @@ const CatalogResourcesSideBar: React.FC<CatalogResourcesSideBarProps> = ({ resou
|
|
|
75
75
|
if (!isInitialized) return null;
|
|
76
76
|
|
|
77
77
|
return (
|
|
78
|
-
<nav className="space-y-6 text-black ">
|
|
78
|
+
<nav className="space-y-6 text-black px-5 py-4 ">
|
|
79
79
|
<div className="space-y-2">
|
|
80
80
|
<div className="mb-4 px-1">
|
|
81
81
|
<input
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import config from '@config';
|
|
4
|
+
|
|
5
|
+
// FlatView
|
|
6
|
+
import CatalogResourcesSideBar from './CatalogResourcesSideBar';
|
|
7
|
+
import { getCatalogResources } from './CatalogResourcesSideBar/getCatalogResources';
|
|
8
|
+
|
|
9
|
+
// TreeView
|
|
10
|
+
import { SideNavTreeView } from './TreeView';
|
|
11
|
+
import { getTreeView } from './TreeView/getTreeView';
|
|
12
|
+
|
|
13
|
+
interface Props extends Omit<HTMLAttributes<'div'>, 'children'> {}
|
|
14
|
+
|
|
15
|
+
const currentPath = Astro.url.pathname;
|
|
16
|
+
|
|
17
|
+
let props;
|
|
18
|
+
|
|
19
|
+
const SIDENAV_TYPE = config?.docs?.sidebar?.type ?? 'FLAT_VIEW';
|
|
20
|
+
|
|
21
|
+
if (SIDENAV_TYPE === 'FLAT_VIEW') {
|
|
22
|
+
props = await getCatalogResources({ currentPath });
|
|
23
|
+
} else if (SIDENAV_TYPE === 'TREE_VIEW') {
|
|
24
|
+
props = getTreeView({ projectDir: process.env.PROJECT_DIR!, currentPath });
|
|
25
|
+
}
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<div {...Astro.props}>
|
|
29
|
+
{SIDENAV_TYPE === 'FLAT_VIEW' && <CatalogResourcesSideBar resources={props} currentPath={currentPath} client:load />}
|
|
30
|
+
{SIDENAV_TYPE === 'TREE_VIEW' && <SideNavTreeView client:only transition:persist tree={props} />}
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import gm from 'gray-matter';
|
|
4
|
+
import { globSync } from 'glob';
|
|
5
|
+
import type { CollectionKey } from 'astro:content';
|
|
6
|
+
import { buildUrl } from '@utils/url-builder';
|
|
7
|
+
|
|
8
|
+
export type TreeNode = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
href?: string;
|
|
13
|
+
type: CollectionKey | null;
|
|
14
|
+
children: TreeNode[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resource types that should be in the sidenav
|
|
19
|
+
*/
|
|
20
|
+
const RESOURCE_TYPES = ['domains', 'services', 'events', 'commands', 'queries', 'flows', 'channels'];
|
|
21
|
+
// const RESOURCE_TYPES = ['domains', 'services', 'events', 'commands', 'queries', 'flows', 'channels'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if the path has a RESOURCE_TYPE on path
|
|
25
|
+
*/
|
|
26
|
+
function canBeResource(dirPath: string) {
|
|
27
|
+
const parts = dirPath.split(path.sep);
|
|
28
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
29
|
+
if (RESOURCE_TYPES.includes(parts[i])) return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isNotVersioned(dirPath: string) {
|
|
35
|
+
const parts = dirPath.split(path.sep);
|
|
36
|
+
return parts.every((p) => p !== 'versioned');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getResourceType(filePath: string): CollectionKey | null {
|
|
40
|
+
const parts = filePath.split(path.sep);
|
|
41
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
42
|
+
if (RESOURCE_TYPES.includes(parts[i])) return parts[i] as CollectionKey;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildTreeOfDir(directory: string, parentNode: TreeNode, options: { ignore?: CollectionKey[] }) {
|
|
48
|
+
let node: TreeNode | null = null;
|
|
49
|
+
|
|
50
|
+
const resourceType = getResourceType(directory);
|
|
51
|
+
|
|
52
|
+
const markdownFiles = globSync(path.join(directory, '/*.md'));
|
|
53
|
+
const isResourceIgnored = options?.ignore && resourceType && options.ignore.includes(resourceType);
|
|
54
|
+
|
|
55
|
+
if (markdownFiles.length > 0 && !isResourceIgnored) {
|
|
56
|
+
const resourceFilePath = markdownFiles.find((md) => md.endsWith('index.md'));
|
|
57
|
+
if (resourceFilePath) {
|
|
58
|
+
const resourceDef = gm.read(resourceFilePath);
|
|
59
|
+
node = {
|
|
60
|
+
id: resourceDef.data.id,
|
|
61
|
+
name: resourceDef.data.name,
|
|
62
|
+
type: resourceType,
|
|
63
|
+
version: resourceDef.data.version,
|
|
64
|
+
children: [],
|
|
65
|
+
};
|
|
66
|
+
parentNode.children.push(node);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const directories = fs.readdirSync(directory).filter((name) => {
|
|
71
|
+
const dirPath = path.join(directory, name);
|
|
72
|
+
return fs.statSync(dirPath).isDirectory() && isNotVersioned(dirPath) && canBeResource(dirPath);
|
|
73
|
+
});
|
|
74
|
+
for (const dir of directories) {
|
|
75
|
+
buildTreeOfDir(path.join(directory, dir), node || parentNode, options);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function forEachTreeNodeOf(node: TreeNode, ...callbacks: Array<(node: TreeNode) => void>) {
|
|
80
|
+
const next = node.children;
|
|
81
|
+
|
|
82
|
+
callbacks.forEach((cb) => cb(node));
|
|
83
|
+
|
|
84
|
+
// Go to next level
|
|
85
|
+
next.forEach((n) => {
|
|
86
|
+
forEachTreeNodeOf(n, ...callbacks);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function addHrefToNode(basePathname: 'docs' | 'visualiser') {
|
|
91
|
+
return (node: TreeNode) => {
|
|
92
|
+
node.href = encodeURI(
|
|
93
|
+
buildUrl(
|
|
94
|
+
`/${basePathname}/${node.type}/${node.id}${node.type === 'teams' || node.type === 'users' ? '' : `/${node.version}`}`
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function orderChildrenByName(parentNode: TreeNode) {
|
|
101
|
+
parentNode.children.sort((a, b) => a.name.localeCompare(b.name));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function groupChildrenByType(parentNode: TreeNode) {
|
|
105
|
+
if (parentNode.children.length === 0) return; // Only group if there are children
|
|
106
|
+
|
|
107
|
+
const acc: Record<string, TreeNode[]> = {};
|
|
108
|
+
|
|
109
|
+
// Flows and messages are collapsed by default
|
|
110
|
+
|
|
111
|
+
parentNode.children.forEach((n) => {
|
|
112
|
+
if (n.type === null) return; // TODO: Just ignore or remove the type null???
|
|
113
|
+
if (!(n.type in acc)) acc[n.type] = [];
|
|
114
|
+
acc[n.type].push(n);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Collapse all messages
|
|
118
|
+
const AUTO_EXPANDED_TYPES = ['domains', 'services', 'channels'];
|
|
119
|
+
|
|
120
|
+
parentNode.children = Object.entries(acc)
|
|
121
|
+
// Order label nodes by RESOURCE_TYPES
|
|
122
|
+
.sort(([aType], [bType]) => RESOURCE_TYPES.indexOf(aType) - RESOURCE_TYPES.indexOf(bType))
|
|
123
|
+
// Construct the label nodes
|
|
124
|
+
.map(([type, nodes]) => {
|
|
125
|
+
return {
|
|
126
|
+
id: `${parentNode.id}/${type}`,
|
|
127
|
+
name: type,
|
|
128
|
+
type: type as CollectionKey,
|
|
129
|
+
version: '0',
|
|
130
|
+
children: nodes,
|
|
131
|
+
isExpanded: AUTO_EXPANDED_TYPES.includes(type),
|
|
132
|
+
isLabel: true,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const treeViewCache = new Map<string, TreeNode>();
|
|
138
|
+
|
|
139
|
+
export function getTreeView({ projectDir, currentPath }: { projectDir: string; currentPath: string }): TreeNode {
|
|
140
|
+
const basePathname = currentPath.split('/')[1] as 'docs' | 'visualiser';
|
|
141
|
+
|
|
142
|
+
const cacheKey = `${projectDir}:${basePathname}`;
|
|
143
|
+
if (treeViewCache.has(cacheKey)) return treeViewCache.get(cacheKey)!;
|
|
144
|
+
|
|
145
|
+
const rootNode: TreeNode = {
|
|
146
|
+
id: '/',
|
|
147
|
+
name: 'root',
|
|
148
|
+
type: null,
|
|
149
|
+
version: '0',
|
|
150
|
+
children: [],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
buildTreeOfDir(projectDir, rootNode, {
|
|
154
|
+
ignore: basePathname === 'visualiser' ? ['teams', 'users', 'channels'] : undefined,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// prettier-ignore
|
|
158
|
+
forEachTreeNodeOf(
|
|
159
|
+
rootNode,
|
|
160
|
+
addHrefToNode(basePathname),
|
|
161
|
+
orderChildrenByName,
|
|
162
|
+
groupChildrenByType,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (basePathname === 'visualiser') {
|
|
166
|
+
rootNode.children.unshift({
|
|
167
|
+
id: '/bounded-context-map',
|
|
168
|
+
name: 'bounded context map',
|
|
169
|
+
type: 'bounded-context-map' as any,
|
|
170
|
+
version: '0',
|
|
171
|
+
isLabel: true,
|
|
172
|
+
children: [
|
|
173
|
+
{
|
|
174
|
+
id: '/domain-map',
|
|
175
|
+
name: 'Domain map',
|
|
176
|
+
href: buildUrl('/visualiser/context-map'),
|
|
177
|
+
type: 'bounded-context-map' as any,
|
|
178
|
+
version: '',
|
|
179
|
+
children: [],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
} as TreeNode);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Store in cache before returning
|
|
186
|
+
treeViewCache.set(cacheKey, rootNode);
|
|
187
|
+
|
|
188
|
+
return rootNode;
|
|
189
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { gray } from 'tailwindcss/colors';
|
|
2
|
+
import { TreeView } from '@components/TreeView';
|
|
3
|
+
import { navigate } from 'astro:transitions/client';
|
|
4
|
+
import type { TreeNode as RawTreeNode } from './getTreeView';
|
|
5
|
+
import { getIconForCollection } from '@utils/collections/icons';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
type TreeNode = RawTreeNode & { isLabel?: true; isDefaultExpanded?: boolean; isExpanded?: boolean };
|
|
9
|
+
|
|
10
|
+
function isCurrentNode(node: TreeNode, currentPathname: string) {
|
|
11
|
+
return currentPathname === node.href;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function TreeNode({ node }: { node: TreeNode }) {
|
|
15
|
+
const Icon = getIconForCollection(node.type ?? '');
|
|
16
|
+
const [isCurrent, setIsCurrent] = useState(document.location.pathname === node.href);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const abortCtrl = new AbortController();
|
|
20
|
+
// prettier-ignore
|
|
21
|
+
document.addEventListener(
|
|
22
|
+
'astro:page-load',
|
|
23
|
+
() => setIsCurrent(document.location.pathname === node.href),
|
|
24
|
+
{ signal: abortCtrl.signal },
|
|
25
|
+
);
|
|
26
|
+
return () => abortCtrl.abort();
|
|
27
|
+
}, [document, node]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<TreeView.Item
|
|
31
|
+
key={node.id}
|
|
32
|
+
id={node.id}
|
|
33
|
+
current={isCurrent}
|
|
34
|
+
defaultExpanded={node?.isExpanded || node?.isDefaultExpanded}
|
|
35
|
+
onSelect={node?.isLabel || !node?.href ? undefined : () => navigate(node.href!)}
|
|
36
|
+
>
|
|
37
|
+
{!node?.isLabel && (
|
|
38
|
+
<TreeView.LeadingVisual>
|
|
39
|
+
<Icon className="w-3 -ml-1" />
|
|
40
|
+
</TreeView.LeadingVisual>
|
|
41
|
+
)}
|
|
42
|
+
<span
|
|
43
|
+
className={node?.isLabel ? ' capitalize text-[13px] text-purple-900 font-extrabold' : 'font-light text-[14px] -ml-0.5'}
|
|
44
|
+
>
|
|
45
|
+
{node.name} {node.isLabel ? `(${node.children.length})` : ''}
|
|
46
|
+
</span>
|
|
47
|
+
{(node.children || []).length > 0 && (
|
|
48
|
+
<TreeView.SubTree>
|
|
49
|
+
{node.children!.map((childNode) => (
|
|
50
|
+
<TreeNode key={childNode.id} node={childNode} />
|
|
51
|
+
))}
|
|
52
|
+
</TreeView.SubTree>
|
|
53
|
+
)}
|
|
54
|
+
</TreeView.Item>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function SideNavTreeView({ tree }: { tree: TreeNode }) {
|
|
59
|
+
function bubbleUpExpanded(parentNode: TreeNode) {
|
|
60
|
+
if (isCurrentNode(parentNode, document.location.pathname)) return true;
|
|
61
|
+
return (parentNode.isDefaultExpanded = parentNode.children.some(bubbleUpExpanded));
|
|
62
|
+
}
|
|
63
|
+
bubbleUpExpanded(tree);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<nav id="resources-tree" className="px-2 py-2">
|
|
67
|
+
<TreeView
|
|
68
|
+
truncate={false}
|
|
69
|
+
style={{
|
|
70
|
+
// @ts-expect-error inline css var
|
|
71
|
+
'--base-size-8': '0.5rem',
|
|
72
|
+
'--base-size-12': '0.75rem',
|
|
73
|
+
'--borderColor-muted': '#fff',
|
|
74
|
+
'--borderRadius-medium': '0.375rem',
|
|
75
|
+
'--borderWidth-thick': '0.125rem',
|
|
76
|
+
'--borderWidth-thin': '0.0625rem',
|
|
77
|
+
'--boxShadow-thick': 'inset 0 0 0 var(--borderWidth-thick)',
|
|
78
|
+
'--control-transparent-bgColor-hover': '#656c7626',
|
|
79
|
+
'--control-transparent-bgColor-selected': '#656c761a',
|
|
80
|
+
// '--fgColor-accent': purple[700],
|
|
81
|
+
'--fgColor-default': gray[600],
|
|
82
|
+
'--fgColor-muted': gray[600],
|
|
83
|
+
'--text-body-size-medium': '0.875rem',
|
|
84
|
+
'--stack-gap-condensed': '0.5rem',
|
|
85
|
+
'--treeViewItem-leadingVisual-iconColor-rest': 'var(--fgColor-muted)',
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{tree.children.map((n) => (
|
|
89
|
+
<TreeNode key={n.id} node={n} />
|
|
90
|
+
))}
|
|
91
|
+
</TreeView>
|
|
92
|
+
</nav>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import React, { useCallback, useEffect } from 'react';
|
|
2
|
+
import classes from './styles.module.css';
|
|
3
|
+
import { useSlots } from './useSlots';
|
|
4
|
+
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
// ----------------------------------------------------------------------------
|
|
7
|
+
// Context
|
|
8
|
+
|
|
9
|
+
const RootContext = React.createContext<{
|
|
10
|
+
// We cache the expanded state of tree items so we can preserve the state
|
|
11
|
+
// across remounts. This is necessary because we unmount tree items
|
|
12
|
+
// when their parent is collapsed.
|
|
13
|
+
expandedStateCache: React.RefObject<Map<string, boolean> | null>;
|
|
14
|
+
}>({
|
|
15
|
+
expandedStateCache: { current: new Map() },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const ItemContext = React.createContext<{
|
|
19
|
+
level: number;
|
|
20
|
+
isExpanded: boolean;
|
|
21
|
+
}>({
|
|
22
|
+
level: 1,
|
|
23
|
+
isExpanded: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ----------------------------------------------------------------------------
|
|
27
|
+
// TreeView
|
|
28
|
+
|
|
29
|
+
export type TreeViewProps = {
|
|
30
|
+
'aria-label'?: React.AriaAttributes['aria-label'];
|
|
31
|
+
'aria-labelledby'?: React.AriaAttributes['aria-labelledby'];
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
flat?: boolean;
|
|
34
|
+
truncate?: boolean;
|
|
35
|
+
style?: React.CSSProperties;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/* Size of toggle icon in pixels. */
|
|
39
|
+
const TOGGLE_ICON_SIZE = 12;
|
|
40
|
+
|
|
41
|
+
const Root: React.FC<TreeViewProps> = ({
|
|
42
|
+
'aria-label': ariaLabel,
|
|
43
|
+
'aria-labelledby': ariaLabelledby,
|
|
44
|
+
children,
|
|
45
|
+
flat,
|
|
46
|
+
truncate = true,
|
|
47
|
+
style,
|
|
48
|
+
}) => {
|
|
49
|
+
const containerRef = React.useRef<HTMLUListElement>(null);
|
|
50
|
+
const mouseDownRef = React.useRef<boolean>(false);
|
|
51
|
+
|
|
52
|
+
const onMouseDown = useCallback(() => {
|
|
53
|
+
mouseDownRef.current = true;
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
function onMouseUp() {
|
|
58
|
+
mouseDownRef.current = false;
|
|
59
|
+
}
|
|
60
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
61
|
+
return () => {
|
|
62
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
63
|
+
};
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const expandedStateCache = React.useRef<Map<string, boolean> | null>(null);
|
|
67
|
+
|
|
68
|
+
if (expandedStateCache.current === null) {
|
|
69
|
+
expandedStateCache.current = new Map();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<RootContext.Provider
|
|
74
|
+
value={{
|
|
75
|
+
expandedStateCache,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<ul
|
|
79
|
+
ref={containerRef}
|
|
80
|
+
role="tree"
|
|
81
|
+
aria-label={ariaLabel}
|
|
82
|
+
aria-labelledby={ariaLabelledby}
|
|
83
|
+
data-omit-spacer={flat}
|
|
84
|
+
data-truncate-text={truncate || false}
|
|
85
|
+
onMouseDown={onMouseDown}
|
|
86
|
+
className={classes.TreeViewRootUlStyles}
|
|
87
|
+
style={style}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</ul>
|
|
91
|
+
</RootContext.Provider>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
Root.displayName = 'TreeView';
|
|
96
|
+
|
|
97
|
+
// ----------------------------------------------------------------------------
|
|
98
|
+
// TreeView.Item
|
|
99
|
+
|
|
100
|
+
export type TreeViewItemProps = {
|
|
101
|
+
id: string;
|
|
102
|
+
children: React.ReactNode;
|
|
103
|
+
current?: boolean;
|
|
104
|
+
defaultExpanded?: boolean;
|
|
105
|
+
onSelect?: (event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => void;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
|
|
109
|
+
({ id: itemId, current: isCurrentItem = false, defaultExpanded, onSelect, children }, ref) => {
|
|
110
|
+
const [slots, rest] = useSlots(children, {
|
|
111
|
+
leadingVisual: LeadingVisual,
|
|
112
|
+
});
|
|
113
|
+
const { expandedStateCache } = React.useContext(RootContext);
|
|
114
|
+
|
|
115
|
+
const [isExpanded, setIsExpanded] = React.useState(
|
|
116
|
+
expandedStateCache.current?.get(itemId) ?? defaultExpanded ?? isCurrentItem
|
|
117
|
+
);
|
|
118
|
+
const { level } = React.useContext(ItemContext);
|
|
119
|
+
const { hasSubTree, subTree, childrenWithoutSubTree } = useSubTree(rest);
|
|
120
|
+
const [isFocused, setIsFocused] = React.useState(false);
|
|
121
|
+
|
|
122
|
+
// Set the expanded state and cache it
|
|
123
|
+
const setIsExpandedWithCache = React.useCallback(
|
|
124
|
+
(newIsExpanded: boolean) => {
|
|
125
|
+
setIsExpanded(newIsExpanded);
|
|
126
|
+
expandedStateCache.current?.set(itemId, newIsExpanded);
|
|
127
|
+
},
|
|
128
|
+
[itemId, setIsExpanded, expandedStateCache]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Expand or collapse the subtree
|
|
132
|
+
const toggle = React.useCallback(
|
|
133
|
+
(event?: React.MouseEvent | React.KeyboardEvent) => {
|
|
134
|
+
setIsExpandedWithCache(!isExpanded);
|
|
135
|
+
event?.stopPropagation();
|
|
136
|
+
},
|
|
137
|
+
[isExpanded, setIsExpandedWithCache]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const handleKeyDown = React.useCallback(
|
|
141
|
+
(event: React.KeyboardEvent<HTMLElement>) => {
|
|
142
|
+
switch (event.key) {
|
|
143
|
+
case 'Enter':
|
|
144
|
+
case ' ':
|
|
145
|
+
if (onSelect) {
|
|
146
|
+
onSelect(event);
|
|
147
|
+
} else {
|
|
148
|
+
toggle(event);
|
|
149
|
+
}
|
|
150
|
+
event.stopPropagation();
|
|
151
|
+
break;
|
|
152
|
+
case 'ArrowRight':
|
|
153
|
+
// Ignore if modifier keys are pressed
|
|
154
|
+
if (event.altKey || event.metaKey) return;
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
event.stopPropagation();
|
|
157
|
+
setIsExpandedWithCache(true);
|
|
158
|
+
break;
|
|
159
|
+
case 'ArrowLeft':
|
|
160
|
+
// Ignore if modifier keys are pressed
|
|
161
|
+
if (event.altKey || event.metaKey) return;
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
event.stopPropagation();
|
|
164
|
+
setIsExpandedWithCache(false);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
[onSelect, setIsExpandedWithCache, toggle]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<ItemContext.Provider
|
|
173
|
+
value={{
|
|
174
|
+
level: level + 1,
|
|
175
|
+
isExpanded,
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<li
|
|
179
|
+
className={classes.TreeViewItem}
|
|
180
|
+
ref={ref as React.ForwardedRef<HTMLLIElement>}
|
|
181
|
+
tabIndex={0}
|
|
182
|
+
id={itemId}
|
|
183
|
+
role="treeitem"
|
|
184
|
+
aria-level={level}
|
|
185
|
+
aria-expanded={isExpanded}
|
|
186
|
+
aria-current={isCurrentItem ? 'true' : undefined}
|
|
187
|
+
aria-selected={isFocused ? 'true' : 'false'}
|
|
188
|
+
onKeyDown={handleKeyDown}
|
|
189
|
+
onFocus={(event) => {
|
|
190
|
+
// Scroll the first child into view when the item receives focus
|
|
191
|
+
event.currentTarget.firstElementChild?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
192
|
+
|
|
193
|
+
// Set the focused state
|
|
194
|
+
setIsFocused(true);
|
|
195
|
+
|
|
196
|
+
// Prevent focus event from bubbling up to parent items
|
|
197
|
+
event.stopPropagation();
|
|
198
|
+
}}
|
|
199
|
+
onBlur={() => setIsFocused(false)}
|
|
200
|
+
onClick={(event) => {
|
|
201
|
+
if (onSelect) {
|
|
202
|
+
onSelect(event);
|
|
203
|
+
// if has children open them too
|
|
204
|
+
if (hasSubTree) {
|
|
205
|
+
toggle(event);
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
toggle(event);
|
|
209
|
+
}
|
|
210
|
+
event.stopPropagation();
|
|
211
|
+
}}
|
|
212
|
+
onAuxClick={(event) => {
|
|
213
|
+
if (onSelect && event.button === 1) {
|
|
214
|
+
onSelect(event);
|
|
215
|
+
}
|
|
216
|
+
event.stopPropagation();
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<div
|
|
220
|
+
className={classes.TreeViewItemContainer}
|
|
221
|
+
style={{
|
|
222
|
+
// @ts-ignore CSS custom property
|
|
223
|
+
'--level': level,
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<div style={{ gridArea: 'spacer', display: 'flex' }}>{/* <LevelIndicatorLines level={level} /> */}</div>
|
|
227
|
+
|
|
228
|
+
<div className={classes.TreeViewItemContent}>
|
|
229
|
+
{slots.leadingVisual}
|
|
230
|
+
<span className={classes.TreeViewItemContentText}>{childrenWithoutSubTree}</span>
|
|
231
|
+
</div>
|
|
232
|
+
{hasSubTree ? (
|
|
233
|
+
<div
|
|
234
|
+
className={[classes.TreeViewItemToggle, classes.TreeViewItemToggleHover, classes.TreeViewItemToggleEnd].join(' ')}
|
|
235
|
+
onClick={(event) => {
|
|
236
|
+
if (onSelect) {
|
|
237
|
+
toggle(event);
|
|
238
|
+
}
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
{isExpanded ? <ChevronDownIcon size={TOGGLE_ICON_SIZE} /> : <ChevronRightIcon size={TOGGLE_ICON_SIZE} />}
|
|
242
|
+
</div>
|
|
243
|
+
) : null}
|
|
244
|
+
</div>
|
|
245
|
+
{subTree}
|
|
246
|
+
</li>
|
|
247
|
+
</ItemContext.Provider>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
Item.displayName = 'TreeView.Item';
|
|
253
|
+
|
|
254
|
+
// ----------------------------------------------------------------------------
|
|
255
|
+
// TreeView.SubTree
|
|
256
|
+
|
|
257
|
+
export type TreeViewSubTreeProps = {
|
|
258
|
+
children?: React.ReactNode;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const SubTree: React.FC<TreeViewSubTreeProps> = ({ children }) => {
|
|
262
|
+
const { isExpanded } = React.useContext(ItemContext);
|
|
263
|
+
const ref = React.useRef<HTMLUListElement>(null);
|
|
264
|
+
|
|
265
|
+
if (!isExpanded) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<ul
|
|
271
|
+
role="group"
|
|
272
|
+
style={{
|
|
273
|
+
listStyle: 'none',
|
|
274
|
+
padding: 0,
|
|
275
|
+
margin: 0,
|
|
276
|
+
}}
|
|
277
|
+
ref={ref}
|
|
278
|
+
>
|
|
279
|
+
{children}
|
|
280
|
+
</ul>
|
|
281
|
+
);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
SubTree.displayName = 'TreeView.SubTree';
|
|
285
|
+
|
|
286
|
+
function useSubTree(children: React.ReactNode) {
|
|
287
|
+
return React.useMemo(() => {
|
|
288
|
+
const subTree = React.Children.toArray(children).find((child) => React.isValidElement(child) && child.type === SubTree);
|
|
289
|
+
|
|
290
|
+
const childrenWithoutSubTree = React.Children.toArray(children).filter(
|
|
291
|
+
(child) => !(React.isValidElement(child) && child.type === SubTree)
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
subTree,
|
|
296
|
+
childrenWithoutSubTree,
|
|
297
|
+
hasSubTree: Boolean(subTree),
|
|
298
|
+
};
|
|
299
|
+
}, [children]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ----------------------------------------------------------------------------
|
|
303
|
+
// TreeView.LeadingVisual
|
|
304
|
+
|
|
305
|
+
export type TreeViewLeadingVisualProps = {
|
|
306
|
+
children: React.ReactNode | ((props: { isExpanded: boolean }) => React.ReactNode);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const LeadingVisual: React.FC<TreeViewLeadingVisualProps> = (props) => {
|
|
310
|
+
const { isExpanded } = React.useContext(ItemContext);
|
|
311
|
+
const children = typeof props.children === 'function' ? props.children({ isExpanded }) : props.children;
|
|
312
|
+
return (
|
|
313
|
+
<div className={classes.TreeViewItemVisual} aria-hidden={true}>
|
|
314
|
+
{children}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
LeadingVisual.displayName = 'TreeView.LeadingVisual';
|
|
320
|
+
|
|
321
|
+
// ----------------------------------------------------------------------------
|
|
322
|
+
// Export
|
|
323
|
+
|
|
324
|
+
export const TreeView = Object.assign(Root, {
|
|
325
|
+
Item,
|
|
326
|
+
SubTree,
|
|
327
|
+
LeadingVisual,
|
|
328
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
.TreeViewRootUlStyles {
|
|
2
|
+
padding: 0;
|
|
3
|
+
margin: 0;
|
|
4
|
+
list-style: none;
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* WARNING: This is a performance optimization.
|
|
8
|
+
*
|
|
9
|
+
* We define styles for the tree items at the root level of the tree
|
|
10
|
+
* to avoid recomputing the styles for each item when the tree updates.
|
|
11
|
+
* We're sacrificing maintainability for performance because TreeView
|
|
12
|
+
* needs to be performant enough to handle large trees (thousands of items).
|
|
13
|
+
*
|
|
14
|
+
* This is intended to be a temporary solution until we can improve the
|
|
15
|
+
* performance of our styling patterns.
|
|
16
|
+
*
|
|
17
|
+
* Do NOT copy this pattern without understanding the tradeoffs.
|
|
18
|
+
*/
|
|
19
|
+
.TreeViewItem {
|
|
20
|
+
outline: none;
|
|
21
|
+
|
|
22
|
+
&:focus-visible > div,
|
|
23
|
+
&.focus-visible > div {
|
|
24
|
+
box-shadow: var(--boxShadow-thick) /* var(--fgColor-accent) */ slategray;
|
|
25
|
+
|
|
26
|
+
@media (forced-colors: active) {
|
|
27
|
+
outline: 2px solid HighlightText;
|
|
28
|
+
outline-offset: -2;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
&[data-has-leading-action] {
|
|
33
|
+
--has-leading-action: 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.TreeViewItemContainer {
|
|
38
|
+
--level: 1;
|
|
39
|
+
--toggle-width: 1rem;
|
|
40
|
+
--min-item-height: 2rem;
|
|
41
|
+
|
|
42
|
+
position: relative;
|
|
43
|
+
display: grid;
|
|
44
|
+
width: 100%;
|
|
45
|
+
font-size: var(--text-body-size-medium);
|
|
46
|
+
color: var(--fgColor-default);
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
border-radius: var(--borderRadius-medium);
|
|
49
|
+
grid-template-columns: var(--spacer-width) var(--leading-action-width) 1fr var(--toggle-width);
|
|
50
|
+
grid-template-areas: 'spacer leadingAction content toggle';
|
|
51
|
+
|
|
52
|
+
--leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem);
|
|
53
|
+
--spacer-width: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2));
|
|
54
|
+
|
|
55
|
+
&:hover {
|
|
56
|
+
background-color: var(--control-transparent-bgColor-hover);
|
|
57
|
+
|
|
58
|
+
@media (forced-colors: active) {
|
|
59
|
+
outline: 2px solid transparent;
|
|
60
|
+
outline-offset: -2px;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@media (pointer: coarse) {
|
|
65
|
+
--toggle-width: 1.5rem;
|
|
66
|
+
--min-item-height: 2.75rem;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&:has(.TreeViewItemSkeleton):hover {
|
|
70
|
+
cursor: default;
|
|
71
|
+
background-color: transparent;
|
|
72
|
+
|
|
73
|
+
@media (forced-colors: active) {
|
|
74
|
+
outline: none;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
&:where([data-omit-spacer='true']) .TreeViewItemContainer {
|
|
80
|
+
grid-template-columns: 0 0 1fr 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.TreeViewItem[aria-current='true'] > .TreeViewItemContainer {
|
|
84
|
+
background-color: var(--control-transparent-bgColor-selected);
|
|
85
|
+
|
|
86
|
+
/* Current item indicator */
|
|
87
|
+
/* stylelint-disable-next-line selector-max-specificity */
|
|
88
|
+
&::after {
|
|
89
|
+
position: absolute;
|
|
90
|
+
top: calc(50% - var(--base-size-12));
|
|
91
|
+
left: calc(-1 * var(--base-size-8));
|
|
92
|
+
width: 0.25rem;
|
|
93
|
+
height: 1.5rem;
|
|
94
|
+
content: '';
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* Use fgColor accent for consistency across all themes. Using the "correct" variable,
|
|
98
|
+
* --bgColor-accent-emphasis, causes vrt failures for dark high contrast mode
|
|
99
|
+
*/
|
|
100
|
+
/* stylelint-disable-next-line primer/colors */
|
|
101
|
+
background-color: var(--fgColor-accent);
|
|
102
|
+
border-radius: var(--borderRadius-medium);
|
|
103
|
+
|
|
104
|
+
@media (forced-colors: active) {
|
|
105
|
+
background-color: HighlightText;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.TreeViewItemToggle {
|
|
111
|
+
display: flex;
|
|
112
|
+
height: 100%;
|
|
113
|
+
|
|
114
|
+
/* The toggle should appear vertically centered for single-line items, but remain at the top for items that wrap
|
|
115
|
+
across more lines. */
|
|
116
|
+
/* stylelint-disable-next-line primer/spacing */
|
|
117
|
+
padding-top: calc(var(--min-item-height) / 2 - var(--base-size-12) / 2);
|
|
118
|
+
color: var(--fgColor-muted);
|
|
119
|
+
grid-area: toggle;
|
|
120
|
+
justify-content: center;
|
|
121
|
+
align-items: flex-start;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.TreeViewItemToggleHover:hover {
|
|
125
|
+
background-color: var(--control-transparent-bgColor-hover);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.TreeViewItemToggleEnd {
|
|
129
|
+
border-top-left-radius: var(--borderRadius-medium);
|
|
130
|
+
border-bottom-left-radius: var(--borderRadius-medium);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.TreeViewItemContent {
|
|
134
|
+
display: flex;
|
|
135
|
+
height: 100%;
|
|
136
|
+
padding: 0 var(--base-size-8);
|
|
137
|
+
|
|
138
|
+
/* The dynamic top and bottom padding to maintain the minimum item height for single line items */
|
|
139
|
+
/* stylelint-disable-next-line primer/spacing */
|
|
140
|
+
padding-top: calc((var(--min-item-height) - var(--custom-line-height, 1.3rem)) / 2);
|
|
141
|
+
/* stylelint-disable-next-line primer/spacing */
|
|
142
|
+
padding-bottom: calc((var(--min-item-height) - var(--custom-line-height, 1.3rem)) / 2);
|
|
143
|
+
line-height: var(--custom-line-height, var(--text-body-lineHeight-medium, 1.4285));
|
|
144
|
+
grid-area: content;
|
|
145
|
+
gap: var(--stack-gap-condensed);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.TreeViewItemContentText {
|
|
149
|
+
flex: 1 1 auto;
|
|
150
|
+
width: 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
&:where([data-truncate-text='true']) .TreeViewItemContentText {
|
|
154
|
+
overflow: hidden;
|
|
155
|
+
text-overflow: ellipsis;
|
|
156
|
+
white-space: nowrap;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
&:where([data-truncate-text='false']) .TreeViewItemContentText {
|
|
160
|
+
word-break: break-word;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.TreeViewItemVisual {
|
|
164
|
+
display: flex;
|
|
165
|
+
|
|
166
|
+
/* The visual icons should appear vertically centered for single-line items, but remain at the top for items that wrap
|
|
167
|
+
across more lines. */
|
|
168
|
+
height: var(--custom-line-height, 1.3rem);
|
|
169
|
+
color: var(--fgColor-muted);
|
|
170
|
+
align-items: center;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.TreeViewItemLeadingAction {
|
|
174
|
+
display: flex;
|
|
175
|
+
color: var(--fgColor-muted);
|
|
176
|
+
grid-area: leadingAction;
|
|
177
|
+
|
|
178
|
+
& > button {
|
|
179
|
+
flex-shrink: 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.TreeViewItemLevelLine {
|
|
184
|
+
width: 100%;
|
|
185
|
+
height: 100%;
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* On devices without hover, the nesting indicator lines
|
|
189
|
+
* appear at all times.
|
|
190
|
+
*/
|
|
191
|
+
border-color: var(--borderColor-muted);
|
|
192
|
+
border-right: var(--borderWidth-thin) solid;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/*
|
|
196
|
+
* On devices with :hover support, the nesting indicator lines
|
|
197
|
+
* fade in when the user mouses over the entire component,
|
|
198
|
+
* or when there's focus inside the component. This makes
|
|
199
|
+
* sure the component remains simple when not in use.
|
|
200
|
+
*/
|
|
201
|
+
@media (hover: hover) {
|
|
202
|
+
.TreeViewItemLevelLine {
|
|
203
|
+
border-color: transparent;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
&:hover .TreeViewItemLevelLine,
|
|
207
|
+
&:focus-within .TreeViewItemLevelLine {
|
|
208
|
+
border-color: var(--borderColor-muted);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.TreeViewDirectoryIcon {
|
|
213
|
+
display: grid;
|
|
214
|
+
color: var(--treeViewItem-leadingVisual-iconColor-rest);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.TreeViewVisuallyHidden {
|
|
218
|
+
position: absolute;
|
|
219
|
+
width: 1px;
|
|
220
|
+
height: 1px;
|
|
221
|
+
padding: 0;
|
|
222
|
+
/* stylelint-disable-next-line primer/spacing */
|
|
223
|
+
margin: -1px;
|
|
224
|
+
overflow: hidden;
|
|
225
|
+
clip: rect(0, 0, 0, 0);
|
|
226
|
+
white-space: nowrap;
|
|
227
|
+
border-width: 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.TreeViewSkeletonItemContainerStyle {
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
column-gap: 0.5rem;
|
|
235
|
+
height: 2rem;
|
|
236
|
+
|
|
237
|
+
@media (pointer: coarse) {
|
|
238
|
+
height: 2.75rem;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
&:nth-of-type(5n + 1) {
|
|
242
|
+
--tree-item-loading-width: 67%;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
&:nth-of-type(5n + 2) {
|
|
246
|
+
--tree-item-loading-width: 47%;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
&:nth-of-type(5n + 3) {
|
|
250
|
+
--tree-item-loading-width: 73%;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
&:nth-of-type(5n + 4) {
|
|
254
|
+
--tree-item-loading-width: 64%;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
&:nth-of-type(5n + 5) {
|
|
258
|
+
--tree-item-loading-width: 50%;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.TreeItemSkeletonTextStyles {
|
|
263
|
+
width: var(--tree-item-loading-width, 67%);
|
|
264
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
// import {warning} from '../utils/warning'
|
|
3
|
+
|
|
4
|
+
// slot config allows 2 options:
|
|
5
|
+
// 1. Component to match, example: { leadingVisual: LeadingVisual }
|
|
6
|
+
type ComponentMatcher = React.ElementType<Props>;
|
|
7
|
+
// 2. Component to match + a test function, example: { blockDescription: [Description, props => props.variant === 'block'] }
|
|
8
|
+
type ComponentAndPropsMatcher = [ComponentMatcher, (props: Props) => boolean];
|
|
9
|
+
|
|
10
|
+
export type SlotConfig = Record<string, ComponentMatcher | ComponentAndPropsMatcher>;
|
|
11
|
+
|
|
12
|
+
// We don't know what the props are yet, we set them later based on slot config
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
type Props = any;
|
|
15
|
+
|
|
16
|
+
type SlotElements<Config extends SlotConfig> = {
|
|
17
|
+
[Property in keyof Config]: SlotValue<Config, Property>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type SlotValue<Config, Property extends keyof Config> = Config[Property] extends React.ElementType // config option 1
|
|
21
|
+
? React.ReactElement<React.ComponentPropsWithoutRef<Config[Property]>, Config[Property]>
|
|
22
|
+
: Config[Property] extends readonly [
|
|
23
|
+
infer ElementType extends React.ElementType, // config option 2, infer array[0] as component
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
25
|
+
infer _testFn, // even though we don't use testFn, we need to infer it to support types for slots.*.props
|
|
26
|
+
]
|
|
27
|
+
? React.ReactElement<React.ComponentPropsWithoutRef<ElementType>, ElementType>
|
|
28
|
+
: never; // useful for narrowing types, third option is not possible
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract components from `children` so we can render them in different places,
|
|
32
|
+
* allowing us to implement components with SSR-compatible slot APIs.
|
|
33
|
+
* Note: We can only extract direct children, not nested ones.
|
|
34
|
+
*/
|
|
35
|
+
export function useSlots<Config extends SlotConfig>(
|
|
36
|
+
children: React.ReactNode,
|
|
37
|
+
config: Config
|
|
38
|
+
): [Partial<SlotElements<Config>>, React.ReactNode[]] {
|
|
39
|
+
// Object mapping slot names to their elements
|
|
40
|
+
const slots: Partial<SlotElements<Config>> = mapValues(config, () => undefined);
|
|
41
|
+
|
|
42
|
+
// Array of elements that are not slots
|
|
43
|
+
const rest: React.ReactNode[] = [];
|
|
44
|
+
|
|
45
|
+
const keys = Object.keys(config) as Array<keyof Config>;
|
|
46
|
+
const values = Object.values(config);
|
|
47
|
+
|
|
48
|
+
// eslint-disable-next-line github/array-foreach
|
|
49
|
+
React.Children.forEach(children, (child) => {
|
|
50
|
+
if (!React.isValidElement(child)) {
|
|
51
|
+
rest.push(child);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const index = values.findIndex((value) => {
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
const [component, testFn] = value;
|
|
58
|
+
return child.type === component && testFn(child.props);
|
|
59
|
+
} else {
|
|
60
|
+
return child.type === value;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// If the child is not a slot, add it to the `rest` array
|
|
65
|
+
if (index === -1) {
|
|
66
|
+
rest.push(child);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const slotKey = keys[index];
|
|
71
|
+
|
|
72
|
+
// If slot is already filled, ignore duplicates
|
|
73
|
+
if (slots[slotKey]) {
|
|
74
|
+
// warning(true, `Found duplicate "${String(slotKey)}" slot. Only the first will be rendered.`)
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// If the child is a slot, add it to the `slots` object
|
|
79
|
+
|
|
80
|
+
slots[slotKey] = child as SlotValue<Config, keyof Config>;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return [slots, rest];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Map the values of an object */
|
|
87
|
+
function mapValues<T extends Record<string, unknown>, V>(obj: T, fn: (value: T[keyof T]) => V) {
|
|
88
|
+
return Object.keys(obj).reduce(
|
|
89
|
+
(result, key: keyof T) => {
|
|
90
|
+
result[key] = fn(obj[key]);
|
|
91
|
+
return result;
|
|
92
|
+
},
|
|
93
|
+
{} as Record<keyof T, V>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -8,6 +8,7 @@ interface Props {
|
|
|
8
8
|
import { BookOpenText, Workflow, TableProperties, House, BookUser } from 'lucide-react';
|
|
9
9
|
import Header from '../components/Header.astro';
|
|
10
10
|
import SEO from '../components/Seo.astro';
|
|
11
|
+
import SideNav from '../components/SideNav/SideNav.astro';
|
|
11
12
|
|
|
12
13
|
import { getCommands } from '@utils/commands';
|
|
13
14
|
import { getDomains } from '@utils/collections/domains';
|
|
@@ -17,8 +18,6 @@ import { getFlows } from '@utils/collections/flows';
|
|
|
17
18
|
import { isCollectionVisibleInCatalog } from '@eventcatalog';
|
|
18
19
|
import { buildUrl } from '@utils/url-builder';
|
|
19
20
|
import { getQueries } from '@utils/queries';
|
|
20
|
-
import { getChannels } from '@utils/channels';
|
|
21
|
-
import CatalogResourcesSideBar from '@components/SideBars/CatalogResourcesSideBar';
|
|
22
21
|
import { hasLandingPageForDocs } from '@utils/pages';
|
|
23
22
|
|
|
24
23
|
const events = await getEvents({ getAllVersions: false });
|
|
@@ -26,14 +25,8 @@ const commands = await getCommands({ getAllVersions: false });
|
|
|
26
25
|
const queries = await getQueries({ getAllVersions: false });
|
|
27
26
|
const services = await getServices({ getAllVersions: false });
|
|
28
27
|
const domains = await getDomains({ getAllVersions: false });
|
|
29
|
-
const channels = await getChannels({ getAllVersions: false });
|
|
30
28
|
const flows = await getFlows({ getAllVersions: false });
|
|
31
29
|
|
|
32
|
-
const messages = [...events, ...commands, ...queries];
|
|
33
|
-
|
|
34
|
-
// @ts-ignore for large catalogs https://github.com/event-catalog/eventcatalog/issues/552
|
|
35
|
-
const allData = [...domains, ...services, ...messages, ...channels, ...flows];
|
|
36
|
-
|
|
37
30
|
const currentPath = Astro.url.pathname;
|
|
38
31
|
|
|
39
32
|
const catalogHasDefaultLandingPageForDocs = await hasLandingPageForDocs();
|
|
@@ -101,45 +94,8 @@ const navigationItems = [
|
|
|
101
94
|
},
|
|
102
95
|
];
|
|
103
96
|
|
|
104
|
-
const allDataAsSideNav = allData.reduce((acc, item) => {
|
|
105
|
-
const title = item.collection;
|
|
106
|
-
const group = acc[title] || [];
|
|
107
|
-
const currentPath = Astro.url.pathname;
|
|
108
|
-
const route = currentPath.includes('visualiser') ? 'visualiser' : 'docs';
|
|
109
|
-
|
|
110
|
-
const navigationItem = {
|
|
111
|
-
label: item.data.name,
|
|
112
|
-
version: item.data.version,
|
|
113
|
-
visible: isCollectionVisibleInCatalog(item.collection),
|
|
114
|
-
// @ts-ignore
|
|
115
|
-
href: item.data.version
|
|
116
|
-
? // @ts-ignore
|
|
117
|
-
buildUrl(`/${route}/${item.collection}/${item.data.id}/${item.data.version}`)
|
|
118
|
-
: buildUrl(`/${route}/${item.collection}/${item.data.id}`),
|
|
119
|
-
collection: item.collection,
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
group.push(navigationItem);
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
...acc,
|
|
126
|
-
[title]: group,
|
|
127
|
-
};
|
|
128
|
-
}, {} as any);
|
|
129
|
-
|
|
130
|
-
const sideNav = {
|
|
131
|
-
...(currentPath.includes('visualiser')
|
|
132
|
-
? {
|
|
133
|
-
'bounded context map': [
|
|
134
|
-
{ label: 'Domain map', href: buildUrl('/visualiser/context-map'), collection: 'bounded-context-map' },
|
|
135
|
-
],
|
|
136
|
-
}
|
|
137
|
-
: {}),
|
|
138
|
-
...allDataAsSideNav,
|
|
139
|
-
};
|
|
140
|
-
|
|
141
97
|
const currentNavigationItem = navigationItems.find((item) => item.current);
|
|
142
|
-
const { title, description
|
|
98
|
+
const { title, description } = Astro.props;
|
|
143
99
|
|
|
144
100
|
const showSideBarOnLoad = currentNavigationItem?.sidebar;
|
|
145
101
|
|
|
@@ -198,12 +154,10 @@ const canPageBeEmbedded = process.env.ENABLE_EMBED === 'true';
|
|
|
198
154
|
</nav>
|
|
199
155
|
</div>
|
|
200
156
|
|
|
201
|
-
<
|
|
157
|
+
<SideNav
|
|
202
158
|
id="sidebar"
|
|
203
|
-
class={`sidebar-transition h-content
|
|
204
|
-
|
|
205
|
-
<CatalogResourcesSideBar resources={sideNav} currentPath={currentPath} client:load />
|
|
206
|
-
</div>
|
|
159
|
+
class={`sidebar-transition h-content overflow-y-auto bg-white border-r border-gray-100 w-60 ml-16 ${showSideBarOnLoad ? 'block' : 'hidden'}`}
|
|
160
|
+
/>
|
|
207
161
|
</aside>
|
|
208
162
|
<main
|
|
209
163
|
class={`sidebar-transition w-full max-h-content overflow-y-auto ${showSideBarOnLoad ? 'ml-0' : 'ml-16'}`}
|
package/package.json
CHANGED
/package/eventcatalog/src/components/{SideBars → SideNav}/CatalogResourcesSideBar/styles.css
RENAMED
|
File without changes
|