@dxos/react-ui-list 0.8.4-main.b97322e → 0.8.4-main.bc674ce

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 (52) hide show
  1. package/dist/lib/browser/index.mjs +657 -728
  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 -728
  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 +2 -2
  18. package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
  19. package/dist/types/src/components/List/testing.d.ts +1 -1
  20. package/dist/types/src/components/List/testing.d.ts.map +1 -1
  21. package/dist/types/src/components/Tree/Tree.d.ts +7 -4
  22. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  23. package/dist/types/src/components/Tree/Tree.stories.d.ts +18 -7
  24. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  25. package/dist/types/src/components/Tree/TreeContext.d.ts +7 -4
  26. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  27. package/dist/types/src/components/Tree/TreeItem.d.ts +24 -10
  28. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  29. package/dist/types/src/components/Tree/TreeItemHeading.d.ts +4 -3
  30. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  31. package/dist/types/src/components/Tree/TreeItemToggle.d.ts +3 -3
  32. package/dist/types/src/components/Tree/TreeItemToggle.d.ts.map +1 -1
  33. package/dist/types/src/components/Tree/testing.d.ts +3 -3
  34. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  35. package/dist/types/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +31 -28
  37. package/src/components/Accordion/Accordion.stories.tsx +7 -9
  38. package/src/components/Accordion/Accordion.tsx +1 -1
  39. package/src/components/Accordion/AccordionItem.tsx +7 -4
  40. package/src/components/Accordion/AccordionRoot.tsx +1 -1
  41. package/src/components/List/List.stories.tsx +41 -27
  42. package/src/components/List/List.tsx +2 -5
  43. package/src/components/List/ListItem.tsx +40 -28
  44. package/src/components/List/ListRoot.tsx +3 -3
  45. package/src/components/List/testing.ts +3 -3
  46. package/src/components/Tree/Tree.stories.tsx +101 -84
  47. package/src/components/Tree/Tree.tsx +22 -9
  48. package/src/components/Tree/TreeContext.tsx +7 -4
  49. package/src/components/Tree/TreeItem.tsx +64 -51
  50. package/src/components/Tree/TreeItemHeading.tsx +9 -6
  51. package/src/components/Tree/TreeItemToggle.tsx +29 -18
  52. 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,34 +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
- } from '@dxos/react-ui-theme';
24
+ } from '@dxos/ui-theme';
25
25
 
26
+ import { DEFAULT_INDENTATION, paddingIndentation } from './helpers';
26
27
  import { useTree } from './TreeContext';
27
28
  import { TreeItemHeading } from './TreeItemHeading';
28
29
  import { TreeItemToggle } from './TreeItemToggle';
29
- import { DEFAULT_INDENTATION, paddingIndentation } from './helpers';
30
-
31
- type TreeItemState = 'idle' | 'dragging' | 'preview' | 'parent-of-instruction';
32
30
 
33
31
  const hoverableDescriptionIcons =
34
32
  '[--icons-color:inherit] hover-hover:[--icons-color:var(--description-text)] hover-hover:hover:[--icons-color:inherit] focus-within:[--icons-color:inherit]';
35
33
 
