@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.
Files changed (101) hide show
  1. package/dist/lib/browser/index.mjs +628 -167
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +628 -167
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/aspects/useListDisclosure.d.ts +4 -4
  8. package/dist/types/src/aspects/useListDisclosure.d.ts.map +1 -1
  9. package/dist/types/src/aspects/useListNavigation.d.ts +9 -7
  10. package/dist/types/src/aspects/useListNavigation.d.ts.map +1 -1
  11. package/dist/types/src/aspects/useListSelection.d.ts +3 -3
  12. package/dist/types/src/aspects/useListSelection.d.ts.map +1 -1
  13. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  14. package/dist/types/src/components/Accordion/AccordionItem.d.ts +3 -1
  15. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  16. package/dist/types/src/components/Combobox/Combobox.d.ts +10 -3
  17. package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -1
  18. package/dist/types/src/components/DropIndicator/DropIndicator.d.ts +12 -0
  19. package/dist/types/src/components/DropIndicator/DropIndicator.d.ts.map +1 -0
  20. package/dist/types/src/components/DropIndicator/index.d.ts +2 -0
  21. package/dist/types/src/components/DropIndicator/index.d.ts.map +1 -0
  22. package/dist/types/src/components/Empty/Empty.d.ts +17 -0
  23. package/dist/types/src/components/Empty/Empty.d.ts.map +1 -0
  24. package/dist/types/src/components/Empty/Empty.stories.d.ts +18 -0
  25. package/dist/types/src/components/Empty/Empty.stories.d.ts.map +1 -0
  26. package/dist/types/src/components/Empty/index.d.ts +2 -0
  27. package/dist/types/src/components/Empty/index.d.ts.map +1 -0
  28. package/dist/types/src/components/List.theme.d.ts +193 -0
  29. package/dist/types/src/components/List.theme.d.ts.map +1 -0
  30. package/dist/types/src/components/Listbox/ListItemContent.d.ts +27 -0
  31. package/dist/types/src/components/Listbox/ListItemContent.d.ts.map +1 -0
  32. package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts +24 -0
  33. package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Listbox/Listbox.d.ts +19 -2
  35. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
  36. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +4 -0
  37. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/OrderedList/OrderedList.d.ts +1 -1
  39. package/dist/types/src/components/OrderedList/OrderedList.d.ts.map +1 -1
  40. package/dist/types/src/components/OrderedList/OrderedListItem.d.ts.map +1 -1
  41. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts +1 -1
  42. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts.map +1 -1
  43. package/dist/types/src/components/Picker/Picker.d.ts +12 -4
  44. package/dist/types/src/components/Picker/Picker.d.ts.map +1 -1
  45. package/dist/types/src/components/Picker/index.d.ts +1 -1
  46. package/dist/types/src/components/Picker/index.d.ts.map +1 -1
  47. package/dist/types/src/components/Tree/Tree.d.ts +1 -1
  48. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  49. package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -2
  50. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  51. package/dist/types/src/components/Tree/TreeContext.d.ts +2 -0
  52. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  53. package/dist/types/src/components/Tree/TreeDropIndicator.d.ts +10 -0
  54. package/dist/types/src/components/Tree/TreeDropIndicator.d.ts.map +1 -0
  55. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  56. package/dist/types/src/components/Tree/testing.d.ts +4 -1
  57. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  58. package/dist/types/src/components/Treegrid/Treegrid.d.ts +33 -0
  59. package/dist/types/src/components/Treegrid/Treegrid.d.ts.map +1 -0
  60. package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts +11 -0
  61. package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts.map +1 -0
  62. package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts +37 -0
  63. package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts.map +1 -0
  64. package/dist/types/src/components/Treegrid/index.d.ts +2 -0
  65. package/dist/types/src/components/Treegrid/index.d.ts.map +1 -0
  66. package/dist/types/src/components/index.d.ts +3 -0
  67. package/dist/types/src/components/index.d.ts.map +1 -1
  68. package/dist/types/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +23 -22
  70. package/src/aspects/useListDisclosure.ts +8 -8
  71. package/src/aspects/useListNavigation.ts +9 -7
  72. package/src/aspects/useListSelection.ts +4 -4
  73. package/src/components/Accordion/AccordionItem.tsx +31 -13
  74. package/src/components/Combobox/Combobox.tsx +46 -38
  75. package/src/components/DropIndicator/DropIndicator.tsx +74 -0
  76. package/src/components/DropIndicator/index.ts +5 -0
  77. package/src/components/Empty/Empty.stories.tsx +37 -0
  78. package/src/components/Empty/Empty.tsx +41 -0
  79. package/src/components/Empty/index.ts +5 -0
  80. package/src/components/List.theme.ts +99 -0
  81. package/src/components/Listbox/ListItemContent.stories.tsx +41 -0
  82. package/src/components/Listbox/ListItemContent.tsx +49 -0
  83. package/src/components/Listbox/Listbox.stories.tsx +106 -7
  84. package/src/components/Listbox/Listbox.tsx +85 -42
  85. package/src/components/OrderedList/OrderedList.tsx +1 -1
  86. package/src/components/OrderedList/OrderedListItem.tsx +13 -13
  87. package/src/components/OrderedList/OrderedListRoot.tsx +6 -3
  88. package/src/components/Picker/Picker.tsx +25 -15
  89. package/src/components/Picker/index.ts +1 -1
  90. package/src/components/Tree/Tree.stories.tsx +39 -15
  91. package/src/components/Tree/Tree.tsx +13 -3
  92. package/src/components/Tree/TreeContext.tsx +2 -0
  93. package/src/components/Tree/TreeDropIndicator.tsx +72 -0
  94. package/src/components/Tree/TreeItem.tsx +43 -3
  95. package/src/components/Tree/TreeItemHeading.tsx +2 -2
  96. package/src/components/Tree/testing.ts +19 -14
  97. package/src/components/Treegrid/Treegrid.stories.tsx +132 -0
  98. package/src/components/Treegrid/Treegrid.theme.ts +38 -0
  99. package/src/components/Treegrid/Treegrid.tsx +187 -0
  100. package/src/components/Treegrid/index.ts +5 -0
  101. 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={mx('flex flex-col', classNames)}>
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: 'dx-container' })}
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
- // gone or disabled.
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 { PickerRootProps, PickerInputProps, PickerItemProps };
377
+ export type { PickerInputProps, PickerItemProps, PickerRootProps };
368
378
 
