@dxos/plugin-navtree 0.6.8-main.046e6cf
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/LICENSE +8 -0
- package/README.md +15 -0
- package/dist/lib/browser/chunk-U7MTKDFQ.mjs +13 -0
- package/dist/lib/browser/chunk-U7MTKDFQ.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +1055 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/browser/meta.mjs +11 -0
- package/dist/lib/browser/meta.mjs.map +7 -0
- package/dist/types/src/NavTreePlugin.d.ts +4 -0
- package/dist/types/src/NavTreePlugin.d.ts.map +1 -0
- package/dist/types/src/components/CommandsDialog.d.ts +5 -0
- package/dist/types/src/components/CommandsDialog.d.ts.map +1 -0
- package/dist/types/src/components/CommandsTrigger.d.ts +3 -0
- package/dist/types/src/components/CommandsTrigger.d.ts.map +1 -0
- package/dist/types/src/components/HaloButton.d.ts +12 -0
- package/dist/types/src/components/HaloButton.d.ts.map +1 -0
- package/dist/types/src/components/HaloButton.stories.d.ts +12 -0
- package/dist/types/src/components/HaloButton.stories.d.ts.map +1 -0
- package/dist/types/src/components/NavBarStart.d.ts +7 -0
- package/dist/types/src/components/NavBarStart.d.ts.map +1 -0
- package/dist/types/src/components/NavTreeContainer.d.ts +12 -0
- package/dist/types/src/components/NavTreeContainer.d.ts.map +1 -0
- package/dist/types/src/components/NavTreeDocumentTitle.d.ts +5 -0
- package/dist/types/src/components/NavTreeDocumentTitle.d.ts.map +1 -0
- package/dist/types/src/components/NavTreeFooter.d.ts +6 -0
- package/dist/types/src/components/NavTreeFooter.d.ts.map +1 -0
- package/dist/types/src/components/NotchStart.d.ts +3 -0
- package/dist/types/src/components/NotchStart.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +6 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/experimental/Tree.d.ts +56 -0
- package/dist/types/src/experimental/Tree.d.ts.map +1 -0
- package/dist/types/src/experimental/Tree.stories.d.ts +12 -0
- package/dist/types/src/experimental/Tree.stories.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/meta.d.ts +7 -0
- package/dist/types/src/meta.d.ts.map +1 -0
- package/dist/types/src/stories/Graph.stories.d.ts +17 -0
- package/dist/types/src/stories/Graph.stories.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +27 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/src/util.d.ts +32 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/package.json +77 -0
- package/src/NavTreePlugin.tsx +262 -0
- package/src/components/CommandsDialog.tsx +114 -0
- package/src/components/CommandsTrigger.tsx +34 -0
- package/src/components/HaloButton.stories.tsx +19 -0
- package/src/components/HaloButton.tsx +41 -0
- package/src/components/NavBarStart.tsx +48 -0
- package/src/components/NavTreeContainer.tsx +375 -0
- package/src/components/NavTreeDocumentTitle.tsx +17 -0
- package/src/components/NavTreeFooter.tsx +155 -0
- package/src/components/NotchStart.tsx +29 -0
- package/src/components/index.ts +9 -0
- package/src/experimental/Tree.stories.tsx +229 -0
- package/src/experimental/Tree.tsx +282 -0
- package/src/index.ts +10 -0
- package/src/meta.ts +12 -0
- package/src/stories/Graph.stories.tsx +162 -0
- package/src/translations.ts +28 -0
- package/src/util.ts +231 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
|
|
7
|
+
import { House, List, Planet, PlusCircle, Sailboat } from '@phosphor-icons/react';
|
|
8
|
+
import React, { type JSX, type PropsWithChildren, useEffect, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { faker } from '@dxos/random';
|
|
11
|
+
import { modalSurface, mx } from '@dxos/react-ui-theme';
|
|
12
|
+
import { withFullscreen, withTheme } from '@dxos/storybook-utils';
|
|
13
|
+
|
|
14
|
+
import { type ItemMap, Tree, type TreeNodeData, type TreeProps, visitNodes, visitor } from './Tree';
|
|
15
|
+
|
|
16
|
+
faker.seed(1234);
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
title: 'plugin-navtree/Tree',
|
|
20
|
+
component: Tree,
|
|
21
|
+
decorators: [withTheme, withFullscreen({ classNames: modalSurface })],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Space:
|
|
26
|
+
* - Space panels are separated from each other to give them more weight than folders.
|
|
27
|
+
* - Private space isn't different (just first, different icon).
|
|
28
|
+
* - Edit titles in place.
|
|
29
|
+
*
|
|
30
|
+
* Navigation:
|
|
31
|
+
* - up/down across spaces/folder peers
|
|
32
|
+
* - right to enter space/folder children
|
|
33
|
+
* - left to parent
|
|
34
|
+
*/
|
|
35
|
+
const Container = ({ children, sidebar }: PropsWithChildren<{ sidebar: JSX.Element }>) => {
|
|
36
|
+
return (
|
|
37
|
+
<div className='flex'>
|
|
38
|
+
{/* TODO(burdon): Custom thin scrollbar. */}
|
|
39
|
+
{/* TODO(burdon): Horizontal scrolling within navtree? */}
|
|
40
|
+
<div className='flex flex-col overflow-y-auto w-[300px] bg-neutral-100 dark:bg-neutral-950'>{sidebar}</div>
|
|
41
|
+
<div className='flex flex-col grow overflow-hidden'>{children}</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// TODO(burdon): Generate.
|
|
47
|
+
const data: TreeNodeData[] = [
|
|
48
|
+
{
|
|
49
|
+
id: 'root',
|
|
50
|
+
title: 'Root',
|
|
51
|
+
children: [
|
|
52
|
+
{
|
|
53
|
+
id: faker.string.uuid(),
|
|
54
|
+
title: 'Personal Space',
|
|
55
|
+
color: 'text-green-400',
|
|
56
|
+
Icon: House,
|
|
57
|
+
children: [
|
|
58
|
+
{
|
|
59
|
+
id: faker.string.uuid(),
|
|
60
|
+
title: faker.commerce.productName(),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: faker.string.uuid(),
|
|
64
|
+
title: faker.commerce.productName(),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: faker.string.uuid(),
|
|
68
|
+
title: faker.commerce.productName(),
|
|
69
|
+
children: [
|
|
70
|
+
{
|
|
71
|
+
id: faker.string.uuid(),
|
|
72
|
+
title: faker.commerce.productName(),
|
|
73
|
+
children: [
|
|
74
|
+
{
|
|
75
|
+
id: faker.string.uuid(),
|
|
76
|
+
title: faker.commerce.productName(),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: faker.string.uuid(),
|
|
80
|
+
title: faker.commerce.productName(),
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: faker.string.uuid(),
|
|
90
|
+
title: faker.commerce.productName(),
|
|
91
|
+
Icon: Planet,
|
|
92
|
+
children: [
|
|
93
|
+
{
|
|
94
|
+
id: faker.string.uuid(),
|
|
95
|
+
title: faker.commerce.productName(),
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: faker.string.uuid(),
|
|
101
|
+
title: faker.commerce.productName(),
|
|
102
|
+
Icon: Sailboat,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: faker.string.uuid(),
|
|
106
|
+
title: faker.commerce.productName(),
|
|
107
|
+
Icon: Planet,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
export const Default = () => {
|
|
114
|
+
return <Container sidebar={<Sidebar mutate />} />;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const Visitor = () => {
|
|
118
|
+
const [items] = useState(data);
|
|
119
|
+
const root = items[0];
|
|
120
|
+
const [openItems] = useState<ItemMap>({ [root.children![0].id]: true });
|
|
121
|
+
return (
|
|
122
|
+
<div className='flex flex-col'>
|
|
123
|
+
{Array.from(visitor(root, openItems)).map(({ node: { id, title }, depth }) => (
|
|
124
|
+
<div key={id} className='grid grid-cols-[40px_400px_400px] font-mono'>
|
|
125
|
+
<div>{depth}</div>
|
|
126
|
+
<div>{id}</div>
|
|
127
|
+
<div>{title}</div>
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const Sidebar = ({ mutate }: { mutate?: boolean }) => {
|
|
135
|
+
const [items, setItems] = useState(data);
|
|
136
|
+
const root = items[0];
|
|
137
|
+
const [openItems, setOpenItems] = useState<ItemMap>({ [root.children![0].id]: true });
|
|
138
|
+
const [selectedItems, setSelectedItems] = useState<ItemMap>({});
|
|
139
|
+
const [activeItems, setActiveItems] = useState<ItemMap>(() => {
|
|
140
|
+
const active: ItemMap = {};
|
|
141
|
+
visitNodes(root, (node, depth) => {
|
|
142
|
+
if (depth > 1 && Math.random() > 0.6) {
|
|
143
|
+
active[node.id] = true;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return active;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (mutate) {
|
|
151
|
+
const t = setInterval(() => {
|
|
152
|
+
setActiveItems((active) => {
|
|
153
|
+
visitNodes(root, (node, depth) => {
|
|
154
|
+
if (depth > 1) {
|
|
155
|
+
active[node.id] = active[node.id] ? Math.random() > 0.4 : Math.random() > 0.7;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return { ...active };
|
|
159
|
+
});
|
|
160
|
+
}, 5_000);
|
|
161
|
+
return () => clearInterval(t);
|
|
162
|
+
}
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const handleCreateItem: TreeProps['onMenuAction'] = (id, action) => {
|
|
166
|
+
setItems((items) => {
|
|
167
|
+
let parent: TreeNodeData | undefined;
|
|
168
|
+
visitNodes(root, (node) => {
|
|
169
|
+
if (node.id === id) {
|
|
170
|
+
parent = node;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
(parent!.children ??= []).push({
|
|
175
|
+
id: faker.string.uuid(),
|
|
176
|
+
title: faker.commerce.productName(),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
setOpenItems((open) => ({ ...open, [parent!.id]: true }));
|
|
180
|
+
|
|
181
|
+
return { ...items };
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handleCreateSpace = () => {
|
|
186
|
+
setItems((items) => {
|
|
187
|
+
items[0].children?.push({
|
|
188
|
+
id: faker.string.uuid(),
|
|
189
|
+
Icon: Planet,
|
|
190
|
+
title: faker.commerce.productName(),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { ...items };
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div className='flex flex-col overflow-hidden'>
|
|
199
|
+
<div className='flex flex-col overflow-y-auto'>
|
|
200
|
+
<Tree
|
|
201
|
+
className='p-0.5 gap-1'
|
|
202
|
+
node={root}
|
|
203
|
+
open={openItems}
|
|
204
|
+
selected={selectedItems}
|
|
205
|
+
active={activeItems}
|
|
206
|
+
onChangeOpen={(id, open) => setOpenItems((items) => ({ ...items, [id]: open }))}
|
|
207
|
+
onChangeSelected={(id, open) => setSelectedItems((items) => ({ ...items, [id]: open }))}
|
|
208
|
+
onMenuAction={handleCreateItem}
|
|
209
|
+
getSlots={(node, open, depth, ancestors) => {
|
|
210
|
+
if (depth === 1) {
|
|
211
|
+
return {
|
|
212
|
+
root: 'rounded bg-white dark:bg-neutral-850',
|
|
213
|
+
header: mx('rounded-t bg-neutral-50 dark:bg-neutral-900', !open && 'rounded-b'),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<div className='flex items-center my-2 px-2 gap-2'>
|
|
221
|
+
<PlusCircle onClick={handleCreateSpace} />
|
|
222
|
+
<span className='grow text-sm' onClick={handleCreateSpace}>
|
|
223
|
+
New space
|
|
224
|
+
</span>
|
|
225
|
+
<List />
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { CaretRight, Circle, File, Folder, type Icon, type IconProps, Plus, UserCircle } from '@phosphor-icons/react';
|
|
6
|
+
import React, { type HTMLAttributes, type PropsWithChildren } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type ClassNameValue, type Size } from '@dxos/react-ui';
|
|
9
|
+
import { getSize, mx } from '@dxos/react-ui-theme';
|
|
10
|
+
|
|
11
|
+
// TODO(burdon): Rounded border if first/last.
|
|
12
|
+
const styles = {
|
|
13
|
+
hover: 'hover:bg-slate-400 dark:hover:bg-slate-800',
|
|
14
|
+
selected: '!bg-slate-300 dark:!bg-slate-700',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const IconButton = ({
|
|
18
|
+
Icon,
|
|
19
|
+
classNames,
|
|
20
|
+
size = 4,
|
|
21
|
+
onClick,
|
|
22
|
+
...props
|
|
23
|
+
}: {
|
|
24
|
+
Icon: Icon;
|
|
25
|
+
classNames?: ClassNameValue;
|
|
26
|
+
size?: Size;
|
|
27
|
+
} & Pick<IconProps, 'weight'> &
|
|
28
|
+
Pick<HTMLAttributes<HTMLDivElement>, 'onClick'>) => {
|
|
29
|
+
// TODO(burdon): Density aware.
|
|
30
|
+
return (
|
|
31
|
+
<div className={mx('flex w-6 h-6 items-center justify-center select-none', classNames)} onClick={onClick}>
|
|
32
|
+
<Icon className={mx(getSize(size), 'cursor-pointer')} {...props} />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type TreeNodeData = {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
Icon?: Icon;
|
|
41
|
+
color?: string;
|
|
42
|
+
children?: TreeNodeData[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type TreeIteratorNode = {
|
|
46
|
+
node: TreeNodeData;
|
|
47
|
+
depth: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function* visitor(node: TreeNodeData, open: ItemMap): Generator<TreeIteratorNode> {
|
|
51
|
+
const stack: TreeIteratorNode[] = [{ node, depth: 0 }];
|
|
52
|
+
while (stack.length > 0) {
|
|
53
|
+
const { node, depth } = stack.pop()!;
|
|
54
|
+
if (depth > 0) {
|
|
55
|
+
yield { node, depth };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const children = Array.from(node.children ?? []);
|
|
59
|
+
if (depth === 0 || open[node.id]) {
|
|
60
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
61
|
+
const child = children[i];
|
|
62
|
+
stack.push({ node: child, depth: depth + 1 });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const visitNodes = <T,>(
|
|
69
|
+
node: TreeNodeData,
|
|
70
|
+
callback: (node: TreeNodeData, depth: number) => T,
|
|
71
|
+
depth = 0,
|
|
72
|
+
): T | undefined => {
|
|
73
|
+
const result = callback(node, depth);
|
|
74
|
+
if (result) {
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const child of node.children ?? []) {
|
|
79
|
+
const result = visitNodes(child, callback, depth + 1);
|
|
80
|
+
if (result) {
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type ItemMap<T = boolean> = Record<TreeNodeData['id'], T>;
|
|
87
|
+
|
|
88
|
+
export type TreeNodeProps = {
|
|
89
|
+
node: TreeNodeData;
|
|
90
|
+
depth?: number;
|
|
91
|
+
getSlots?: (
|
|
92
|
+
node: TreeNodeData,
|
|
93
|
+
open: boolean,
|
|
94
|
+
depth: number,
|
|
95
|
+
ancestors: TreeNodeData[],
|
|
96
|
+
) => { root?: string; header?: string } | undefined;
|
|
97
|
+
|
|
98
|
+
// Component state is separate from the data (stored separately).
|
|
99
|
+
open?: ItemMap;
|
|
100
|
+
selected?: ItemMap;
|
|
101
|
+
active?: ItemMap;
|
|
102
|
+
|
|
103
|
+
onChangeOpen?: (id: string, open: boolean) => void;
|
|
104
|
+
onChangeSelected?: (id: string, select: boolean) => void;
|
|
105
|
+
onMenuAction?: (id: string, action: string) => void;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const StateIcon = ({ node, open, selected, active }: TreeNodeProps) => {
|
|
109
|
+
const { id } = node;
|
|
110
|
+
|
|
111
|
+
// Check if any children are active.
|
|
112
|
+
let isChildActive = false;
|
|
113
|
+
if (!active?.[id] && !open?.[id]) {
|
|
114
|
+
isChildActive = !!visitNodes(node, ({ id }) => {
|
|
115
|
+
if (active?.[id]) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const isActive = active?.[id] || isChildActive;
|
|
122
|
+
|
|
123
|
+
// TODO(burdon): No animation if opening/closing folder.
|
|
124
|
+
return (
|
|
125
|
+
<IconButton
|
|
126
|
+
Icon={isActive ? UserCircle : Circle}
|
|
127
|
+
size={4}
|
|
128
|
+
weight={selected?.[id] ? 'fill' : 'duotone'}
|
|
129
|
+
classNames={mx(
|
|
130
|
+
'text-slate-500',
|
|
131
|
+
!isChildActive && 'opacity-0 transition duration-500',
|
|
132
|
+
(isActive || selected?.[id]) && 'opacity-100',
|
|
133
|
+
)}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const OpenIcon = ({
|
|
139
|
+
node: { id, children },
|
|
140
|
+
open,
|
|
141
|
+
onChangeOpen,
|
|
142
|
+
}: Pick<TreeNodeProps, 'node' | 'open' | 'onChangeOpen'>) => {
|
|
143
|
+
return (
|
|
144
|
+
(children?.length && open && (
|
|
145
|
+
<IconButton
|
|
146
|
+
Icon={CaretRight}
|
|
147
|
+
size={3}
|
|
148
|
+
classNames={mx('transition duration-200', open?.[id] ? 'rotate-90' : 'transform-none')}
|
|
149
|
+
onClick={(ev) => {
|
|
150
|
+
ev.stopPropagation();
|
|
151
|
+
onChangeOpen?.(id, !open[id]);
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
)) || <div />
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// TODO(burdon): Drag handle,
|
|
159
|
+
const ItemIcon = ({
|
|
160
|
+
node: { children, Icon = children?.length ? Folder : File, color },
|
|
161
|
+
}: Pick<TreeNodeProps, 'node'>) => {
|
|
162
|
+
return (
|
|
163
|
+
(Icon && (
|
|
164
|
+
<IconButton
|
|
165
|
+
Icon={Icon}
|
|
166
|
+
classNames={color ?? 'text-neutral-700 dark:text-neutral-300'}
|
|
167
|
+
weight={color ? 'duotone' : 'regular'}
|
|
168
|
+
/>
|
|
169
|
+
)) || <div />
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const MenuItem = ({ node: { id }, onMenuAction }: Pick<TreeNodeProps, 'node' | 'onMenuAction'>) => {
|
|
174
|
+
return (
|
|
175
|
+
<IconButton
|
|
176
|
+
Icon={Plus}
|
|
177
|
+
classNames='invisible group-hover:visible'
|
|
178
|
+
onClick={(ev) => {
|
|
179
|
+
ev.stopPropagation();
|
|
180
|
+
onMenuAction?.(id, 'create');
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// TODO(burdon): Editable.
|
|
187
|
+
export const Title = ({ node: { title } }: Pick<TreeNodeProps, 'node'>) => {
|
|
188
|
+
return (
|
|
189
|
+
<div className='grow p-1 text-sm whitespace-nowrap truncate text-neutral-800 dark:text-neutral-200'>{title}</div>
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const Grid = ({
|
|
194
|
+
className,
|
|
195
|
+
...props
|
|
196
|
+
}: PropsWithChildren<{ className?: string }> & HTMLAttributes<HTMLDivElement>) => {
|
|
197
|
+
// TODO(burdon): Density aware.
|
|
198
|
+
return <div className={mx('grid grid-cols-[24px_24px_1fr_24px_24px]', className)} {...props} />;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const TreeNodeRow = (props: TreeNodeProps & { className?: string }) => {
|
|
202
|
+
const {
|
|
203
|
+
node: { id },
|
|
204
|
+
className,
|
|
205
|
+
depth = 0,
|
|
206
|
+
selected,
|
|
207
|
+
onChangeSelected,
|
|
208
|
+
} = props;
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Grid
|
|
212
|
+
style={{ paddingLeft: `${(depth - 1) * 24}px` }}
|
|
213
|
+
className={mx(
|
|
214
|
+
'group w-full items-center cursor-pointer',
|
|
215
|
+
styles.hover,
|
|
216
|
+
selected?.[id] && styles.selected,
|
|
217
|
+
className,
|
|
218
|
+
)}
|
|
219
|
+
onClick={() => selected && onChangeSelected?.(id, !selected[id])}
|
|
220
|
+
>
|
|
221
|
+
<OpenIcon {...props} />
|
|
222
|
+
<ItemIcon {...props} />
|
|
223
|
+
<Title {...props} />
|
|
224
|
+
<MenuItem {...props} />
|
|
225
|
+
<StateIcon {...props} />
|
|
226
|
+
</Grid>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const TreeNode = (props: TreeNodeProps & { ancestors?: TreeNodeData[] }) => {
|
|
231
|
+
const {
|
|
232
|
+
node: { id, children },
|
|
233
|
+
depth = 0,
|
|
234
|
+
getSlots,
|
|
235
|
+
open,
|
|
236
|
+
ancestors = [],
|
|
237
|
+
} = props;
|
|
238
|
+
const { root, header } = getSlots?.(props.node, open?.[id] ?? false, depth, ancestors) ?? {};
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div role='none' className={mx('flex flex-col', root)}>
|
|
242
|
+
<TreeNodeRow {...props} className={header} />
|
|
243
|
+
{(children?.length ?? 0) > 0 && open?.[id] && <TreeChildNodes {...props} ancestors={ancestors} />}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
export const TreeChildNodes = ({
|
|
249
|
+
className,
|
|
250
|
+
...props
|
|
251
|
+
}: TreeNodeProps & { ancestors?: TreeNodeData[]; className?: string }) => {
|
|
252
|
+
const {
|
|
253
|
+
node: { children },
|
|
254
|
+
depth = 0,
|
|
255
|
+
ancestors = [],
|
|
256
|
+
} = props;
|
|
257
|
+
return (
|
|
258
|
+
<div className={mx('flex flex-col mt-0.5 gap-0.5', className)}>
|
|
259
|
+
{children?.map((child, i) => (
|
|
260
|
+
<TreeNode key={child.id} {...props} depth={depth + 1} node={child} ancestors={[...ancestors, props.node]} />
|
|
261
|
+
))}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export type TreeProps = { className?: string; showRoot?: boolean } & TreeNodeProps;
|
|
267
|
+
|
|
268
|
+
export const Tree = ({ className, showRoot, ...props }: TreeProps) => {
|
|
269
|
+
if (showRoot) {
|
|
270
|
+
return (
|
|
271
|
+
<div className={className}>
|
|
272
|
+
<TreeNode {...props} depth={0} />
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
} else {
|
|
276
|
+
return (
|
|
277
|
+
<div className={className}>
|
|
278
|
+
<TreeChildNodes {...props} className={className} depth={0} node={props.node} />
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
};
|
package/src/index.ts
ADDED
package/src/meta.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
|
|
7
|
+
import { batch } from '@preact/signals-core';
|
|
8
|
+
import React, { useMemo, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { Graph } from '@dxos/app-graph';
|
|
11
|
+
import { registerSignalRuntime } from '@dxos/echo-signals/react';
|
|
12
|
+
import { faker } from '@dxos/random';
|
|
13
|
+
import { DensityProvider, Tooltip, Treegrid } from '@dxos/react-ui';
|
|
14
|
+
import { Mosaic, Path, type MosaicDropEvent, type MosaicMoveEvent, type MosaicOperation } from '@dxos/react-ui-mosaic';
|
|
15
|
+
import { NavTree, type NavTreeItemNode, type OpenItemIds } from '@dxos/react-ui-navtree';
|
|
16
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
17
|
+
import { arrayMove } from '@dxos/util';
|
|
18
|
+
|
|
19
|
+
import { type NavTreeItemGraphNode, treeItemsFromRootNode } from '../util';
|
|
20
|
+
|
|
21
|
+
faker.seed(3);
|
|
22
|
+
registerSignalRuntime();
|
|
23
|
+
|
|
24
|
+
const createGraph = () => {
|
|
25
|
+
const graph = new Graph();
|
|
26
|
+
|
|
27
|
+
batch(() => {
|
|
28
|
+
(graph as any)._addNode({
|
|
29
|
+
id: ROOT_ID,
|
|
30
|
+
nodes: [...Array(2)].map((_, i) => ({
|
|
31
|
+
id: faker.string.hexadecimal({ length: 4 }).slice(2).toUpperCase(),
|
|
32
|
+
properties: { index: `a${i}`, label: faker.lorem.words(2), description: faker.lorem.sentence() },
|
|
33
|
+
nodes: [...Array(2)].map((_, j) => ({
|
|
34
|
+
id: faker.string.hexadecimal({ length: 4 }).slice(2).toUpperCase(),
|
|
35
|
+
properties: { index: `a${j}`, label: faker.lorem.words(2), description: faker.lorem.sentence() },
|
|
36
|
+
nodes: [...Array(2)].map((_, k) => ({
|
|
37
|
+
id: faker.string.hexadecimal({ length: 4 }).slice(2).toUpperCase(),
|
|
38
|
+
properties: { index: `a${k}`, label: faker.lorem.words(2), description: faker.lorem.sentence() },
|
|
39
|
+
})),
|
|
40
|
+
})),
|
|
41
|
+
})),
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return graph;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ROOT_ID = 'root';
|
|
49
|
+
const graph = createGraph();
|
|
50
|
+
|
|
51
|
+
const StorybookNavTree = ({ id = ROOT_ID }: { id?: string }) => {
|
|
52
|
+
const [openItemIds, setopenItemIds] = useState<OpenItemIds>({});
|
|
53
|
+
const items = useMemo(
|
|
54
|
+
() => treeItemsFromRootNode(graph, graph.root as NavTreeItemGraphNode, openItemIds),
|
|
55
|
+
[openItemIds],
|
|
56
|
+
);
|
|
57
|
+
const handleopenItemIdsChange = (item: NavTreeItemNode, nextOpen: boolean) => {
|
|
58
|
+
const itemId = item.path?.join(Treegrid.PATH_SEPARATOR) ?? 'never';
|
|
59
|
+
setopenItemIds((prevOpenItemIds) => {
|
|
60
|
+
if (nextOpen) {
|
|
61
|
+
return { ...prevOpenItemIds, [itemId]: true };
|
|
62
|
+
} else {
|
|
63
|
+
const { [itemId]: _, ...nextOpenItemIds } = prevOpenItemIds;
|
|
64
|
+
return nextOpenItemIds;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleOver = ({ active, over }: MosaicMoveEvent<number>): MosaicOperation => {
|
|
70
|
+
// Reject all operations that don’t match the graph’s root id
|
|
71
|
+
if (Path.first(active.path) !== id || Path.first(over.path) !== Path.first(active.path)) {
|
|
72
|
+
return 'reject';
|
|
73
|
+
}
|
|
74
|
+
// Rearrange if rearrange is supported and active and over are siblings
|
|
75
|
+
else if (Path.siblings(over.path, active.path)) {
|
|
76
|
+
return graph.findNode(Path.last(Path.parent(over.path)))?.properties.onRearrangeChildren ? 'rearrange' : 'reject';
|
|
77
|
+
}
|
|
78
|
+
// Rearrange if rearrange is supported and active is or would be a child of over
|
|
79
|
+
else if (Path.hasChild(over.path, active.path)) {
|
|
80
|
+
return graph.findNode(Path.last(over.path))?.properties.onRearrangeChildren ? 'rearrange' : 'reject';
|
|
81
|
+
}
|
|
82
|
+
// Check if transfer is supported
|
|
83
|
+
else {
|
|
84
|
+
const overNode = graph.findNode(Path.last(over.path));
|
|
85
|
+
const activeNode = graph.findNode(Path.last(active.path));
|
|
86
|
+
if (overNode && activeNode) {
|
|
87
|
+
return 'transfer';
|
|
88
|
+
} else {
|
|
89
|
+
return 'reject';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleDrop = ({ operation, active, over }: MosaicDropEvent<number>) => {
|
|
95
|
+
batch(() => {
|
|
96
|
+
const activeNode = graph.findNode(active.item.id);
|
|
97
|
+
const overNode = graph.findNode(over.item.id);
|
|
98
|
+
const activeParent = activeNode && graph.nodes(activeNode, { relation: 'inbound' })[0];
|
|
99
|
+
const overParent = overNode && graph.nodes(overNode, { relation: 'inbound' })[0];
|
|
100
|
+
if (
|
|
101
|
+
activeNode &&
|
|
102
|
+
overNode &&
|
|
103
|
+
activeParent &&
|
|
104
|
+
overParent &&
|
|
105
|
+
activeParent.id === overParent.id &&
|
|
106
|
+
activeNode.id !== overNode.id &&
|
|
107
|
+
operation === 'rearrange'
|
|
108
|
+
) {
|
|
109
|
+
const ids = graph.nodes(activeParent).map((node) => node.id);
|
|
110
|
+
const activeIndex = ids.indexOf(activeNode.id);
|
|
111
|
+
const overIndex = ids.indexOf(overNode.id);
|
|
112
|
+
(graph as any)._sortEdges(
|
|
113
|
+
activeParent.id,
|
|
114
|
+
'outbound',
|
|
115
|
+
arrayMove(ids, activeIndex, overIndex > -1 ? overIndex : ids.length - 1),
|
|
116
|
+
);
|
|
117
|
+
} else if (activeNode && activeParent && overParent && operation === 'transfer') {
|
|
118
|
+
(graph as any)._removeEdges([{ source: activeParent.id, target: activeNode.id }]);
|
|
119
|
+
(graph as any)._addEdges([{ source: overNode.id, target: activeNode.id }]);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<NavTree
|
|
126
|
+
id='storybook navtree'
|
|
127
|
+
items={items}
|
|
128
|
+
onItemOpenChange={handleopenItemIdsChange}
|
|
129
|
+
onDrop={handleDrop}
|
|
130
|
+
onOver={handleOver}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export default {
|
|
136
|
+
title: 'react-ui-navtree/Graph',
|
|
137
|
+
component: NavTree,
|
|
138
|
+
parameters: {
|
|
139
|
+
layout: 'fullscreen',
|
|
140
|
+
},
|
|
141
|
+
decorators: [
|
|
142
|
+
withTheme,
|
|
143
|
+
(Story: any) => (
|
|
144
|
+
<Tooltip.Provider>
|
|
145
|
+
<DensityProvider density='fine'>
|
|
146
|
+
<div role='none' className='p-2'>
|
|
147
|
+
<Story />
|
|
148
|
+
</div>
|
|
149
|
+
</DensityProvider>
|
|
150
|
+
</Tooltip.Provider>
|
|
151
|
+
),
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const Default = {
|
|
156
|
+
render: ({ debug }: { debug?: boolean }) => (
|
|
157
|
+
<Mosaic.Root debug={debug}>
|
|
158
|
+
<StorybookNavTree />
|
|
159
|
+
<Mosaic.DragOverlay />
|
|
160
|
+
</Mosaic.Root>
|
|
161
|
+
),
|
|
162
|
+
};
|