34
+ type TreeItemState = 'idle' | 'dragging' | 'preview' | 'parent-of-instruction';
35
+
36
36
  export const TreeDataSchema = Schema.Struct({
37
37
  id: Schema.String,
38
38
  path: Schema.Array(Schema.String),
@@ -40,37 +40,42 @@ export const TreeDataSchema = Schema.Struct({
40
40
  });
41
41
 
42
42
  export type TreeData = Schema.Schema.Type<typeof TreeDataSchema>;
43
-
44
43
  export const isTreeData = (data: unknown): data is TreeData => Schema.is(TreeDataSchema)(data);
45
44
 
46
- 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> = {
47
54
  item: T;
48
55
  path: string[];
49
56
  levelOffset?: number;
50
57
  last: boolean;
51
58
  draggable?: boolean;
52
- renderColumns?: FC<{
53
- item: T;
54
- path: string[];
55
- open: boolean;
56
- menuOpen: boolean;
57
- setMenuOpen: (open: boolean) => void;
58
- }>;
59
+ renderColumns?: ColumnRenderer<T>;
60
+ blockInstruction?: (params: { instruction: Instruction; source: TreeData; target: TreeData }) => boolean;
59
61
  canDrop?: (params: { source: TreeData; target: TreeData }) => boolean;
62
+ canSelect?: (params: { item: T; path: string[] }) => boolean;
60
63
  onOpenChange?: (params: { item: T; path: string[]; open: boolean }) => void;
61
64
  onSelect?: (params: { item: T; path: string[]; current: boolean; option: boolean }) => void;
62
65
  };
63
66
 
64
- const RawTreeItem = <T extends HasId = any>({
67
+ const RawTreeItem = <T extends { id: string } = any>({
65
68
  item,
66
69
  path: _path,
70
+ levelOffset = 2,
67
71
  last,
68
72
  draggable: _draggable,
69
73
  renderColumns: Columns,
74
+ blockInstruction,
70
75
  canDrop,
76
+ canSelect,
71
77
  onOpenChange,
72
78
  onSelect,
73
- levelOffset = 2,
74
79
  }: TreeItemProps<T>) => {
75
80
  const rowRef = useRef<HTMLDivElement | null>(null);
76
81
  const buttonRef = useRef<HTMLButtonElement | null>(null);
@@ -80,15 +85,16 @@ const RawTreeItem = <T extends HasId = any>({
80
85
  const [instruction, setInstruction] = useState<Instruction | null>(null);
81
86
  const [menuOpen, setMenuOpen] = useState(false);
82
87
 
83
- const { useItems, getProps, isOpen, isCurrent } = useTree();
88
+ const { useItems, getProps, useIsOpen, useIsCurrent } = useTree();
84
89
  const items = useItems(item);
85
- 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);
86
91
  const path = useMemo(() => [..._path, id], [_path, id]);
87
- const open = isOpen(path, item);
88
- const current = isCurrent(path, item);
92
+ const open = useIsOpen(path, item);
93
+ const current = useIsCurrent(path, item);
89
94
  const level = path.length - levelOffset;
90
95
  const isBranch = !!parentOf;
91
96
  const mode: ItemMode = last ? 'last-in-group' : open ? 'expanded' : 'standard';
97
+ const canSelectItem = canSelect?.({ item, path }) ?? true;
92
98
 
93
99
  const cancelExpand = useCallback(() => {
94
100
  if (cancelExpandRef.current) {
@@ -144,7 +150,11 @@ const RawTreeItem = <T extends HasId = any>({
144
150
  },
145
151
  getIsSticky: () => true,
146
152
  onDrag: ({ self, source }) => {
147
- 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;
148
158
 
149
159
  if (source.data.id !== id) {
150
160
  if (instruction?.type === 'make-child' && isBranch && !open && !cancelExpandRef.current) {
@@ -175,43 +185,42 @@ const RawTreeItem = <T extends HasId = any>({
175
185
  },
176
186
  }),
177
187
  );
178
- }, [_draggable, item, id, mode, path, open, canDrop]);
188
+ }, [_draggable, item, id, mode, path, open, blockInstruction, canDrop]);
179
189
 
180
190
  // Cancel expand on unmount.
181
191
  useEffect(() => () => cancelExpand(), [cancelExpand]);
182
192
 
183
- const handleOpenChange = useCallback(
193
+ const handleOpenToggle = useCallback(
184
194
  () => onOpenChange?.({ item, path, open: !open }),
185
195
  [onOpenChange, item, path, open],
186
196
  );
187
197
 
188
198
  const handleSelect = useCallback(
189
199
  (option = false) => {
190
- if (isBranch) {
191
- handleOpenChange();
192
- } 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 });
193
207
  rowRef.current?.focus();
194
208
  onSelect?.({ item, path, current: !current, option });
195
209
  }
196
210
  },
197
- [item, path, current, isBranch, handleOpenChange, onSelect],
211
+ [item, path, current, isBranch, canSelectItem, handleOpenToggle, onSelect],
198
212
  );
199
213
 
200
214
  const handleKeyDown = useCallback(
201
215
  (event: KeyboardEvent) => {
202
216
  switch (event.key) {
203
217
  case 'ArrowRight':
204
- isBranch && !open && handleOpenChange();
205
- break;
206
218
  case 'ArrowLeft':
207
- isBranch && open && handleOpenChange();
208
- break;
209
- case ' ':
210
- handleSelect(event.altKey);
219
+ isBranch && handleOpenToggle();
211
220
  break;
212
221
  }
213
222
  },
214
- [isBranch, open, handleOpenChange, handleSelect],
223
+ [isBranch, open, handleOpenToggle, handleSelect],
215
224
  );
216
225
 
217
226
  return (
@@ -229,9 +238,10 @@ const RawTreeItem = <T extends HasId = any>({
229
238
  hoverableFocusedWithinControls,
230
239
  hoverableDescriptionIcons,
231
240
  ghostHover,
241
+ ghostFocusWithin,
232
242
  className,
233
243
  ]}
234
- data-itemid={id}
244
+ data-object-id={id}
235
245
  data-testid={testId}
236
246
  // NOTE(thure): This is intentionally an empty string to for descendents to select by in the CSS
237
247
  // without alerting the user (except for in the correct link element). See also:
@@ -243,26 +253,27 @@ const RawTreeItem = <T extends HasId = any>({
243
253
  setMenuOpen(true);
244
254
  }}
245
255
  >
246
- <Treegrid.Cell
247
- indent
248
- 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]'
249
259
  style={paddingIndentation(level)}
250
260
  >
251
- <div role='none' className='flex items-center'>
252
- <TreeItemToggle isBranch={isBranch} open={open} onToggle={handleOpenChange} />
261
+ <Treegrid.Cell classNames='flex items-center'>
262
+ <TreeItemToggle isBranch={isBranch} open={open} onClick={handleOpenToggle} />
253
263
  <TreeItemHeading
254
- ref={buttonRef}
255
- label={label}
256
- icon={icon}
257
- className={headingClassName}
258
264
  disabled={disabled}
259
265
  current={current}
266
+ label={label}
267
+ className={headingClassName}
268
+ icon={icon}
269
+ iconHue={iconHue}
260
270
  onSelect={handleSelect}
271
+ ref={buttonRef}
261
272
  />
262
- </div>
273
+ </Treegrid.Cell>
263
274
  {Columns && <Columns item={item} path={path} open={open} menuOpen={menuOpen} setMenuOpen={setMenuOpen} />}
264
275
  {instruction && <NaturalTreeItem.DropIndicator instruction={instruction} gap={2} />}
265
- </Treegrid.Cell>
276
+ </div>
266
277
  </Treegrid.Row>
267
278
  {open &&
268
279
  items.map((item, index) => (
@@ -273,7 +284,9 @@ const RawTreeItem = <T extends HasId = any>({
273
284
  last={index === items.length - 1}
274
285
  draggable={_draggable}
275
286
  renderColumns={Columns}
287
+ blockInstruction={blockInstruction}
276
288
  canDrop={canDrop}
289
+ canSelect={canSelect}
277
290
  onOpenChange={onOpenChange}
278
291
  onSelect={onSelect}
279
292
  />
@@ -4,24 +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 { getStyles } from '@dxos/ui-theme';
9
10
 
10
11
  // TODO(wittjosiah): Consider whether there should be a separate disabled prop which was visually distinct
11
12
  // rather than just making the item unselectable.
12
- export type NavTreeItemHeadingProps = {
13
+ export type TreeItemHeadingProps = {
13
14
  label: Label;
14
- icon?: string;
15
15
  className?: string;
16
+ icon?: string;
17
+ iconHue?: string;
16
18
  disabled?: boolean;
17
19
  current?: boolean;
18
20
  onSelect?: (option: boolean) => void;
19
21
  };
20
22
 
21
23
  export const TreeItemHeading = memo(
22
- forwardRef<HTMLButtonElement, NavTreeItemHeadingProps>(
23
- ({ label, icon, className, disabled, current, onSelect }, forwardedRef) => {
24
+ forwardRef<HTMLButtonElement, TreeItemHeadingProps>(
25
+ ({ label, className, icon, iconHue, disabled, current, onSelect }, forwardedRef) => {
24
26
  const { t } = useTranslation();
27
+ const styles = iconHue ? getStyles(iconHue) : undefined;
25
28
 
26
29
  const handleSelect = useCallback(
27
30
  (event: MouseEvent) => {
@@ -64,7 +67,7 @@ export const TreeItemHeading = memo(
64
67
  onKeyDown={handleButtonKeydown}
65
68
  {...(current && { 'aria-current': 'location' })}
66
69
  >
67
- {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]} />}
68
71
  <span className='flex-1 is-0 truncate text-start text-sm font-normal' data-tooltip>
69
72
  {toLocalizedString(label, t)}
70
73
  </span>
@@ -4,29 +4,40 @@
4
4
 
5
5
  import React, { forwardRef, memo } from 'react';
6
6
 
7
- import { Button, Icon } from '@dxos/react-ui';
7
+ import { IconButton, type IconButtonProps } from '@dxos/react-ui';
8
8
 
9
- export type TreeItemToggleProps = {
9
+ export type TreeItemToggleProps = Omit<IconButtonProps, 'icon' | 'size' | 'label'> & {
10
10
  open?: boolean;
11
11
  isBranch?: boolean;
12
- onToggle?: () => void;
13
12
  hidden?: boolean;
14
13
  };
15
14
 
16
15
  export const TreeItemToggle = memo(
17
- forwardRef<HTMLButtonElement, TreeItemToggleProps>(({ open, isBranch, hidden, onToggle }, forwardedRef) => {
18
- return (
19
- <Button
20
- ref={forwardedRef}
21
- data-testid='treeItem.toggle'
22
- aria-expanded={open}
23
- variant='ghost'
24
- density='fine'
25
- classNames={['is-6 pli-0 dx-focus-ring-inset', hidden ? 'hidden' : !isBranch && 'invisible']}
26
- onClick={onToggle}
27
- >
28
- <Icon icon='ph--caret-right--bold' size={3} classNames={['transition duration-200', open && 'rotate-90']} />
29
- </Button>
30
- );
31
- }),
16
+ forwardRef<HTMLButtonElement, TreeItemToggleProps>(
17
+ ({ open, isBranch, hidden, classNames, ...props }, forwardedRef) => {
18
+ return (
19
+ <IconButton
20
+ ref={forwardedRef}
21
+ data-testid='treeItem.toggle'
22
+ aria-expanded={open}
23
+ variant='ghost'
24
+ density='fine'
25
+ classNames={[
26
+ 'bs-full is-6 pli-0',
27
+ '[&_svg]:transition-[transform] [&_svg]:duration-200',
28
+ open && '[&_svg]:rotate-90',
29
+ hidden ? 'hidden' : !isBranch && 'invisible',
30
+ classNames,
31
+ ]}
32
+ size={3}
33
+ icon='ph--caret-right--bold'
34
+ iconOnly
35
+ noTooltip
36
+ label={open ? 'Click to close' : 'Click to open'}
37
+ tabIndex={-1}
38
+ {...props}
39
+ />
40
+ );
41
+ },
42
+ ),
32
43
  );