@dxos/plugin-navtree 0.6.14-main.2b6a0f3 → 0.6.14-main.f49f251
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/lib/browser/index.mjs +576 -386
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/NavTreePlugin.d.ts.map +1 -1
- package/dist/types/src/components/NavTree.d.ts +20 -0
- package/dist/types/src/components/NavTree.d.ts.map +1 -0
- package/dist/types/src/components/NavTree.stories.d.ts +7 -0
- package/dist/types/src/components/NavTree.stories.d.ts.map +1 -0
- package/dist/types/src/components/NavTreeContainer.d.ts +8 -8
- package/dist/types/src/components/NavTreeContainer.d.ts.map +1 -1
- package/dist/types/src/components/NavTreeItemAction.d.ts +23 -0
- package/dist/types/src/components/NavTreeItemAction.d.ts.map +1 -0
- package/dist/types/src/components/NavTreeItemAction.stories.d.ts +45 -0
- package/dist/types/src/components/NavTreeItemAction.stories.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/stories/Graph.stories.d.ts +1 -6
- package/dist/types/src/stories/Graph.stories.d.ts.map +1 -1
- package/dist/types/src/testing/generator.d.ts +86 -0
- package/dist/types/src/testing/generator.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +3 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/visitor.d.ts +18 -0
- package/dist/types/src/testing/visitor.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +5 -10
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +41 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/src/util.d.ts +8 -9
- package/dist/types/src/util.d.ts.map +1 -1
- package/package.json +36 -33
- package/src/NavTreePlugin.tsx +88 -33
- package/src/components/NavTree.stories.tsx +104 -0
- package/src/components/NavTree.tsx +127 -0
- package/src/components/NavTreeContainer.tsx +141 -325
- package/src/components/NavTreeItemAction.stories.tsx +57 -0
- package/src/components/NavTreeItemAction.tsx +367 -0
- package/src/components/index.ts +1 -0
- package/src/experimental/Tree.stories.tsx +1 -1
- package/src/stories/Graph.stories.tsx +104 -98
- package/src/testing/generator.ts +199 -0
- package/src/testing/index.ts +6 -0
- package/src/testing/visitor.ts +152 -0
- package/src/translations.ts +3 -3
- package/src/types.ts +50 -0
- package/src/util.ts +52 -63
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
|
6
|
+
import React, { type MutableRefObject, type PropsWithChildren, useRef, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type Action, type ActionLike, type Node } from '@dxos/app-graph';
|
|
9
|
+
import { keySymbols } from '@dxos/keyboard';
|
|
10
|
+
import {
|
|
11
|
+
Button,
|
|
12
|
+
Dialog,
|
|
13
|
+
DropdownMenu,
|
|
14
|
+
ContextMenu,
|
|
15
|
+
Tooltip,
|
|
16
|
+
useTranslation,
|
|
17
|
+
toLocalizedString,
|
|
18
|
+
Icon,
|
|
19
|
+
} from '@dxos/react-ui';
|
|
20
|
+
import { SearchList } from '@dxos/react-ui-searchlist';
|
|
21
|
+
import { descriptionText, hoverableControlItem, hoverableOpenControlItem, mx } from '@dxos/react-ui-theme';
|
|
22
|
+
import { getHostPlatform } from '@dxos/util';
|
|
23
|
+
|
|
24
|
+
import { NAVTREE_PLUGIN } from '../meta';
|
|
25
|
+
import { type ActionProperties } from '../types';
|
|
26
|
+
|
|
27
|
+
export type NavTreeItemActionMenuProps = ActionProperties & {
|
|
28
|
+
parent: Node;
|
|
29
|
+
caller?: string;
|
|
30
|
+
monolithic?: boolean;
|
|
31
|
+
menuActions?: Action[];
|
|
32
|
+
suppressNextTooltip?: MutableRefObject<boolean>;
|
|
33
|
+
menuOpen?: boolean;
|
|
34
|
+
defaultMenuOpen?: boolean;
|
|
35
|
+
onChangeMenuOpen?: (nextOpen: boolean) => void;
|
|
36
|
+
onAction?: (action: Action) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getShortcut = (action: ActionLike) => {
|
|
40
|
+
return typeof action.properties?.keyBinding === 'string'
|
|
41
|
+
? action.properties.keyBinding
|
|
42
|
+
: action.properties?.keyBinding?.[getHostPlatform()];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const fallbackIcon = 'ph--placeholder--regular';
|
|
46
|
+
|
|
47
|
+
export const NavTreeItemActionDropdownMenu = ({
|
|
48
|
+
label,
|
|
49
|
+
icon,
|
|
50
|
+
testId,
|
|
51
|
+
defaultMenuOpen,
|
|
52
|
+
menuActions,
|
|
53
|
+
menuOpen,
|
|
54
|
+
suppressNextTooltip,
|
|
55
|
+
onChangeMenuOpen,
|
|
56
|
+
onAction,
|
|
57
|
+
}: NavTreeItemActionMenuProps) => {
|
|
58
|
+
const { t } = useTranslation(NAVTREE_PLUGIN);
|
|
59
|
+
|
|
60
|
+
const [optionsMenuOpen, setOptionsMenuOpen] = useControllableState({
|
|
61
|
+
prop: menuOpen,
|
|
62
|
+
defaultProp: defaultMenuOpen,
|
|
63
|
+
onChange: onChangeMenuOpen,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<DropdownMenu.Root
|
|
68
|
+
{...{
|
|
69
|
+
open: optionsMenuOpen,
|
|
70
|
+
onOpenChange: (nextOpen: boolean) => {
|
|
71
|
+
if (!nextOpen && suppressNextTooltip) {
|
|
72
|
+
suppressNextTooltip.current = true;
|
|
73
|
+
}
|
|
74
|
+
return setOptionsMenuOpen(nextOpen);
|
|
75
|
+
},
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Tooltip.Trigger asChild>
|
|
79
|
+
<DropdownMenu.Trigger asChild>
|
|
80
|
+
<Button
|
|
81
|
+
variant='ghost'
|
|
82
|
+
density='fine'
|
|
83
|
+
classNames={mx('shrink-0 !pli-2 pointer-fine:!pli-1', hoverableControlItem, hoverableOpenControlItem)}
|
|
84
|
+
data-testid={testId}
|
|
85
|
+
aria-label={t('tree item actions label')}
|
|
86
|
+
>
|
|
87
|
+
<span className='sr-only'>{toLocalizedString(label, t)}</span>
|
|
88
|
+
<Icon icon={icon ?? fallbackIcon} size={4} />
|
|
89
|
+
</Button>
|
|
90
|
+
</DropdownMenu.Trigger>
|
|
91
|
+
</Tooltip.Trigger>
|
|
92
|
+
<DropdownMenu.Portal>
|
|
93
|
+
<DropdownMenu.Content classNames='z-[31]'>
|
|
94
|
+
<DropdownMenu.Viewport>
|
|
95
|
+
{menuActions?.map((action) => {
|
|
96
|
+
const shortcut = getShortcut(action);
|
|
97
|
+
return (
|
|
98
|
+
<DropdownMenu.Item
|
|
99
|
+
key={action.id}
|
|
100
|
+
onClick={(event) => {
|
|
101
|
+
if (action.properties?.disabled) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
event.stopPropagation();
|
|
105
|
+
// TODO(thure): Why does Dialog’s modal-ness cause issues if we don’t explicitly close the menu here?
|
|
106
|
+
if (suppressNextTooltip) {
|
|
107
|
+
suppressNextTooltip.current = true;
|
|
108
|
+
}
|
|
109
|
+
setOptionsMenuOpen(false);
|
|
110
|
+
onAction?.(action);
|
|
111
|
+
}}
|
|
112
|
+
classNames='gap-2'
|
|
113
|
+
disabled={action.properties?.disabled}
|
|
114
|
+
{...(action.properties?.testId && { 'data-testid': action.properties.testId })}
|
|
115
|
+
>
|
|
116
|
+
{action.properties?.icon && <Icon icon={action.properties!.icon} size={4} />}
|
|
117
|
+
<span className='grow truncate'>{toLocalizedString(action.properties!.label, t)}</span>
|
|
118
|
+
{shortcut && <span className={mx('shrink-0', descriptionText)}>{keySymbols(shortcut).join('')}</span>}
|
|
119
|
+
</DropdownMenu.Item>
|
|
120
|
+
);
|
|
121
|
+
})}
|
|
122
|
+
</DropdownMenu.Viewport>
|
|
123
|
+
<DropdownMenu.Arrow />
|
|
124
|
+
</DropdownMenu.Content>
|
|
125
|
+
</DropdownMenu.Portal>
|
|
126
|
+
</DropdownMenu.Root>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const NavTreeItemActionContextMenu = (
|
|
131
|
+
props: PropsWithChildren<Pick<NavTreeItemActionMenuProps, 'menuActions' | 'onAction'>>,
|
|
132
|
+
) => {
|
|
133
|
+
return (props.menuActions?.length ?? 0) > 0 ? <NavTreeItemActionContextMenuImpl {...props} /> : <>{props.children}</>;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const NavTreeItemActionContextMenuImpl = ({
|
|
137
|
+
menuActions,
|
|
138
|
+
onAction,
|
|
139
|
+
children,
|
|
140
|
+
}: PropsWithChildren<Pick<NavTreeItemActionMenuProps, 'menuActions' | 'onAction'>>) => {
|
|
141
|
+
const { t } = useTranslation(NAVTREE_PLUGIN);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<ContextMenu.Root>
|
|
145
|
+
<ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>
|
|
146
|
+
<ContextMenu.Portal>
|
|
147
|
+
<ContextMenu.Content classNames='z-[31]'>
|
|
148
|
+
<ContextMenu.Viewport>
|
|
149
|
+
{menuActions?.map((action) => {
|
|
150
|
+
const shortcut = getShortcut(action);
|
|
151
|
+
return (
|
|
152
|
+
<ContextMenu.Item
|
|
153
|
+
key={action.id}
|
|
154
|
+
onClick={(event) => {
|
|
155
|
+
if (action.properties?.disabled) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
event.stopPropagation();
|
|
159
|
+
onAction?.(action);
|
|
160
|
+
}}
|
|
161
|
+
classNames='gap-2'
|
|
162
|
+
disabled={action.properties?.disabled}
|
|
163
|
+
{...(action.properties?.testId && { 'data-testid': action.properties.testId })}
|
|
164
|
+
>
|
|
165
|
+
{action.properties?.icon && <Icon icon={action.properties?.icon} size={4} />}
|
|
166
|
+
<span className='grow truncate'>{toLocalizedString(action.properties!.label, t)}</span>
|
|
167
|
+
{shortcut && <span className={mx('shrink-0', descriptionText)}>{keySymbols(shortcut).join('')}</span>}
|
|
168
|
+
</ContextMenu.Item>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</ContextMenu.Viewport>
|
|
172
|
+
<ContextMenu.Arrow />
|
|
173
|
+
</ContextMenu.Content>
|
|
174
|
+
</ContextMenu.Portal>
|
|
175
|
+
</ContextMenu.Root>
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const NavTreeItemActionSearchList = ({
|
|
180
|
+
menuActions,
|
|
181
|
+
icon,
|
|
182
|
+
label,
|
|
183
|
+
testId,
|
|
184
|
+
suppressNextTooltip,
|
|
185
|
+
onAction,
|
|
186
|
+
}: Pick<
|
|
187
|
+
NavTreeItemActionMenuProps,
|
|
188
|
+
'icon' | 'menuActions' | 'testId' | 'label' | 'onAction' | 'suppressNextTooltip'
|
|
189
|
+
>) => {
|
|
190
|
+
const { t } = useTranslation(NAVTREE_PLUGIN);
|
|
191
|
+
|
|
192
|
+
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
|
|
193
|
+
const button = useRef<HTMLButtonElement | null>(null);
|
|
194
|
+
|
|
195
|
+
// TODO(burdon): Optionally sort.
|
|
196
|
+
const sortedActions = menuActions?.sort(({ properties: p1 }, { properties: p2 }) => {
|
|
197
|
+
const t1 = toLocalizedString(p1!.label, t).toLowerCase();
|
|
198
|
+
const t2 = toLocalizedString(p2!.label, t).toLowerCase();
|
|
199
|
+
return t1.localeCompare(t2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// TODO(thure): Use LayoutPlugin’s global Dialog.
|
|
203
|
+
return (
|
|
204
|
+
<Dialog.Root
|
|
205
|
+
{...{
|
|
206
|
+
open: optionsMenuOpen,
|
|
207
|
+
onOpenChange: (nextOpen: boolean) => {
|
|
208
|
+
if (!nextOpen && suppressNextTooltip) {
|
|
209
|
+
suppressNextTooltip.current = true;
|
|
210
|
+
}
|
|
211
|
+
return setOptionsMenuOpen(nextOpen);
|
|
212
|
+
},
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<Tooltip.Trigger asChild>
|
|
216
|
+
<Dialog.Trigger asChild>
|
|
217
|
+
<Button
|
|
218
|
+
variant='ghost'
|
|
219
|
+
density='fine'
|
|
220
|
+
classNames={mx('shrink-0 !pli-2 pointer-fine:!pli-1', hoverableControlItem, hoverableOpenControlItem)}
|
|
221
|
+
data-testid={testId}
|
|
222
|
+
onKeyDownCapture={(event) => {
|
|
223
|
+
// TODO(thure): Why is only this `Button` affected and not `DropdownMenu.Trigger`’s?
|
|
224
|
+
if (button.current && button.current === event.target) {
|
|
225
|
+
switch (event.key) {
|
|
226
|
+
case 'Enter':
|
|
227
|
+
case ' ':
|
|
228
|
+
// Capture this event and stop propagation to the drag start handler.
|
|
229
|
+
event.stopPropagation();
|
|
230
|
+
setOptionsMenuOpen(true);
|
|
231
|
+
break;
|
|
232
|
+
default:
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}}
|
|
236
|
+
ref={button}
|
|
237
|
+
>
|
|
238
|
+
<Icon icon={icon ?? fallbackIcon} size={4} />
|
|
239
|
+
</Button>
|
|
240
|
+
</Dialog.Trigger>
|
|
241
|
+
</Tooltip.Trigger>
|
|
242
|
+
<Dialog.Portal>
|
|
243
|
+
<Dialog.Overlay>
|
|
244
|
+
<Dialog.Content classNames='z-[31] is-full max-is-[24rem] pli-2 plb-1'>
|
|
245
|
+
<SearchList.Root label={t('tree item searchlist input placeholder')}>
|
|
246
|
+
<SearchList.Input placeholder={t('tree item searchlist input placeholder')} classNames='pli-3' />
|
|
247
|
+
<SearchList.Content classNames='min-bs-[12rem] bs-[50dvh] max-bs-[30rem] overflow-auto'>
|
|
248
|
+
{sortedActions?.map((action) => {
|
|
249
|
+
const label = toLocalizedString(action.properties!.label, t);
|
|
250
|
+
const shortcut = getShortcut(action);
|
|
251
|
+
return (
|
|
252
|
+
<SearchList.Item
|
|
253
|
+
// TODO(burdon): Value should be id since label is not unique.
|
|
254
|
+
value={label}
|
|
255
|
+
key={action.id}
|
|
256
|
+
onSelect={() => {
|
|
257
|
+
if (action.properties?.disabled) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (suppressNextTooltip) {
|
|
261
|
+
suppressNextTooltip.current = true;
|
|
262
|
+
}
|
|
263
|
+
setOptionsMenuOpen(false);
|
|
264
|
+
onAction?.(action);
|
|
265
|
+
}}
|
|
266
|
+
classNames='flex items-center gap-2 pli-2'
|
|
267
|
+
disabled={action.properties?.disabled}
|
|
268
|
+
{...(action.properties?.testId && { 'data-testid': action.properties.testId })}
|
|
269
|
+
>
|
|
270
|
+
{action.properties?.icon && <Icon icon={action.properties?.icon} size={4} />}
|
|
271
|
+
<span className='grow truncate'>{label}</span>
|
|
272
|
+
{shortcut && (
|
|
273
|
+
<span className={mx('shrink-0', descriptionText)}>{keySymbols(shortcut).join('')}</span>
|
|
274
|
+
)}
|
|
275
|
+
</SearchList.Item>
|
|
276
|
+
);
|
|
277
|
+
})}
|
|
278
|
+
</SearchList.Content>
|
|
279
|
+
<div role='none' className='flex items-center plb-2 pli-3'>
|
|
280
|
+
<span className={descriptionText}>{toLocalizedString(label, t)}</span>
|
|
281
|
+
</div>
|
|
282
|
+
</SearchList.Root>
|
|
283
|
+
</Dialog.Content>
|
|
284
|
+
</Dialog.Overlay>
|
|
285
|
+
</Dialog.Portal>
|
|
286
|
+
</Dialog.Root>
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
export const NavTreeItemMonolithicAction = ({
|
|
291
|
+
parent,
|
|
292
|
+
properties: { disabled, caller, testId, label, icon } = { label: 'never' },
|
|
293
|
+
data: invoke,
|
|
294
|
+
}: Action & { parent: Node; onAction?: (action: Action) => void }) => {
|
|
295
|
+
const { t } = useTranslation(NAVTREE_PLUGIN);
|
|
296
|
+
return (
|
|
297
|
+
<Tooltip.Trigger asChild>
|
|
298
|
+
<Button
|
|
299
|
+
variant='ghost'
|
|
300
|
+
density='fine'
|
|
301
|
+
classNames={mx('shrink-0 !pli-2 pointer-fine:!pli-1', hoverableControlItem, hoverableOpenControlItem)}
|
|
302
|
+
disabled={disabled}
|
|
303
|
+
onClick={(event) => {
|
|
304
|
+
if (disabled) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
event.stopPropagation();
|
|
308
|
+
void invoke?.(caller ? { node: parent, caller } : { node: parent });
|
|
309
|
+
}}
|
|
310
|
+
data-testid={testId}
|
|
311
|
+
>
|
|
312
|
+
<span className='sr-only'>{toLocalizedString(label, t)}</span>
|
|
313
|
+
<Icon icon={icon ?? fallbackIcon} size={4} />
|
|
314
|
+
</Button>
|
|
315
|
+
</Tooltip.Trigger>
|
|
316
|
+
);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
export const NavTreeItemAction = ({ monolithic, menuActions, parent: node, ...props }: NavTreeItemActionMenuProps) => {
|
|
320
|
+
const { t } = useTranslation(NAVTREE_PLUGIN);
|
|
321
|
+
const suppressNextTooltip = useRef<boolean>(false);
|
|
322
|
+
const [triggerTooltipOpen, setTriggerTooltipOpen] = useState(false);
|
|
323
|
+
|
|
324
|
+
const monolithicAction = menuActions?.length === 1 && menuActions[0];
|
|
325
|
+
const baseLabel = toLocalizedString(monolithicAction ? monolithicAction.properties!.label : props.label, t);
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<Tooltip.Root
|
|
329
|
+
open={triggerTooltipOpen}
|
|
330
|
+
onOpenChange={(nextOpen) => {
|
|
331
|
+
if (suppressNextTooltip.current) {
|
|
332
|
+
setTriggerTooltipOpen(false);
|
|
333
|
+
suppressNextTooltip.current = false;
|
|
334
|
+
} else {
|
|
335
|
+
setTriggerTooltipOpen(nextOpen);
|
|
336
|
+
}
|
|
337
|
+
}}
|
|
338
|
+
>
|
|
339
|
+
{baseLabel && (
|
|
340
|
+
<Tooltip.Portal>
|
|
341
|
+
<Tooltip.Content classNames='z-[31]' side='bottom'>
|
|
342
|
+
{baseLabel}
|
|
343
|
+
<Tooltip.Arrow />
|
|
344
|
+
</Tooltip.Content>
|
|
345
|
+
</Tooltip.Portal>
|
|
346
|
+
)}
|
|
347
|
+
{monolithic && menuActions?.length === 1 ? (
|
|
348
|
+
<NavTreeItemMonolithicAction parent={node} {...menuActions[0]} />
|
|
349
|
+
) : props.menuType === 'searchList' ? (
|
|
350
|
+
<NavTreeItemActionSearchList
|
|
351
|
+
{...props}
|
|
352
|
+
menuActions={menuActions}
|
|
353
|
+
suppressNextTooltip={suppressNextTooltip}
|
|
354
|
+
onAction={(action) => action.data?.(props.caller ? { node, caller: props.caller } : { node })}
|
|
355
|
+
/>
|
|
356
|
+
) : (
|
|
357
|
+
<NavTreeItemActionDropdownMenu
|
|
358
|
+
{...props}
|
|
359
|
+
parent={node}
|
|
360
|
+
menuActions={menuActions}
|
|
361
|
+
suppressNextTooltip={suppressNextTooltip}
|
|
362
|
+
onAction={(action) => action.data?.(props.caller ? { node, caller: props.caller } : { node })}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
365
|
+
</Tooltip.Root>
|
|
366
|
+
);
|
|
367
|
+
};
|
package/src/components/index.ts
CHANGED
|
@@ -224,7 +224,7 @@ const Sidebar = ({ mutate }: { mutate?: boolean }) => {
|
|
|
224
224
|
};
|
|
225
225
|
|
|
226
226
|
const meta: Meta = {
|
|
227
|
-
title: 'plugins/plugin-navtree/Tree',
|
|
227
|
+
title: 'plugins/plugin-navtree/experimental/Tree',
|
|
228
228
|
component: Tree,
|
|
229
229
|
decorators: [withTheme, withLayout({ fullscreen: true, classNames: modalSurface })],
|
|
230
230
|
};
|
|
@@ -4,20 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
import '@dxos-theme';
|
|
6
6
|
|
|
7
|
+
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
|
8
|
+
import { extractInstruction, type Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
|
|
7
9
|
import { batch } from '@preact/signals-core';
|
|
8
10
|
import { type Meta } from '@storybook/react';
|
|
9
|
-
import React, {
|
|
11
|
+
import React, { useEffect } from 'react';
|
|
10
12
|
|
|
11
|
-
import { Graph } from '@dxos/app-graph';
|
|
13
|
+
import { Graph, ROOT_ID, type NodeFilter } from '@dxos/app-graph';
|
|
14
|
+
import { create } from '@dxos/echo-schema';
|
|
12
15
|
import { registerSignalsRuntime } from '@dxos/echo-signals/react';
|
|
16
|
+
import { invariant } from '@dxos/invariant';
|
|
13
17
|
import { faker } from '@dxos/random';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { NavTree, type NavTreeItemNode, type OpenItemIds } from '@dxos/react-ui-navtree';
|
|
18
|
+
import { isItem } from '@dxos/react-ui-list';
|
|
19
|
+
import { Path } from '@dxos/react-ui-mosaic';
|
|
17
20
|
import { withLayout, withTheme } from '@dxos/storybook-utils';
|
|
18
|
-
import { arrayMove } from '@dxos/util';
|
|
19
21
|
|
|
20
|
-
import { type
|
|
22
|
+
import { NavTree, type NavTreeProps } from '../components';
|
|
23
|
+
import { type NavTreeItem } from '../types';
|
|
24
|
+
import { getActions, getChildren, type NavTreeItemGraphNode, treeItemsFromRootNode } from '../util';
|
|
21
25
|
|
|
22
26
|
faker.seed(3);
|
|
23
27
|
registerSignalsRuntime();
|
|
@@ -46,106 +50,108 @@ const createGraph = () => {
|
|
|
46
50
|
return graph;
|
|
47
51
|
};
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
type State = {
|
|
54
|
+
flatTree: NavTreeItem[];
|
|
55
|
+
open: string[];
|
|
56
|
+
current: string[];
|
|
57
|
+
};
|
|
58
|
+
|
|
50
59
|
const graph = createGraph();
|
|
60
|
+
const itemCache = new Map<string, NavTreeItem>();
|
|
61
|
+
const state = create<State>({
|
|
62
|
+
get flatTree() {
|
|
63
|
+
return treeItemsFromRootNode(graph, graph.root, this.open, getItem);
|
|
64
|
+
},
|
|
65
|
+
open: [],
|
|
66
|
+
current: [],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const getItem = (node: NavTreeItemGraphNode, parent: readonly string[], filter?: NodeFilter) => {
|
|
70
|
+
invariant(graph);
|
|
71
|
+
const path = [...parent, node.id];
|
|
72
|
+
const { actions, groupedActions } = getActions(graph, node);
|
|
73
|
+
const children = getChildren(graph, node, filter, path);
|
|
74
|
+
const parentOf =
|
|
75
|
+
children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
|
|
76
|
+
const item = {
|
|
77
|
+
id: node.id,
|
|
78
|
+
label: node.properties.label ?? node.id,
|
|
79
|
+
icon: node.properties.icon,
|
|
80
|
+
path,
|
|
81
|
+
parentOf,
|
|
82
|
+
node,
|
|
83
|
+
actions,
|
|
84
|
+
groupedActions,
|
|
85
|
+
} satisfies NavTreeItem;
|
|
86
|
+
|
|
87
|
+
const cachedItem = itemCache.get(node.id);
|
|
88
|
+
// TODO(wittjosiah): This is not a good enough check.
|
|
89
|
+
// Consider better ways to doing reactive transformations which retain referential equality.
|
|
90
|
+
if (cachedItem && JSON.stringify(item) === JSON.stringify(cachedItem)) {
|
|
91
|
+
return cachedItem;
|
|
92
|
+
} else {
|
|
93
|
+
itemCache.set(node.id, item);
|
|
94
|
+
return item;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
51
97
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
const items =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
98
|
+
const Story = (args: Partial<NavTreeProps>) => {
|
|
99
|
+
// NOTE: If passed directly to args, this won't be reactive.
|
|
100
|
+
const items = state.flatTree;
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
return monitorForElements({
|
|
104
|
+
canMonitor: ({ source }) => isItem(source.data),
|
|
105
|
+
onDrop: ({ location, source }) => {
|
|
106
|
+
// Didn't drop on anything.
|
|
107
|
+
if (!location.current.dropTargets.length) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const target = location.current.dropTargets[0];
|
|
112
|
+
|
|
113
|
+
const instruction: Instruction | null = extractInstruction(target.data);
|
|
114
|
+
if (instruction !== null) {
|
|
115
|
+
// TODO(wittjosiah): Implement drop logic.
|
|
116
|
+
console.log('update', instruction);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
67
119
|
});
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const handleOver = ({ active, over }: MosaicMoveEvent<number>): MosaicOperation => {
|
|
71
|
-
// Reject all operations that don’t match the graph’s root id
|
|
72
|
-
if (Path.first(active.path) !== id || Path.first(over.path) !== Path.first(active.path)) {
|
|
73
|
-
return 'reject';
|
|
74
|
-
}
|
|
75
|
-
// Rearrange if rearrange is supported and active and over are siblings
|
|
76
|
-
else if (Path.siblings(over.path, active.path)) {
|
|
77
|
-
return graph.findNode(Path.last(Path.parent(over.path)))?.properties.onRearrangeChildren ? 'rearrange' : 'reject';
|
|
78
|
-
}
|
|
79
|
-
// Rearrange if rearrange is supported and active is or would be a child of over
|
|
80
|
-
else if (Path.hasChild(over.path, active.path)) {
|
|
81
|
-
return graph.findNode(Path.last(over.path))?.properties.onRearrangeChildren ? 'rearrange' : 'reject';
|
|
82
|
-
}
|
|
83
|
-
// Check if transfer is supported
|
|
84
|
-
else {
|
|
85
|
-
const overNode = graph.findNode(Path.last(over.path));
|
|
86
|
-
const activeNode = graph.findNode(Path.last(active.path));
|
|
87
|
-
if (overNode && activeNode) {
|
|
88
|
-
return 'transfer';
|
|
89
|
-
} else {
|
|
90
|
-
return 'reject';
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const handleDrop = ({ operation, active, over }: MosaicDropEvent<number>) => {
|
|
96
|
-
batch(() => {
|
|
97
|
-
const activeNode = graph.findNode(active.item.id);
|
|
98
|
-
const overNode = graph.findNode(over.item.id);
|
|
99
|
-
const activeParent = activeNode && graph.nodes(activeNode, { relation: 'inbound' })[0];
|
|
100
|
-
const overParent = overNode && graph.nodes(overNode, { relation: 'inbound' })[0];
|
|
101
|
-
if (
|
|
102
|
-
activeNode &&
|
|
103
|
-
overNode &&
|
|
104
|
-
activeParent &&
|
|
105
|
-
overParent &&
|
|
106
|
-
activeParent.id === overParent.id &&
|
|
107
|
-
activeNode.id !== overNode.id &&
|
|
108
|
-
operation === 'rearrange'
|
|
109
|
-
) {
|
|
110
|
-
const ids = graph.nodes(activeParent).map((node) => node.id);
|
|
111
|
-
const activeIndex = ids.indexOf(activeNode.id);
|
|
112
|
-
const overIndex = ids.indexOf(overNode.id);
|
|
113
|
-
(graph as any)._sortEdges(
|
|
114
|
-
activeParent.id,
|
|
115
|
-
'outbound',
|
|
116
|
-
arrayMove(ids, activeIndex, overIndex > -1 ? overIndex : ids.length - 1),
|
|
117
|
-
);
|
|
118
|
-
} else if (activeNode && activeParent && overParent && operation === 'transfer') {
|
|
119
|
-
(graph as any)._removeEdges([{ source: activeParent.id, target: activeNode.id }]);
|
|
120
|
-
(graph as any)._addEdges([{ source: overNode.id, target: activeNode.id }]);
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
return (
|
|
126
|
-
<NavTree
|
|
127
|
-
id='storybook navtree'
|
|
128
|
-
items={items}
|
|
129
|
-
onItemOpenChange={handleopenItemIdsChange}
|
|
130
|
-
onDrop={handleDrop}
|
|
131
|
-
onOver={handleOver}
|
|
132
|
-
/>
|
|
133
|
-
);
|
|
134
|
-
};
|
|
120
|
+
}, []);
|
|
135
121
|
|
|
136
|
-
|
|
137
|
-
render: ({ debug }: { debug?: boolean }) => (
|
|
138
|
-
<Mosaic.Root debug={debug}>
|
|
139
|
-
<StorybookNavTree />
|
|
140
|
-
<Mosaic.DragOverlay />
|
|
141
|
-
</Mosaic.Root>
|
|
142
|
-
),
|
|
122
|
+
return <NavTree items={items} open={state.open} current={state.current} {...args} />;
|
|
143
123
|
};
|
|
144
124
|
|
|
125
|
+
export const Default = {};
|
|
126
|
+
|
|
145
127
|
const meta: Meta = {
|
|
146
128
|
title: 'plugins/plugin-navtree/Graph',
|
|
147
129
|
component: NavTree,
|
|
148
|
-
|
|
130
|
+
render: Story,
|
|
131
|
+
decorators: [withTheme, withLayout({ tooltips: true })],
|
|
132
|
+
args: {
|
|
133
|
+
onOpenChange: (item: NavTreeItem, open: boolean) => {
|
|
134
|
+
const path = Path.create(...item.path);
|
|
135
|
+
if (open) {
|
|
136
|
+
state.open.push(path);
|
|
137
|
+
} else {
|
|
138
|
+
const index = state.open.indexOf(path);
|
|
139
|
+
if (index > -1) {
|
|
140
|
+
state.open.splice(index, 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
onSelect: (item: NavTreeItem, current: boolean) => {
|
|
145
|
+
if (current) {
|
|
146
|
+
state.current.push(item.id);
|
|
147
|
+
} else {
|
|
148
|
+
const index = state.current.indexOf(item.id);
|
|
149
|
+
if (index > -1) {
|
|
150
|
+
state.current.splice(index, 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
},
|
|
149
155
|
};
|
|
150
156
|
|
|
151
157
|
export default meta;
|