@dxos/react-ui-list 0.9.1-main.c7dcc2e112 → 0.10.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/lib/browser/index.mjs +628 -167
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +628 -167
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/aspects/useListDisclosure.d.ts +4 -4
- package/dist/types/src/aspects/useListDisclosure.d.ts.map +1 -1
- package/dist/types/src/aspects/useListNavigation.d.ts +9 -7
- package/dist/types/src/aspects/useListNavigation.d.ts.map +1 -1
- package/dist/types/src/aspects/useListSelection.d.ts +3 -3
- package/dist/types/src/aspects/useListSelection.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
- package/dist/types/src/components/Accordion/AccordionItem.d.ts +3 -1
- package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
- package/dist/types/src/components/Combobox/Combobox.d.ts +10 -3
- package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/types/src/components/DropIndicator/DropIndicator.d.ts +12 -0
- package/dist/types/src/components/DropIndicator/DropIndicator.d.ts.map +1 -0
- package/dist/types/src/components/DropIndicator/index.d.ts +2 -0
- package/dist/types/src/components/DropIndicator/index.d.ts.map +1 -0
- package/dist/types/src/components/Empty/Empty.d.ts +17 -0
- package/dist/types/src/components/Empty/Empty.d.ts.map +1 -0
- package/dist/types/src/components/Empty/Empty.stories.d.ts +18 -0
- package/dist/types/src/components/Empty/Empty.stories.d.ts.map +1 -0
- package/dist/types/src/components/Empty/index.d.ts +2 -0
- package/dist/types/src/components/Empty/index.d.ts.map +1 -0
- package/dist/types/src/components/List.theme.d.ts +193 -0
- package/dist/types/src/components/List.theme.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/ListItemContent.d.ts +27 -0
- package/dist/types/src/components/Listbox/ListItemContent.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts +24 -0
- package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/Listbox.d.ts +19 -2
- package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts +4 -0
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -1
- package/dist/types/src/components/OrderedList/OrderedList.d.ts +1 -1
- package/dist/types/src/components/OrderedList/OrderedList.d.ts.map +1 -1
- package/dist/types/src/components/OrderedList/OrderedListItem.d.ts.map +1 -1
- package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts +1 -1
- package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts.map +1 -1
- package/dist/types/src/components/Picker/Picker.d.ts +12 -4
- package/dist/types/src/components/Picker/Picker.d.ts.map +1 -1
- package/dist/types/src/components/Picker/index.d.ts +1 -1
- package/dist/types/src/components/Picker/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/Tree.d.ts +1 -1
- package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -2
- package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeContext.d.ts +2 -0
- package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeDropIndicator.d.ts +10 -0
- package/dist/types/src/components/Tree/TreeDropIndicator.d.ts.map +1 -0
- package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
- package/dist/types/src/components/Tree/testing.d.ts +4 -1
- package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
- package/dist/types/src/components/Treegrid/Treegrid.d.ts +33 -0
- package/dist/types/src/components/Treegrid/Treegrid.d.ts.map +1 -0
- package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts +11 -0
- package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts.map +1 -0
- package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts +37 -0
- package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts.map +1 -0
- package/dist/types/src/components/Treegrid/index.d.ts +2 -0
- package/dist/types/src/components/Treegrid/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +3 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -22
- package/src/aspects/useListDisclosure.ts +8 -8
- package/src/aspects/useListNavigation.ts +9 -7
- package/src/aspects/useListSelection.ts +4 -4
- package/src/components/Accordion/AccordionItem.tsx +31 -13
- package/src/components/Combobox/Combobox.tsx +46 -38
- package/src/components/DropIndicator/DropIndicator.tsx +74 -0
- package/src/components/DropIndicator/index.ts +5 -0
- package/src/components/Empty/Empty.stories.tsx +37 -0
- package/src/components/Empty/Empty.tsx +41 -0
- package/src/components/Empty/index.ts +5 -0
- package/src/components/List.theme.ts +99 -0
- package/src/components/Listbox/ListItemContent.stories.tsx +41 -0
- package/src/components/Listbox/ListItemContent.tsx +49 -0
- package/src/components/Listbox/Listbox.stories.tsx +106 -7
- package/src/components/Listbox/Listbox.tsx +85 -42
- package/src/components/OrderedList/OrderedList.tsx +1 -1
- package/src/components/OrderedList/OrderedListItem.tsx +13 -13
- package/src/components/OrderedList/OrderedListRoot.tsx +6 -3
- package/src/components/Picker/Picker.tsx +25 -15
- package/src/components/Picker/index.ts +1 -1
- package/src/components/Tree/Tree.stories.tsx +39 -15
- package/src/components/Tree/Tree.tsx +13 -3
- package/src/components/Tree/TreeContext.tsx +2 -0
- package/src/components/Tree/TreeDropIndicator.tsx +72 -0
- package/src/components/Tree/TreeItem.tsx +43 -3
- package/src/components/Tree/TreeItemHeading.tsx +2 -2
- package/src/components/Tree/testing.ts +19 -14
- package/src/components/Treegrid/Treegrid.stories.tsx +132 -0
- package/src/components/Treegrid/Treegrid.theme.ts +38 -0
- package/src/components/Treegrid/Treegrid.tsx +187 -0
- package/src/components/Treegrid/index.ts +5 -0
- package/src/components/index.ts +3 -0
|
@@ -17,13 +17,16 @@ import { mx } from '@dxos/ui-theme';
|
|
|
17
17
|
import {
|
|
18
18
|
type ReorderActive,
|
|
19
19
|
type ReorderListController,
|
|
20
|
-
type UseListNavigationReturn,
|
|
21
20
|
type UseListDisclosureReturn,
|
|
21
|
+
type UseListNavigationReturn,
|
|
22
22
|
useListDisclosure,
|
|
23
23
|
useListNavigation,
|
|
24
24
|
useReorderAutoScroll,
|
|
25
25
|
useReorderList,
|
|
26
26
|
} from '../../aspects';
|
|
27
|
+
import { listTheme } from '../List.theme';
|
|
28
|
+
|
|
29
|
+
const styles = listTheme.styles();
|
|
27
30
|
|
|
28
31
|
export type ListItemRecord = any;
|
|
29
32
|
|
|
@@ -132,7 +135,7 @@ export const OrderedListRoot = <T extends ListItemRecord>({
|
|
|
132
135
|
export const OrderedListContent = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => {
|
|
133
136
|
const { navigation } = useOrderedListContext('OrderedList.Content');
|
|
134
137
|
return (
|
|
135
|
-
<div {...navigation.containerProps} className={
|
|
138
|
+
<div {...navigation.containerProps} className={styles.orderedListContent({ class: mx(classNames) })}>
|
|
136
139
|
{children}
|
|
137
140
|
</div>
|
|
138
141
|
);
|
|
@@ -158,7 +161,7 @@ export const OrderedListViewport = composable<HTMLDivElement, OrderedListViewpor
|
|
|
158
161
|
const autoScrollRef = useReorderAutoScroll();
|
|
159
162
|
return (
|
|
160
163
|
<ScrollArea.Root
|
|
161
|
-
{...composableProps<HTMLDivElement>(rest, { classNames:
|
|
164
|
+
{...composableProps<HTMLDivElement>(rest, { classNames: styles.orderedListViewport() })}
|
|
162
165
|
{...{ thin, padding, centered }}
|
|
163
166
|
orientation='vertical'
|
|
164
167
|
ref={forwardedRef}
|
|
@@ -15,8 +15,8 @@ import React, {
|
|
|
15
15
|
type ComponentPropsWithRef,
|
|
16
16
|
type ElementType,
|
|
17
17
|
type KeyboardEvent,
|
|
18
|
-
type MouseEvent as ReactMouseEvent,
|
|
19
18
|
type PropsWithChildren,
|
|
19
|
+
type MouseEvent as ReactMouseEvent,
|
|
20
20
|
type ReactNode,
|
|
21
21
|
forwardRef,
|
|
22
22
|
useCallback,
|
|
@@ -29,6 +29,7 @@ import React, {
|
|
|
29
29
|
import { type Density, type Elevation, Input, type ThemedClassName, useThemeContext } from '@dxos/react-ui';
|
|
30
30
|
import { mx } from '@dxos/ui-theme';
|
|
31
31
|
|
|
32
|
+
import { listTheme } from '../List.theme';
|
|
32
33
|
import {
|
|
33
34
|
PickerInputContextProvider,
|
|
34
35
|
PickerItemContextProvider,
|
|
@@ -36,6 +37,8 @@ import {
|
|
|
36
37
|
usePickerItemContext,
|
|
37
38
|
} from './context';
|
|
38
39
|
|
|
40
|
+
const styles = listTheme.styles();
|
|
41
|
+
|
|
39
42
|
type ItemData = {
|
|
40
43
|
element: HTMLElement;
|
|
41
44
|
disabled?: boolean;
|
|
@@ -46,20 +49,33 @@ type ItemData = {
|
|
|
46
49
|
// Root
|
|
47
50
|
//
|
|
48
51
|
|
|
49
|
-
type PickerRootProps = PropsWithChildren<{
|
|
52
|
+
type PickerRootProps = PropsWithChildren<{
|
|
53
|
+
/**
|
|
54
|
+
* When the item set changes, snap the highlight back to the first item (command-palette behavior),
|
|
55
|
+
* instead of only re-selecting when the current selection disappears. Use for type-to-filter lists so
|
|
56
|
+
* the top result stays highlighted as results update. Does not fire on keyboard navigation (which
|
|
57
|
+
* changes the selection, not the item set). Defaults to false.
|
|
58
|
+
*/
|
|
59
|
+
resetSelectionOnChange?: boolean;
|
|
60
|
+
}>;
|
|
50
61
|
|
|
51
|
-
const PickerRoot = ({ children }: PickerRootProps) => {
|
|
62
|
+
const PickerRoot = ({ children, resetSelectionOnChange = false }: PickerRootProps) => {
|
|
52
63
|
const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined);
|
|
53
64
|
const itemsRef = useRef<Map<string, ItemData>>(new Map());
|
|
54
65
|
// Bumped on every (un)register to retrigger auto-select.
|
|
55
66
|
const [itemVersion, setItemVersion] = useState(0);
|
|
67
|
+
// Tracks the last-seen item set so a reset fires only when items change, not on selection change.
|
|
68
|
+
const prevItemVersionRef = useRef(itemVersion);
|
|
56
69
|
|
|
57
|
-
// Auto-select first non-disabled item when the current selection is
|
|
58
|
-
//
|
|
70
|
+
// Auto-select the first non-disabled item when the current selection is gone or disabled — or, when
|
|
71
|
+
// `resetSelectionOnChange`, whenever the item set itself changes.
|
|
59
72
|
useEffect(() => {
|
|
73
|
+
const itemsChanged = prevItemVersionRef.current !== itemVersion;
|
|
74
|
+
prevItemVersionRef.current = itemVersion;
|
|
75
|
+
|
|
60
76
|
const current = selectedValue !== undefined ? itemsRef.current.get(selectedValue) : undefined;
|
|
61
77
|
const isValid = current !== undefined && !current.disabled;
|
|
62
|
-
if (!isValid && itemsRef.current.size > 0) {
|
|
78
|
+
if ((!isValid || (resetSelectionOnChange && itemsChanged)) && itemsRef.current.size > 0) {
|
|
63
79
|
const entries = Array.from(itemsRef.current.entries()).filter(([, data]) => !data.disabled);
|
|
64
80
|
if (entries.length > 0) {
|
|
65
81
|
entries.sort(([, a], [, b]) => {
|
|
@@ -80,7 +96,7 @@ const PickerRoot = ({ children }: PickerRootProps) => {
|
|
|
80
96
|
setSelectedValue(undefined);
|
|
81
97
|
}
|
|
82
98
|
}
|
|
83
|
-
}, [itemVersion, selectedValue]);
|
|
99
|
+
}, [itemVersion, selectedValue, resetSelectionOnChange]);
|
|
84
100
|
|
|
85
101
|
const registerItem = useCallback(
|
|
86
102
|
(value: string, element: HTMLElement | null, onSelect: (() => void) | undefined, disabled?: boolean) => {
|
|
@@ -321,8 +337,6 @@ const PickerItem = forwardRef<HTMLDivElement, PickerItemProps>(
|
|
|
321
337
|
|
|
322
338
|
const Comp: ElementType = asChild ? Slot : 'div';
|
|
323
339
|
|
|
324
|
-
// Padding follows `--gutter` to align with sibling `Column.Center`
|
|
325
|
-
// content; falls back to `0.75rem` when not nested under `Column.Root`.
|
|
326
340
|
return (
|
|
327
341
|
<Comp
|
|
328
342
|
{...props}
|
|
@@ -342,11 +356,7 @@ const PickerItem = forwardRef<HTMLDivElement, PickerItemProps>(
|
|
|
342
356
|
data-value={value}
|
|
343
357
|
// Browser focus stays on the input; highlight is via `aria-selected`.
|
|
344
358
|
tabIndex={-1}
|
|
345
|
-
className={mx(
|
|
346
|
-
'dx-hover dx-selected px-[var(--gutter,0.75rem)] py-1 cursor-pointer select-none',
|
|
347
|
-
disabled && 'opacity-50 cursor-not-allowed',
|
|
348
|
-
classNames,
|
|
349
|
-
)}
|
|
359
|
+
className={styles.pickerItem({ class: mx(disabled && 'opacity-50 cursor-not-allowed', classNames) })}
|
|
350
360
|
onMouseDown={handleMouseDown}
|
|
351
361
|
onClick={handleClick}
|
|
352
362
|
>
|
|
@@ -364,6 +374,6 @@ export const Picker = {
|
|
|
364
374
|
Item: PickerItem,
|
|
365
375
|
};
|
|
366
376
|
|
|
367
|
-
export type {
|
|
377
|
+
export type { PickerInputProps, PickerItemProps, PickerRootProps };
|
|
368
378
|
|
|
369
379
|
export { usePickerInputContext, usePickerItemContext } from './context';
|
|
@@ -21,9 +21,11 @@ import { type TreeData } from './TreeItem';
|
|
|
21
21
|
|
|
22
22
|
random.seed(1234);
|
|
23
23
|
|
|
24
|
-
const tree = createTree()
|
|
24
|
+
const tree = createTree();
|
|
25
|
+
const groupsTree = createTree(4, 4, { groups: true });
|
|
25
26
|
|
|
26
|
-
const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
|
|
27
|
+
const DefaultStory = ({ draggable, groups }: { draggable?: boolean; groups?: boolean }) => {
|
|
28
|
+
const rootTree = groups ? groupsTree : tree;
|
|
27
29
|
const registry = useContext(RegistryContext);
|
|
28
30
|
const stateAtomsRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
|
|
29
31
|
|
|
@@ -43,9 +45,9 @@ const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
|
|
|
43
45
|
map.set(item.id, item);
|
|
44
46
|
item.items?.forEach(walk);
|
|
45
47
|
};
|
|
46
|
-
walk(
|
|
48
|
+
walk(rootTree);
|
|
47
49
|
return map;
|
|
48
|
-
}, []);
|
|
50
|
+
}, [rootTree]);
|
|
49
51
|
|
|
50
52
|
// Build a child IDs map keyed by parent ID.
|
|
51
53
|
const childIdsMap = useMemo(() => {
|
|
@@ -61,15 +63,17 @@ const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
|
|
|
61
63
|
};
|
|
62
64
|
// Root children.
|
|
63
65
|
map.set(
|
|
64
|
-
|
|
65
|
-
(
|
|
66
|
+
rootTree.id,
|
|
67
|
+
(rootTree.items ?? []).map((child) => child.id),
|
|
66
68
|
);
|
|
67
|
-
walk(
|
|
69
|
+
walk(rootTree);
|
|
68
70
|
return map;
|
|
69
|
-
}, []);
|
|
71
|
+
}, [rootTree]);
|
|
70
72
|
|
|
73
|
+
// Writable so drops can push new child orderings (a derived `Atom.make(() => …)` would snapshot
|
|
74
|
+
// the initial map and never reflect the reorder).
|
|
71
75
|
const childIdsFamily = useMemo(
|
|
72
|
-
() => Atom.family((id: string) => Atom.make(
|
|
76
|
+
() => Atom.family((id: string) => Atom.make<string[]>(childIdsMap.get(id) ?? []).pipe(Atom.keepAlive)),
|
|
73
77
|
[childIdsMap],
|
|
74
78
|
);
|
|
75
79
|
|
|
@@ -91,6 +95,7 @@ const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
|
|
|
91
95
|
id: parent.id,
|
|
92
96
|
label: parent.name,
|
|
93
97
|
icon: parent.icon,
|
|
98
|
+
disposition: parent.disposition,
|
|
94
99
|
...((parent.items?.length ?? 0) > 0 && {
|
|
95
100
|
parentOf: parent.items!.map(({ id }) => id),
|
|
96
101
|
count: parent.items!.length,
|
|
@@ -123,13 +128,13 @@ const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
|
|
|
123
128
|
|
|
124
129
|
const model: TreeModel<TestItem> = useMemo(
|
|
125
130
|
() => ({
|
|
126
|
-
childIds: (parentId?: string) => childIdsFamily(parentId ??
|
|
131
|
+
childIds: (parentId?: string) => childIdsFamily(parentId ?? rootTree.id),
|
|
127
132
|
item: (id: string) => itemFamily(id),
|
|
128
133
|
itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
|
|
129
134
|
itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
|
|
130
135
|
itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
|
|
131
136
|
}),
|
|
132
|
-
[childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
|
|
137
|
+
[childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily, rootTree.id],
|
|
133
138
|
);
|
|
134
139
|
|
|
135
140
|
const handleOpenChange = useCallback(
|
|
@@ -164,21 +169,36 @@ const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
|
|
|
164
169
|
const instruction: Instruction | null = extractInstruction(target.data);
|
|
165
170
|
if (instruction !== null) {
|
|
166
171
|
updateState({
|
|
167
|
-
state:
|
|
172
|
+
state: rootTree,
|
|
168
173
|
instruction,
|
|
169
174
|
source: source.data as TreeData,
|
|
170
175
|
target: target.data as TreeData,
|
|
171
176
|
});
|
|
177
|
+
|
|
178
|
+
// `updateState` mutates the tree in place; push the new child orderings into the
|
|
179
|
+
// (writable) childIds atoms so the affected branches re-render.
|
|
180
|
+
const refresh = (item: TestItem) => {
|
|
181
|
+
registry.set(
|
|
182
|
+
childIdsFamily(item.id),
|
|
183
|
+
(item.items ?? []).map((child) => child.id),
|
|
184
|
+
);
|
|
185
|
+
item.items?.forEach(refresh);
|
|
186
|
+
};
|
|
187
|
+
registry.set(
|
|
188
|
+
childIdsFamily(rootTree.id),
|
|
189
|
+
(rootTree.items ?? []).map((child) => child.id),
|
|
190
|
+
);
|
|
191
|
+
rootTree.items?.forEach(refresh);
|
|
172
192
|
}
|
|
173
193
|
},
|
|
174
194
|
});
|
|
175
|
-
}, []);
|
|
195
|
+
}, [rootTree, childIdsFamily, registry]);
|
|
176
196
|
|
|
177
197
|
return (
|
|
178
198
|
<Tree
|
|
179
199
|
model={model}
|
|
180
|
-
id={
|
|
181
|
-
rootId={
|
|
200
|
+
id={rootTree.id}
|
|
201
|
+
rootId={rootTree.id}
|
|
182
202
|
draggable={draggable}
|
|
183
203
|
renderColumns={() => (
|
|
184
204
|
<div className='flex items-center'>
|
|
@@ -210,3 +230,7 @@ export const Draggable: Story = {
|
|
|
210
230
|
draggable: true,
|
|
211
231
|
},
|
|
212
232
|
};
|
|
233
|
+
|
|
234
|
+
export const WithGroups: Story = {
|
|
235
|
+
args: { groups: true },
|
|
236
|
+
};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
//
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
3
4
|
|
|
4
5
|
import { useAtomValue } from '@effect-atom/atom-react';
|
|
5
6
|
import React, { useMemo } from 'react';
|
|
6
7
|
|
|
7
|
-
import { Treegrid, type TreegridRootProps } from '
|
|
8
|
-
|
|
8
|
+
import { Treegrid, type TreegridRootProps } from '../Treegrid';
|
|
9
9
|
import { type TreeModel, TreeProvider } from './TreeContext';
|
|
10
10
|
import { TreeItemById, type TreeItemByIdProps, type TreeItemProps } from './TreeItem';
|
|
11
11
|
|
|
@@ -28,6 +28,16 @@ export type TreeProps<T extends { id: string } = any> = {
|
|
|
28
28
|
| 'levelOffset'
|
|
29
29
|
>;
|
|
30
30
|
|
|
31
|
+
/** Renders a single root-level child. */
|
|
32
|
+
const TreeChild = <T extends { id: string } = any>({
|
|
33
|
+
id,
|
|
34
|
+
path: parentPath,
|
|
35
|
+
last,
|
|
36
|
+
...childProps
|
|
37
|
+
}: TreeItemByIdProps & { last: boolean }) => {
|
|
38
|
+
return <TreeItemById id={id} path={parentPath} last={last} {...childProps} />;
|
|
39
|
+
};
|
|
40
|
+
|
|
31
41
|
export const Tree = <T extends { id: string } = any>({
|
|
32
42
|
classNames,
|
|
33
43
|
model,
|
|
@@ -65,7 +75,7 @@ export const Tree = <T extends { id: string } = any>({
|
|
|
65
75
|
<Treegrid.Root gridTemplateColumns={gridTemplateColumns} classNames={classNames}>
|
|
66
76
|
<TreeProvider value={model}>
|
|
67
77
|
{childIds.map((childId, index) => (
|
|
68
|
-
<
|
|
78
|
+
<TreeChild key={childId} id={childId} last={index === childIds.length - 1} {...childProps} />
|
|
69
79
|
))}
|
|
70
80
|
</TreeProvider>
|
|
71
81
|
</Treegrid.Root>
|
|
@@ -12,6 +12,8 @@ export type TreeItemDataProps = {
|
|
|
12
12
|
id: string;
|
|
13
13
|
label: Label;
|
|
14
14
|
parentOf?: string[];
|
|
15
|
+
/** Pass-through of the node's disposition; the tree uses this to branch render mode (e.g. `'group'` → section header). */
|
|
16
|
+
disposition?: string;
|
|
15
17
|
/** When `false`, the item cannot be dragged (overrides tree-level `draggable`). */
|
|
16
18
|
draggable?: boolean;
|
|
17
19
|
/** When `false`, the item does not participate as a drop target. */
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
|
|
6
|
+
import React, { type CSSProperties, type HTMLAttributes } from 'react';
|
|
7
|
+
|
|
8
|
+
// Tree-item instruction indicator. Atlaskit's `react-drop-indicator` ships `box`/`list-item`
|
|
9
|
+
// renderers but no `tree-item` one, so this stays a small Tailwind port (theme-aware via
|
|
10
|
+
// `bg-accent-bg`). See `react-ui-list/AUDIT.md` D4.
|
|
11
|
+
// https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/packages/hitbox/constellation/index/about.mdx#tree-item
|
|
12
|
+
|
|
13
|
+
type InstructionType = Exclude<Instruction, { type: 'instruction-blocked' }>['type'];
|
|
14
|
+
type Orientation = 'sibling' | 'child';
|
|
15
|
+
|
|
16
|
+
const edgeToOrientationMap: Record<InstructionType, Orientation> = {
|
|
17
|
+
'reorder-above': 'sibling',
|
|
18
|
+
'reorder-below': 'sibling',
|
|
19
|
+
'make-child': 'child',
|
|
20
|
+
'reparent': 'child',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const orientationStyles: Record<Orientation, HTMLAttributes<HTMLElement>['className']> = {
|
|
24
|
+
sibling:
|
|
25
|
+
'h-(--line-thickness) left-(--horizontal-indent) right-0 bg-accent-bg before:left-(--negative-terminal-size)',
|
|
26
|
+
child: 'inset-0 border-[length:var(--line-thickness)] before:invisible',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const instructionStyles: Record<InstructionType, HTMLAttributes<HTMLElement>['className']> = {
|
|
30
|
+
'reorder-above': 'top-(--line-offset) before:top-(--offset-terminal)',
|
|
31
|
+
'reorder-below': 'bottom-(--line-offset) before:bottom-(--offset-terminal)',
|
|
32
|
+
'make-child': 'border-accent-bg',
|
|
33
|
+
'reparent': '',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const strokeSize = 2;
|
|
37
|
+
const terminalSize = 8;
|
|
38
|
+
const offsetToAlignTerminalWithLine = (strokeSize - terminalSize) / 2;
|
|
39
|
+
|
|
40
|
+
/** Props for {@link TreeDropIndicator}. */
|
|
41
|
+
export type TreeDropIndicatorProps = {
|
|
42
|
+
instruction: Instruction;
|
|
43
|
+
gap?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Themed drop indicator for a tree-item pragmatic-dnd `Instruction` (sibling reorder / make-child). */
|
|
47
|
+
export const TreeDropIndicator = ({ instruction, gap = 0 }: TreeDropIndicatorProps) => {
|
|
48
|
+
const lineOffset = `calc(-0.5 * (${gap}px + ${strokeSize}px))`;
|
|
49
|
+
const isBlocked = instruction.type === 'instruction-blocked';
|
|
50
|
+
const desiredInstruction = isBlocked ? instruction.desired : instruction;
|
|
51
|
+
const orientation = edgeToOrientationMap[desiredInstruction.type];
|
|
52
|
+
if (isBlocked) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
style={
|
|
59
|
+
{
|
|
60
|
+
'--line-thickness': `${strokeSize}px`,
|
|
61
|
+
'--line-offset': `${lineOffset}`,
|
|
62
|
+
'--terminal-size': `${terminalSize}px`,
|
|
63
|
+
'--terminal-radius': `${terminalSize / 2}px`,
|
|
64
|
+
'--negative-terminal-size': `-${terminalSize}px`,
|
|
65
|
+
'--offset-terminal': `${offsetToAlignTerminalWithLine}px`,
|
|
66
|
+
'--horizontal-indent': `${desiredInstruction.currentLevel * desiredInstruction.indentPerLevel + 4}px`,
|
|
67
|
+
} as CSSProperties
|
|
68
|
+
}
|
|
69
|
+
className={`absolute z-10 pointer-events-none before:w-(--terminal-size) before:h-(--terminal-size) box-border before:absolute before:border-[length:--line-thickness] before:border-solid before:border-accent-bg before:rounded-full ${orientationStyles[orientation]} ${instructionStyles[desiredInstruction.type]}`}
|
|
70
|
+
></div>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
@@ -25,7 +25,7 @@ import React, {
|
|
|
25
25
|
} from 'react';
|
|
26
26
|
|
|
27
27
|
import { invariant } from '@dxos/invariant';
|
|
28
|
-
import {
|
|
28
|
+
import { type Label, toLocalizedString, useTranslation } from '@dxos/react-ui';
|
|
29
29
|
import {
|
|
30
30
|
ghostFocusWithin,
|
|
31
31
|
ghostHover,
|
|
@@ -35,14 +35,30 @@ import {
|
|
|
35
35
|
mx,
|
|
36
36
|
} from '@dxos/ui-theme';
|
|
37
37
|
|
|
38
|
+
import { Treegrid, TREEGRID_PARENT_OF_SEPARATOR } from '../Treegrid';
|
|
38
39
|
import { DEFAULT_INDENTATION, paddingIndentation } from './helpers';
|
|
39
40
|
import { useTree } from './TreeContext';
|
|
41
|
+
import { TreeDropIndicator } from './TreeDropIndicator';
|
|
40
42
|
import { TreeItemHeading } from './TreeItemHeading';
|
|
41
43
|
import { TreeItemToggle } from './TreeItemToggle';
|
|
42
44
|
|
|
43
45
|
const hoverableDescriptionIcons =
|
|
44
46
|
'[--icons-color:inherit] hover-hover:[--icons-color:var(--description-text)] hover-hover:hover:[--icons-color:inherit] focus-within:[--icons-color:inherit]';
|
|
45
47
|
|
|
48
|
+
/** Renders a section-group label spanning the full tree row. Used when a node has `disposition === 'group'`. */
|
|
49
|
+
const NavTreeSectionHeader = ({ label }: { label: Label }) => {
|
|
50
|
+
const { t } = useTranslation();
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
role='heading'
|
|
54
|
+
aria-level={2}
|
|
55
|
+
className='col-[tree-row] pl-7 pt-3 pb-0.5 text-xs uppercase tracking-widest text-subdued hover:text-description select-none'
|
|
56
|
+
>
|
|
57
|
+
{toLocalizedString(label, t)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
46
62
|
type TreeItemDragState = 'idle' | 'dragging' | 'preview' | 'parent-of-instruction';
|
|
47
63
|
|
|
48
64
|
export const TreeDataSchema = Schema.Struct({
|
|
@@ -110,6 +126,7 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
110
126
|
const {
|
|
111
127
|
id,
|
|
112
128
|
parentOf,
|
|
129
|
+
disposition,
|
|
113
130
|
draggable: itemDraggable,
|
|
114
131
|
droppable: itemDroppable,
|
|
115
132
|
label,
|
|
@@ -145,7 +162,7 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
145
162
|
const nativeDragText = id;
|
|
146
163
|
|
|
147
164
|
useEffect(() => {
|
|
148
|
-
if (!draggableProp) {
|
|
165
|
+
if (!draggableProp || (!isItemDraggable && !isItemDroppable)) {
|
|
149
166
|
return;
|
|
150
167
|
}
|
|
151
168
|
|
|
@@ -300,6 +317,29 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
300
317
|
onSelect,
|
|
301
318
|
};
|
|
302
319
|
|
|
320
|
+
// Group nodes render as a flat section header with their children always expanded at the same visual level.
|
|
321
|
+
// Suppress the header entirely when there are no children to avoid orphaned section labels.
|
|
322
|
+
if (disposition === 'group') {
|
|
323
|
+
if (childIds.length === 0) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return (
|
|
327
|
+
<>
|
|
328
|
+
<NavTreeSectionHeader label={label} />
|
|
329
|
+
{childIds.map((childId, index) => (
|
|
330
|
+
<TreeItemById
|
|
331
|
+
key={childId}
|
|
332
|
+
id={childId}
|
|
333
|
+
path={path}
|
|
334
|
+
last={index === childIds.length - 1}
|
|
335
|
+
levelOffset={levelOffset + 1}
|
|
336
|
+
{...childProps}
|
|
337
|
+
/>
|
|
338
|
+
))}
|
|
339
|
+
</>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
303
343
|
return (
|
|
304
344
|
<>
|
|
305
345
|
<Treegrid.Row
|
|
@@ -347,7 +387,7 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
347
387
|
/>
|
|
348
388
|
</Treegrid.Cell>
|
|
349
389
|
{Columns && <Columns item={item} path={path} open={open} menuOpen={menuOpen} setMenuOpen={setMenuOpen} />}
|
|
350
|
-
{instruction && <
|
|
390
|
+
{instruction && <TreeDropIndicator instruction={instruction} gap={2} />}
|
|
351
391
|
</div>
|
|
352
392
|
</Treegrid.Row>
|
|
353
393
|
{open &&
|
|
@@ -88,7 +88,7 @@ export const TreeItemHeading = memo(
|
|
|
88
88
|
const CountBadge = ({ count, modifiedCount }: Pick<TreeItemHeadingProps, 'count' | 'modifiedCount'>) => {
|
|
89
89
|
if (typeof modifiedCount === 'number' && modifiedCount > 0) {
|
|
90
90
|
return (
|
|
91
|
-
<Tag
|
|
91
|
+
<Tag hue='rose' classNames='shrink-0 text-center [min-inline-size:1.5rem] tabular-nums'>
|
|
92
92
|
{modifiedCount}
|
|
93
93
|
</Tag>
|
|
94
94
|
);
|
|
@@ -96,7 +96,7 @@ const CountBadge = ({ count, modifiedCount }: Pick<TreeItemHeadingProps, 'count'
|
|
|
96
96
|
|
|
97
97
|
if (typeof count === 'number') {
|
|
98
98
|
return (
|
|
99
|
-
<Tag
|
|
99
|
+
<Tag hue='neutral' classNames='shrink-0 text-center [min-inline-size:1.5rem] tabular-nums'>
|
|
100
100
|
{count}
|
|
101
101
|
</Tag>
|
|
102
102
|
);
|
|
@@ -15,6 +15,7 @@ export type TestItem = {
|
|
|
15
15
|
id: string;
|
|
16
16
|
name: string;
|
|
17
17
|
icon?: string;
|
|
18
|
+
disposition?: string;
|
|
18
19
|
items: TestItem[];
|
|
19
20
|
};
|
|
20
21
|
|
|
@@ -25,20 +26,24 @@ export const TestItemSchema = Schema.Struct({
|
|
|
25
26
|
items: Schema.mutable(Schema.Array(Schema.suspend((): Schema.Schema<TestItem> => TestItemSchema))),
|
|
26
27
|
});
|
|
27
28
|
|
|
28
|
-
export const createTree = (n = 4, d = 4): TestItem =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
29
|
+
export const createTree = (n = 4, d = 4, { groups = false }: { groups?: boolean } = {}): TestItem => {
|
|
30
|
+
const children = d > 0 ? random.helpers.multiple(() => createTree(n, d - 1), { count: n }) : [];
|
|
31
|
+
return {
|
|
32
|
+
id: random.string.uuid(),
|
|
33
|
+
name: random.commerce.productName(),
|
|
34
|
+
icon:
|
|
35
|
+
d === 3
|
|
36
|
+
? undefined
|
|
37
|
+
: random.helpers.arrayElement([
|
|
38
|
+
'ph--planet--regular',
|
|
39
|
+
'ph--sailboat--regular',
|
|
40
|
+
'ph--house--regular',
|
|
41
|
+
'ph--gear--regular',
|
|
42
|
+
]),
|
|
43
|
+
// When groups is true, mark every direct child as a group section header (no icon).
|
|
44
|
+
items: groups ? children.map((child) => ({ ...child, disposition: 'group', icon: undefined })) : children,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
42
47
|
|
|
43
48
|
const removeItem = (tree: TestItem, source: TreeData) => {
|
|
44
49
|
const parent = getTestItem(tree, source.path.slice(1, -1));
|