@dxos/react-ui-list 0.8.4-main.84f28bd → 0.8.4-main.937b3ca

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 (51) hide show
  1. package/dist/lib/browser/index.mjs +657 -712
  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 +657 -712
  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/components/Accordion/Accordion.stories.d.ts +7 -4
  8. package/dist/types/src/components/Accordion/Accordion.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/Accordion/AccordionItem.d.ts +1 -1
  10. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  11. package/dist/types/src/components/List/List.d.ts +8 -8
  12. package/dist/types/src/components/List/List.d.ts.map +1 -1
  13. package/dist/types/src/components/List/List.stories.d.ts +14 -5
  14. package/dist/types/src/components/List/List.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/List/ListItem.d.ts +4 -7
  16. package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
  17. package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
  18. package/dist/types/src/components/List/testing.d.ts +1 -1
  19. package/dist/types/src/components/List/testing.d.ts.map +1 -1
  20. package/dist/types/src/components/Tree/Tree.d.ts +7 -4
  21. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  22. package/dist/types/src/components/Tree/Tree.stories.d.ts +18 -7
  23. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  24. package/dist/types/src/components/Tree/TreeContext.d.ts +7 -4
  25. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  26. package/dist/types/src/components/Tree/TreeItem.d.ts +24 -10
  27. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  28. package/dist/types/src/components/Tree/TreeItemHeading.d.ts +4 -3
  29. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  30. package/dist/types/src/components/Tree/TreeItemToggle.d.ts +3 -3
  31. package/dist/types/src/components/Tree/TreeItemToggle.d.ts.map +1 -1
  32. package/dist/types/src/components/Tree/testing.d.ts +3 -3
  33. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  34. package/dist/types/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +31 -28
  36. package/src/components/Accordion/Accordion.stories.tsx +7 -9
  37. package/src/components/Accordion/Accordion.tsx +1 -1
  38. package/src/components/Accordion/AccordionItem.tsx +7 -4
  39. package/src/components/Accordion/AccordionRoot.tsx +1 -1
  40. package/src/components/List/List.stories.tsx +41 -27
  41. package/src/components/List/List.tsx +2 -5
  42. package/src/components/List/ListItem.tsx +40 -28
  43. package/src/components/List/ListRoot.tsx +1 -1
  44. package/src/components/List/testing.ts +3 -3
  45. package/src/components/Tree/Tree.stories.tsx +101 -84
  46. package/src/components/Tree/Tree.tsx +22 -9
  47. package/src/components/Tree/TreeContext.tsx +7 -4
  48. package/src/components/Tree/TreeItem.tsx +66 -54
  49. package/src/components/Tree/TreeItemHeading.tsx +11 -9
  50. package/src/components/Tree/TreeItemToggle.tsx +29 -19
  51. package/src/components/Tree/testing.ts +5 -4
@@ -2,115 +2,132 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
5
  import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