369
379
  export { usePickerInputContext, usePickerItemContext } from './context';
@@ -3,4 +3,4 @@
3
3
  //
4
4
 
5
5
  export { Picker, usePickerInputContext, usePickerItemContext } from './Picker';
6
- export type { PickerRootProps, PickerInputProps, PickerItemProps } from './Picker';
6
+ export type { PickerInputProps, PickerItemProps, PickerRootProps } from './Picker';
@@ -21,9 +21,11 @@ import { type TreeData } from './TreeItem';
21
21
 
22
22
  random.seed(1234);
23
23
 
24
- const tree = createTree() as TestItem;
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(tree);
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
- tree.id,
65
- (tree.items ?? []).map((child) => child.id),
66
+ rootTree.id,
67
+ (rootTree.items ?? []).map((child) => child.id),
66
68
  );
67
- walk(tree);
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(() => childIdsMap.get(id) ?? []).pipe(Atom.keepAlive)),
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 ?? tree.id),
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: tree,
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={tree.id}
181
- rootId={tree.id}
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 '@dxos/react-ui';
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
- <TreeItemById key={childId} id={childId} last={index === childIds.length - 1} {...childProps} />
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 { TreeItem as NaturalTreeItem, Treegrid, TREEGRID_PARENT_OF_SEPARATOR } from '@dxos/react-ui';
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 && <NaturalTreeItem.DropIndicator instruction={instruction} gap={2} />}
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 palette='rose' classNames='shrink-0 text-center [min-inline-size:1.5rem] tabular-nums'>
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 palette='neutral' classNames='shrink-0 text-center [min-inline-size:1.5rem] tabular-nums'>
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
- id: random.string.uuid(),
30
- name: random.commerce.productName(),
31
- icon:
32
- d === 3
33
- ? undefined
34
- : random.helpers.arrayElement([
35
- 'ph--planet--regular',
36
- 'ph--sailboat--regular',
37
- 'ph--house--regular',
38
- 'ph--gear--regular',
39
- ]),
40
- items: d > 0 ? random.helpers.multiple(() => createTree(n, d - 1), { count: n }) : [],
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));