@eventcatalog/core 2.22.0 → 2.23.1
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-GGTLXJHB.js} +1 -1
- package/dist/{chunk-O3MRQUYC.js → chunk-I76AB6JV.js} +1 -1
- package/dist/{chunk-NQSIMY3C.js → chunk-MUBFMCSR.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +38 -39
- package/dist/eventcatalog.config.d.cts +4 -0
- package/dist/eventcatalog.config.d.ts +4 -0
- package/dist/eventcatalog.js +40 -41
- 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 +4 -2
- /package/eventcatalog/src/components/{SideBars → SideNav}/CatalogResourcesSideBar/styles.css +0 -0
|
@@ -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
|
+
});
|