8
- import { extractInstruction, type Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
6
+ import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
7
+ import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
9
8
  import { type Meta, type StoryObj } from '@storybook/react-vite';
10
- import React, { useEffect } from 'react';
9
+ import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
11
10
 
12
- import { live, type Live } from '@dxos/live-object';
13
11
  import { faker } from '@dxos/random';
14
12
  import { Icon } from '@dxos/react-ui';
15
- import { withLayout, withTheme } from '@dxos/storybook-utils';
13
+ import { withTheme } from '@dxos/react-ui/testing';
14
+ import { withRegistry } from '@dxos/storybook-utils';
15
+
16
+ import { Path } from '../../util';
16
17
 
18
+ import { type TestItem, createTree, updateState } from './testing';
17
19
  import { Tree } from './Tree';
18
20
  import { type TreeData } from './TreeItem';
19
- import { createTree, updateState, type TestItem } from './testing';
20
- import { Path } from '../../util';
21
21
 
22
22
  faker.seed(1234);
23
23
 
24
- const tree = live<TestItem>(createTree());
25
- const state = new Map<string, Live<{ open: boolean; current: boolean }>>();
24
+ const tree = createTree() as TestItem;
26
25
 
27
- const meta: Meta<typeof Tree<TestItem>> = {
28
- title: 'ui/react-ui-list/Tree',
29
- component: Tree,
30
- decorators: [withTheme, withLayout()],
31
- render: (args) => {
32
- useEffect(() => {
33
- return monitorForElements({
34
- canMonitor: ({ source }) => typeof source.data.id === 'string' && Array.isArray(source.data.path),
35
- onDrop: ({ location, source }) => {
36
- // Didn't drop on anything.
37
- if (!location.current.dropTargets.length) {
38
- return;
39
- }
40
-
41
- const target = location.current.dropTargets[0];
42
-
43
- const instruction: Instruction | null = extractInstruction(target.data);
44
- if (instruction !== null) {
45
- updateState({
46
- state: tree,
47
- instruction,
48
- source: source.data as TreeData,
49
- target: target.data as TreeData,
50
- });
51
- }
52
- },
53
- });
54
- }, []);
55
-
56
- return <Tree {...args} />;
57
- },
58
- args: {
59
- id: tree.id,
60
- useItems: (testItem?: TestItem) => {
61
- return testItem?.items ?? tree.items;
62
- },
63
- getProps: (testItem: TestItem) => ({
64
- id: testItem.id,
65
- label: testItem.name,
66
- icon: testItem.icon,
67
- ...((testItem.items?.length ?? 0) > 0 && {
68
- parentOf: testItem.items!.map(({ id }) => id),
69
- }),
70
- }),
71
- isOpen: (_path: string[]) => {
72
- const path = Path.create(..._path);
73
- const object = state.get(path) ?? live({ open: false, current: false });
74
- if (!state.has(path)) {
75
- state.set(path, object);
76
- }
26
+ const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
27
+ const registry = useContext(RegistryContext);
28
+ const stateAtomsRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
29
+
30
+ const getOrCreateStateAtom = useCallback((path: string) => {
31
+ let atom = stateAtomsRef.current.get(path);
32
+ if (!atom) {
33
+ atom = Atom.make({ open: false, current: false }).pipe(Atom.keepAlive);
34
+ stateAtomsRef.current.set(path, atom);
35
+ }
36
+ return atom;
37
+ }, []);
77
38
 
78
- return object.open;
39
+ const useItemState = useCallback(
40
+ (_path: string[]) => {
41
+ const path = useMemo(() => Path.create(..._path), [_path.join('~')]);
42
+ const atom = getOrCreateStateAtom(path);
43
+ return useAtomValue(atom);
79
44
  },
80
- isCurrent: (_path: string[]) => {
45
+ [getOrCreateStateAtom],
46
+ );
47
+
48
+ const handleOpenChange = useCallback(
49
+ ({ path: _path, open }: { path: string[]; open: boolean }) => {
81
50
  const path = Path.create(..._path);
82
- const object = state.get(path) ?? live({ open: false, current: false });
83
- if (!state.has(path)) {
84
- state.set(path, object);
85
- }
51
+ const atom = getOrCreateStateAtom(path);
52
+ const prev = registry.get(atom);
53
+ registry.set(atom, { ...prev, open });
54
+ },
55
+ [getOrCreateStateAtom, registry],
56
+ );
86
57
 
87
- return object.current;
58
+ const handleSelect = useCallback(
59
+ ({ path: _path, current }: { path: string[]; current: boolean }) => {
60
+ const path = Path.create(..._path);
61
+ const atom = getOrCreateStateAtom(path);
62
+ const prev = registry.get(atom);
63
+ registry.set(atom, { ...prev, current });
88
64
  },
89
- renderColumns: () => {
90
- return (
65
+ [getOrCreateStateAtom, registry],
66
+ );
67
+
68
+ useEffect(() => {
69
+ return monitorForElements({
70
+ canMonitor: ({ source }) => typeof source.data.id === 'string' && Array.isArray(source.data.path),
71
+ onDrop: ({ location, source }) => {
72
+ if (!location.current.dropTargets.length) {
73
+ return;
74
+ }
75
+
76
+ const target = location.current.dropTargets[0];
77
+ const instruction: Instruction | null = extractInstruction(target.data);
78
+ if (instruction !== null) {
79
+ updateState({
80
+ state: tree,
81
+ instruction,
82
+ source: source.data as TreeData,
83
+ target: target.data as TreeData,
84
+ });
85
+ }
86
+ },
87
+ });
88
+ }, []);
89
+
90
+ return (
91
+ <Tree
92
+ id={tree.id}
93
+ draggable={draggable}
94
+ useItems={(parent?: TestItem) => parent?.items ?? tree.items}
95
+ getProps={(parent: TestItem) => ({
96
+ id: parent.id,
97
+ label: parent.name,
98
+ icon: parent.icon,
99
+ ...((parent.items?.length ?? 0) > 0 && {
100
+ parentOf: parent.items!.map(({ id }) => id),
101
+ }),
102
+ })}
103
+ useIsOpen={(_path: string[]) => useItemState(_path).open}
104
+ useIsCurrent={(_path: string[]) => useItemState(_path).current}
105
+ renderColumns={() => (
91
106
  <div className='flex items-center'>
92
107
  <Icon icon='ph--placeholder--regular' size={5} />
93
108
  </div>
94
- );
95
- },
96
- onOpenChange: ({ path: _path, open }) => {
97
- const path = Path.create(..._path);
98
- const object = state.get(path);
99
- object!.open = open;
100
- },
101
- onSelect: ({ path: _path, current }) => {
102
- const path = Path.create(..._path);
103
- const object = state.get(path);
104
- object!.current = current;
105
- },
106
- },
109
+ )}
110
+ onOpenChange={handleOpenChange}
111
+ onSelect={handleSelect}
112
+ />
113
+ );
107
114
  };
108
115
 
116
+ const meta = {
117
+ title: 'ui/react-ui-list/Tree',
118
+
119
+ decorators: [withTheme, withRegistry],
120
+ component: Tree,
121
+ render: DefaultStory,
122
+ } satisfies Meta<typeof Tree<TestItem>>;
123
+
109
124
  export default meta;
110
125
 
111
- export const Default = {};
126
+ type Story = StoryObj<typeof DefaultStory>;
127
+
128
+ export const Default: Story = {};
112
129
 
113
- export const Draggable: StoryObj<typeof Tree> = {
130
+ export const Draggable: Story = {
114
131
  args: {
115
132
  draggable: true,
116
133
  },
@@ -4,34 +4,45 @@
4
4
 
5
5
  import React, { useMemo } from 'react';
6
6
 
7
- import { type HasId } from '@dxos/echo-schema';
8
7
  import { Treegrid, type TreegridRootProps } from '@dxos/react-ui';
9
8
 
10
9
  import { type TreeContextType, TreeProvider } from './TreeContext';
11
10
  import { TreeItem, type TreeItemProps } from './TreeItem';
12
11
 
13
- export type TreeProps<T extends HasId = any, O = any> = {
12
+ export type TreeProps<T extends { id: string } = any, O = any> = {
14
13
  root?: T;
15
14
  path?: string[];
16
15
  id: string;
17
16
  } & TreeContextType<T, O> &
18
17
  Partial<Pick<TreegridRootProps, 'gridTemplateColumns' | 'classNames'>> &
19
- Pick<TreeItemProps<T>, 'draggable' | 'renderColumns' | 'canDrop' | 'onOpenChange' | 'onSelect' | 'levelOffset'>;
18
+ Pick<
19
+ TreeItemProps<T>,
20
+ | 'draggable'
21
+ | 'renderColumns'
22
+ | 'blockInstruction'
23
+ | 'canDrop'
24
+ | 'canSelect'
25
+ | 'onOpenChange'
26
+ | 'onSelect'
27
+ | 'levelOffset'
28
+ >;
20
29
 
21
- export const Tree = <T extends HasId = any, O = any>({
30
+ export const Tree = <T extends { id: string } = any, O = any>({
22
31
  root,
23
32
  path,
24
33
  id,
25
34
  useItems,
26
35
  getProps,
27
- isOpen,
28
- isCurrent,
36
+ useIsOpen,
37
+ useIsCurrent,
29
38
  draggable = false,
30
39
  gridTemplateColumns = '[tree-row-start] 1fr min-content [tree-row-end]',
31
40
  classNames,
32
41
  levelOffset,
33
42
  renderColumns,
43
+ blockInstruction,
34
44
  canDrop,
45
+ canSelect,
35
46
  onOpenChange,
36
47
  onSelect,
37
48
  }: TreeProps<T, O>) => {
@@ -39,10 +50,10 @@ export const Tree = <T extends HasId = any, O = any>({
39
50
  () => ({
40
51
  useItems,
41
52
  getProps,
42
- isOpen,
43
- isCurrent,
53
+ useIsOpen,
54
+ useIsCurrent,
44
55
  }),
45
- [useItems, getProps, isOpen, isCurrent],
56
+ [useItems, getProps, useIsOpen, useIsCurrent],
46
57
  );
47
58
  const items = useItems(root);
48
59
  const treePath = useMemo(() => (path ? [...path, id] : [id]), [id, path]);
@@ -59,7 +70,9 @@ export const Tree = <T extends HasId = any, O = any>({
59
70
  levelOffset={levelOffset}
60
71
  draggable={draggable}
61
72
  renderColumns={renderColumns}
73
+ blockInstruction={blockInstruction}
62
74
  canDrop={canDrop}
75
+ canSelect={canSelect}
63
76
  onOpenChange={onOpenChange}
64
77
  onSelect={onSelect}
65
78
  />
@@ -11,18 +11,21 @@ export type TreeItemDataProps = {
11
11
  id: string;
12
12
  label: Label;
13
13
  parentOf?: string[];
14
- icon?: string;
15
- disabled?: boolean;
16
14
  className?: string;
17
15
  headingClassName?: string;
16
+ icon?: string;
17
+ iconHue?: string;
18
+ disabled?: boolean;
18
19
  testId?: string;
19
20
  };
20
21
 
21
22
  export type TreeContextType<T = any, O = any> = {
22
23
  useItems: (parent?: T, options?: O) => T[];
23
24
  getProps: (item: T, parent: string[]) => TreeItemDataProps;
24
- isOpen: (path: string[], item: T) => boolean;
25
- isCurrent: (path: string[], item: T) => boolean;
25
+ /** Hook that subscribes to and returns the open state for a tree item. */
26
+ useIsOpen: (path: string[], item: T) => boolean;
27
+ /** Hook that subscribes to and returns the current state for a tree item. */
28
+ useIsCurrent: (path: string[], item: T) => boolean;
26
29
  };
27
30
 
28
31
  const TreeContext = createContext<null | TreeContextType>(null);
@@ -5,35 +5,34 @@
5
5
  import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
6
6
  import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
7
7
  import {
8
- attachInstruction,
9
- extractInstruction,
10
8
  type Instruction,
11
9
  type ItemMode,
10
+ attachInstruction,
11
+ extractInstruction,
12
12
  } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
13
- import { Schema } from 'effect';
14
- import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type FC, type KeyboardEvent } from 'react';
13
+ import * as Schema from 'effect/Schema';
14
+ import React, { type FC, type KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
15
 
16
- import { type HasId } from '@dxos/echo-schema';
17
16
  import { invariant } from '@dxos/invariant';
18
- import { Treegrid, TreeItem as NaturalTreeItem } from '@dxos/react-ui';
17
+ import { TreeItem as NaturalTreeItem, Treegrid } from '@dxos/react-ui';
19
18
  import {
19
+ ghostFocusWithin,
20
20
  ghostHover,
21
21
  hoverableControls,
22
22
  hoverableFocusedKeyboardControls,
23
23
  hoverableFocusedWithinControls,
24
- mx,
25
- } from '@dxos/react-ui-theme';
24
+ } from '@dxos/ui-theme';
26
25
 
26
+ import { DEFAULT_INDENTATION, paddingIndentation } from './helpers';
27
27
  import { useTree } from './TreeContext';
28
28
  import { TreeItemHeading } from './TreeItemHeading';
29
29
  import { TreeItemToggle } from './TreeItemToggle';
30
- import { DEFAULT_INDENTATION, paddingIndentation } from './helpers';
31
-
32
- type TreeItemState = 'idle' | 'dragging' | 'preview' | 'parent-of-instruction';
33
30
 
34
31
  const hoverableDescriptionIcons =
35
32
  '[--icons-color:inherit] hover-hover:[--icons-color:var(--description-text)] hover-hover:hover:[--icons-color:inherit] focus-within:[--icons-color:inherit]';
36
33
 
34
+ type TreeItemState = 'idle' | 'dragging' | 'preview' | 'parent-of-instruction';
35
+
37
36
  export const TreeDataSchema = Schema.Struct({
38
37
  id: Schema.String,
39
38
  path: Schema.Array(Schema.String),
@@ -41,37 +40,42 @@ export const TreeDataSchema = Schema.Struct({
41
40
  });
42
41
 
43
42
  export type TreeData = Schema.Schema.Type<typeof TreeDataSchema>;
44
-
45
43
  export const isTreeData = (data: unknown): data is TreeData => Schema.is(TreeDataSchema)(data);
46
44
 
47
- export type TreeItemProps<T extends HasId = any> = {
45
+ export type ColumnRenderer<T extends { id: string } = any> = FC<{
46
+ item: T;
47
+ path: string[];
48
+ open: boolean;
49
+ menuOpen: boolean;
50
+ setMenuOpen: (open: boolean) => void;
51
+ }>;
52
+
53
+ export type TreeItemProps<T extends { id: string } = any> = {
48
54
  item: T;
49
55
  path: string[];
50
56
  levelOffset?: number;
51
57
  last: boolean;
52
58
  draggable?: boolean;
53
- renderColumns?: FC<{
54
- item: T;
55
- path: string[];
56
- open: boolean;
57
- menuOpen: boolean;
58
- setMenuOpen: (open: boolean) => void;
59
- }>;
59
+ renderColumns?: ColumnRenderer<T>;
60
+ blockInstruction?: (params: { instruction: Instruction; source: TreeData; target: TreeData }) => boolean;
60
61
  canDrop?: (params: { source: TreeData; target: TreeData }) => boolean;
62
+ canSelect?: (params: { item: T; path: string[] }) => boolean;
61
63
  onOpenChange?: (params: { item: T; path: string[]; open: boolean }) => void;
62
64
  onSelect?: (params: { item: T; path: string[]; current: boolean; option: boolean }) => void;
63
65
  };
64
66
 
65
- const RawTreeItem = <T extends HasId = any>({
67
+ const RawTreeItem = <T extends { id: string } = any>({
66
68
  item,
67
69
  path: _path,
70
+ levelOffset = 2,
68
71
  last,
69
72
  draggable: _draggable,
70
73
  renderColumns: Columns,
74
+ blockInstruction,
71
75
  canDrop,
76
+ canSelect,
72
77
  onOpenChange,
73
78
  onSelect,
74
- levelOffset = 2,
75
79
  }: TreeItemProps<T>) => {
76
80
  const rowRef = useRef<HTMLDivElement | null>(null);
77
81
  const buttonRef = useRef<HTMLButtonElement | null>(null);
@@ -81,15 +85,16 @@ const RawTreeItem = <T extends HasId = any>({
81
85
  const [instruction, setInstruction] = useState<Instruction | null>(null);
82
86
  const [menuOpen, setMenuOpen] = useState(false);
83
87
 
84
- const { useItems, getProps, isOpen, isCurrent } = useTree();
88
+ const { useItems, getProps, useIsOpen, useIsCurrent } = useTree();
85
89
  const items = useItems(item);
86
- const { id, label, parentOf, icon, disabled, className, headingClassName, testId } = getProps(item, _path);
90
+ const { id, parentOf, label, className, headingClassName, icon, iconHue, disabled, testId } = getProps(item, _path);
87
91
  const path = useMemo(() => [..._path, id], [_path, id]);
88
- const open = isOpen(path, item);
89
- const current = isCurrent(path, item);
92
+ const open = useIsOpen(path, item);
93
+ const current = useIsCurrent(path, item);
90
94
  const level = path.length - levelOffset;
91
95
  const isBranch = !!parentOf;
92
96
  const mode: ItemMode = last ? 'last-in-group' : open ? 'expanded' : 'standard';
97
+ const canSelectItem = canSelect?.({ item, path }) ?? true;
93
98
 
94
99
  const cancelExpand = useCallback(() => {
95
100
  if (cancelExpandRef.current) {
@@ -145,7 +150,11 @@ const RawTreeItem = <T extends HasId = any>({
145
150
  },
146
151
  getIsSticky: () => true,
147
152
  onDrag: ({ self, source }) => {
148
- const instruction = extractInstruction(self.data);
153
+ const desired = extractInstruction(self.data);
154
+ const block =
155
+ desired && blockInstruction?.({ instruction: desired, source: source.data as TreeData, target: data });
156
+ const instruction: Instruction | null =
157
+ block && desired.type !== 'instruction-blocked' ? { type: 'instruction-blocked', desired } : desired;
149
158
 
150
159
  if (source.data.id !== id) {
151
160
  if (instruction?.type === 'make-child' && isBranch && !open && !cancelExpandRef.current) {
@@ -176,43 +185,42 @@ const RawTreeItem = <T extends HasId = any>({
176
185
  },
177
186
  }),
178
187
  );
179
- }, [_draggable, item, id, mode, path, open, canDrop]);
188
+ }, [_draggable, item, id, mode, path, open, blockInstruction, canDrop]);
180
189
 
181
190
  // Cancel expand on unmount.
182
191
  useEffect(() => () => cancelExpand(), [cancelExpand]);
183
192
 
184
- const handleOpenChange = useCallback(
193
+ const handleOpenToggle = useCallback(
185
194
  () => onOpenChange?.({ item, path, open: !open }),
186
195
  [onOpenChange, item, path, open],
187
196
  );
188
197
 
189
198
  const handleSelect = useCallback(
190
199
  (option = false) => {
191
- if (isBranch) {
192
- handleOpenChange();
193
- } else {
200
+ // If the item is a branch, toggle it if:
201
+ // - also holding down the option key
202
+ // - or the item is currently selected
203
+ if (isBranch && (option || current)) {
204
+ handleOpenToggle();
205
+ } else if (canSelectItem) {
206
+ canSelect?.({ item, path });
194
207
  rowRef.current?.focus();
195
208
  onSelect?.({ item, path, current: !current, option });
196
209
  }
197
210
  },
198
- [item, path, current, isBranch, handleOpenChange, onSelect],
211
+ [item, path, current, isBranch, canSelectItem, handleOpenToggle, onSelect],
199
212
  );
200
213
 
201
214
  const handleKeyDown = useCallback(
202
215
  (event: KeyboardEvent) => {
203
216
  switch (event.key) {
204
217
  case 'ArrowRight':
205
- isBranch && !open && handleOpenChange();
206
- break;
207
218
  case 'ArrowLeft':
208
- isBranch && open && handleOpenChange();
209
- break;
210
- case ' ':
211
- handleSelect(event.altKey);
219
+ isBranch && handleOpenToggle();
212
220
  break;
213
221
  }
214
222
  },
215
- [isBranch, open, handleOpenChange, handleSelect],
223
+ [isBranch, open, handleOpenToggle, handleSelect],
216
224
  );
217
225
 
218
226
  return (
@@ -223,16 +231,17 @@ const RawTreeItem = <T extends HasId = any>({
223
231
  id={id}
224
232
  aria-labelledby={`${id}__label`}
225
233
  parentOf={parentOf?.join(Treegrid.PARENT_OF_SEPARATOR)}
226
- classNames={mx(
234
+ classNames={[
227
235
  'grid grid-cols-subgrid col-[tree-row] mbs-0.5 aria-[current]:bg-activeSurface',
228
236
  hoverableControls,
229
237
  hoverableFocusedKeyboardControls,
230
238
  hoverableFocusedWithinControls,
231
239
  hoverableDescriptionIcons,
232
240
  ghostHover,
241
+ ghostFocusWithin,
233
242
  className,
234
- )}
235
- data-itemid={id}
243
+ ]}
244
+ data-object-id={id}
236
245
  data-testid={testId}
237
246
  // NOTE(thure): This is intentionally an empty string to for descendents to select by in the CSS
238
247
  // without alerting the user (except for in the correct link element). See also:
@@ -244,26 +253,27 @@ const RawTreeItem = <T extends HasId = any>({
244
253
  setMenuOpen(true);
245
254
  }}
246
255
  >
247
- <Treegrid.Cell
248
- indent
249
- classNames='relative grid grid-cols-subgrid col-[tree-row]'
256
+ <div
257
+ role='none'
258
+ className='indent relative grid grid-cols-subgrid col-[tree-row]'
250
259
  style={paddingIndentation(level)}
251
260
  >
252
- <div role='none' className='flex items-center'>
253
- <TreeItemToggle isBranch={isBranch} open={open} onToggle={handleOpenChange} />
261
+ <Treegrid.Cell classNames='flex items-center'>
262
+ <TreeItemToggle isBranch={isBranch} open={open} onClick={handleOpenToggle} />
254
263
  <TreeItemHeading
255
- ref={buttonRef}
256
- label={label}
257
- icon={icon}
258
- className={headingClassName}
259
264
  disabled={disabled}
260
265
  current={current}
266
+ label={label}
267
+ className={headingClassName}
268
+ icon={icon}
269
+ iconHue={iconHue}
261
270
  onSelect={handleSelect}
271
+ ref={buttonRef}
262
272
  />
263
- </div>
273
+ </Treegrid.Cell>
264
274
  {Columns && <Columns item={item} path={path} open={open} menuOpen={menuOpen} setMenuOpen={setMenuOpen} />}
265
275
  {instruction && <NaturalTreeItem.DropIndicator instruction={instruction} gap={2} />}
266
- </Treegrid.Cell>
276
+ </div>
267
277
  </Treegrid.Row>
268
278
  {open &&
269
279
  items.map((item, index) => (
@@ -274,7 +284,9 @@ const RawTreeItem = <T extends HasId = any>({
274
284
  last={index === items.length - 1}
275
285
  draggable={_draggable}
276
286
  renderColumns={Columns}
287
+ blockInstruction={blockInstruction}
277
288
  canDrop={canDrop}
289
+ canSelect={canSelect}
278
290
  onOpenChange={onOpenChange}
279
291
  onSelect={onSelect}
280
292
  />
@@ -4,25 +4,27 @@
4
4
 
5
5
  import React, { type KeyboardEvent, type MouseEvent, forwardRef, memo, useCallback } from 'react';
6
6
 
7
- import { Button, Icon, toLocalizedString, useTranslation, type Label } from '@dxos/react-ui';
7
+ import { Button, Icon, type Label, toLocalizedString, useTranslation } from '@dxos/react-ui';
8
8
  import { TextTooltip } from '@dxos/react-ui-text-tooltip';
9
- import { mx } from '@dxos/react-ui-theme';
9
+ import { getStyles } from '@dxos/ui-theme';
10
10
 
11
11
  // TODO(wittjosiah): Consider whether there should be a separate disabled prop which was visually distinct
12
12
  // rather than just making the item unselectable.
13
- export type NavTreeItemHeadingProps = {
13
+ export type TreeItemHeadingProps = {
14
14
  label: Label;
15
- icon?: string;
16
15
  className?: string;
16
+ icon?: string;
17
+ iconHue?: string;
17
18
  disabled?: boolean;
18
19
  current?: boolean;
19
20
  onSelect?: (option: boolean) => void;
20
21
  };
21
22
 
22
23
  export const TreeItemHeading = memo(
23
- forwardRef<HTMLButtonElement, NavTreeItemHeadingProps>(
24
- ({ label, icon, className, disabled, current, onSelect }, forwardedRef) => {
24
+ forwardRef<HTMLButtonElement, TreeItemHeadingProps>(
25
+ ({ label, className, icon, iconHue, disabled, current, onSelect }, forwardedRef) => {
25
26
  const { t } = useTranslation();
27
+ const styles = iconHue ? getStyles(iconHue) : undefined;
26
28
 
27
29
  const handleSelect = useCallback(
28
30
  (event: MouseEvent) => {
@@ -55,17 +57,17 @@ export const TreeItemHeading = memo(
55
57
  data-testid='treeItem.heading'
56
58
  variant='ghost'
57
59
  density='fine'
58
- classNames={mx(
60
+ classNames={[
59
61
  'grow gap-2 pis-0.5 hover:bg-transparent dark:hover:bg-transparent',
60
62
  'disabled:cursor-default disabled:opacity-100',
61
63
  className,
62
- )}
64
+ ]}
63
65
  disabled={disabled}
64
66
  onClick={handleSelect}
65
67
  onKeyDown={handleButtonKeydown}
66
68
  {...(current && { 'aria-current': 'location' })}
67
69
  >
68
- {icon && <Icon icon={icon ?? 'ph--placeholder--regular'} size={5} classNames='mlb-1' />}
70
+ {icon && <Icon icon={icon ?? 'ph--placeholder--regular'} size={5} classNames={['mlb-1', styles?.icon]} />}
69
71
  <span className='flex-1 is-0 truncate text-start text-sm font-normal' data-tooltip>
70
72
  {toLocalizedString(label, t)}
71
73
  </span>