@dxos/react-ui-list 0.8.4-main.c4373fc → 0.8.4-main.c85a9c8dae

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 (47) hide show
  1. package/dist/lib/browser/index.mjs +657 -712
  2. package/dist/lib/browser/index.mjs.map +3 -3
  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 +3 -3
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/Accordion/Accordion.stories.d.ts +0 -3
  8. package/dist/types/src/components/Accordion/Accordion.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/List/List.d.ts +2 -2
  10. package/dist/types/src/components/List/List.d.ts.map +1 -1
  11. package/dist/types/src/components/List/List.stories.d.ts +2 -2
  12. package/dist/types/src/components/List/List.stories.d.ts.map +1 -1
  13. package/dist/types/src/components/List/ListItem.d.ts +2 -2
  14. package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
  15. package/dist/types/src/components/List/ListRoot.d.ts +2 -2
  16. package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
  17. package/dist/types/src/components/Tree/Tree.d.ts +10 -6
  18. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  19. package/dist/types/src/components/Tree/Tree.stories.d.ts +9 -28
  20. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  21. package/dist/types/src/components/Tree/TreeContext.d.ts +18 -9
  22. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  23. package/dist/types/src/components/Tree/TreeItem.d.ts +20 -3
  24. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  25. package/dist/types/src/components/Tree/TreeItemHeading.d.ts +1 -1
  26. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  27. package/dist/types/src/components/Tree/index.d.ts +2 -0
  28. package/dist/types/src/components/Tree/index.d.ts.map +1 -1
  29. package/dist/types/src/components/Tree/testing.d.ts +2 -2
  30. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +30 -28
  33. package/src/components/Accordion/Accordion.stories.tsx +2 -5
  34. package/src/components/Accordion/AccordionItem.tsx +3 -3
  35. package/src/components/Accordion/AccordionRoot.tsx +1 -1
  36. package/src/components/List/List.stories.tsx +29 -17
  37. package/src/components/List/ListItem.tsx +13 -13
  38. package/src/components/List/ListRoot.tsx +2 -2
  39. package/src/components/List/testing.ts +2 -2
  40. package/src/components/Tree/Tree.stories.tsx +150 -60
  41. package/src/components/Tree/Tree.tsx +39 -41
  42. package/src/components/Tree/TreeContext.tsx +15 -8
  43. package/src/components/Tree/TreeItem.tsx +99 -51
  44. package/src/components/Tree/TreeItemHeading.tsx +9 -5
  45. package/src/components/Tree/TreeItemToggle.tsx +1 -1
  46. package/src/components/Tree/index.ts +2 -0
  47. package/src/components/Tree/testing.ts +4 -3
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-list",
3
- "version": "0.8.4-main.c4373fc",
3
+ "version": "0.8.4-main.c85a9c8dae",
4
4
  "description": "A list component.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
7
11
  "license": "MIT",
8
12
  "author": "DXOS.org",
9
13
  "type": "module",
@@ -24,39 +28,37 @@
24
28
  "src"
25
29
  ],
26
30
  "dependencies": {
27
- "@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
28
- "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
29
- "@preact-signals/safe-react": "^0.9.0",
30
- "@preact/signals-core": "^1.12.1",
31
+ "@atlaskit/pragmatic-drag-and-drop": "1.7.7",
32
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
33
+ "@effect-atom/atom-react": "^0.5.0",
31
34
  "@radix-ui/react-accordion": "1.2.3",
32
35
  "@radix-ui/react-context": "1.1.1",
33
- "@dxos/debug": "0.8.4-main.c4373fc",
34
- "@dxos/echo": "0.8.4-main.c4373fc",
35
- "@dxos/invariant": "0.8.4-main.c4373fc",
36
- "@dxos/live-object": "0.8.4-main.c4373fc",
37
- "@dxos/log": "0.8.4-main.c4373fc",
38
- "@dxos/react-ui": "0.8.4-main.c4373fc",
39
- "@dxos/react-ui-theme": "0.8.4-main.c4373fc",
40
- "@dxos/react-ui-types": "0.8.4-main.c4373fc",
41
- "@dxos/react-ui-text-tooltip": "0.8.4-main.c4373fc",
42
- "@dxos/util": "0.8.4-main.c4373fc"
36
+ "@dxos/debug": "0.8.4-main.c85a9c8dae",
37
+ "@dxos/invariant": "0.8.4-main.c85a9c8dae",
38
+ "@dxos/echo": "0.8.4-main.c85a9c8dae",
39
+ "@dxos/log": "0.8.4-main.c85a9c8dae",
40
+ "@dxos/react-ui": "0.8.4-main.c85a9c8dae",
41
+ "@dxos/react-ui-text-tooltip": "0.8.4-main.c85a9c8dae",
42
+ "@dxos/ui-theme": "0.8.4-main.c85a9c8dae",
43
+ "@dxos/ui-types": "0.8.4-main.c85a9c8dae",
44
+ "@dxos/util": "0.8.4-main.c85a9c8dae"
43
45
  },
44
46
  "devDependencies": {
45
- "@types/react": "~19.2.2",
46
- "@types/react-dom": "~19.2.1",
47
- "effect": "3.18.3",
48
- "react": "~19.2.0",
49
- "react-dom": "~19.2.0",
50
- "vite": "7.1.9",
51
- "@dxos/random": "0.8.4-main.c4373fc",
52
- "@dxos/storybook-utils": "0.8.4-main.c4373fc"
47
+ "@types/react": "~19.2.7",
48
+ "@types/react-dom": "~19.2.3",
49
+ "effect": "3.19.16",
50
+ "react": "~19.2.3",
51
+ "react-dom": "~19.2.3",
52
+ "vite": "^7.1.11",
53
+ "@dxos/random": "0.8.4-main.c85a9c8dae",
54
+ "@dxos/storybook-utils": "0.8.4-main.c85a9c8dae"
53
55
  },
54
56
  "peerDependencies": {
55
- "effect": "^3.13.3",
56
- "react": "^19.0.0",
57
- "react-dom": "^19.0.0",
58
- "@dxos/react-ui": "0.8.4-main.c4373fc",
59
- "@dxos/react-ui-theme": "0.8.4-main.c4373fc"
57
+ "effect": "3.19.16",
58
+ "react": "~19.2.3",
59
+ "react-dom": "~19.2.3",
60
+ "@dxos/react-ui": "0.8.4-main.c85a9c8dae",
61
+ "@dxos/ui-theme": "0.8.4-main.c85a9c8dae"
60
62
  },
61
63
  "publishConfig": {
62
64
  "access": "public"
@@ -6,7 +6,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import React from 'react';
7
7
 
8
8
  import { faker } from '@dxos/random';
9
- import { withTheme } from '@dxos/react-ui/testing';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
10
 
11
11
  import { Accordion } from './Accordion';
12
12
 
@@ -42,10 +42,7 @@ const DefaultStory = () => {
42
42
  const meta = {
43
43
  title: 'ui/react-ui-list/Accordion',
44
44
  render: DefaultStory,
45
- decorators: [withTheme],
46
- parameters: {
47
- layout: 'column',
48
- },
45
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
49
46
  } satisfies Meta<typeof Accordion>;
50
47
 
51
48
  export default meta;
@@ -7,7 +7,7 @@ import { createContext } from '@radix-ui/react-context';
7
7
  import React, { type PropsWithChildren } from 'react';
8
8
 
9
9
  import { Icon, type ThemedClassName } from '@dxos/react-ui';
10
- import { mx } from '@dxos/react-ui-theme';
10
+ import { mx } from '@dxos/ui-theme';
11
11
 
12
12
  import { type ListItemRecord } from '../List';
13
13
 
@@ -43,7 +43,7 @@ export type AccordionItemHeaderProps = ThemedClassName<AccordionPrimitive.Accord
43
43
  export const AccordionItemHeader = ({ classNames, children, ...props }: AccordionItemHeaderProps) => {
44
44
  return (
45
45
  <AccordionPrimitive.Header {...props} className={mx(classNames)}>
46
- <AccordionPrimitive.Trigger className='group flex items-center p-2 dx-focus-ring-inset is-full text-start'>
46
+ <AccordionPrimitive.Trigger className='group flex items-center p-2 dx-focus-ring-inset w-full text-start'>
47
47
  {children}
48
48
  <Icon
49
49
  icon='ph--caret-right--regular'
@@ -59,7 +59,7 @@ export type AccordionItemBodyProps = ThemedClassName<PropsWithChildren>;
59
59
 
60
60
  export const AccordionItemBody = ({ children, classNames }: AccordionItemBodyProps) => {
61
61
  return (
62
- <AccordionPrimitive.Content className='overflow-hidden data-[state=closed]:animate-slideUp data-[state=open]:animate-slideDown'>
62
+ <AccordionPrimitive.Content className='overflow-hidden data-[state=closed]:animate-slide-up data-[state=open]:animate-slide-down'>
63
63
  <div role='none' className={mx('p-2', classNames)}>
64
64
  {children}
65
65
  </div>
@@ -7,7 +7,7 @@ import { createContext } from '@radix-ui/react-context';
7
7
  import React, { type ReactNode } from 'react';
8
8
 
9
9
  import { type ThemedClassName } from '@dxos/react-ui';
10
- import { mx } from '@dxos/react-ui-theme';
10
+ import { mx } from '@dxos/ui-theme';
11
11
 
12
12
  import { type ListItemRecord } from '../List';
13
13
 
@@ -2,31 +2,43 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
5
6
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
7
  import * as Schema from 'effect/Schema';
7
- import React from 'react';
8
+ import React, { useContext, useMemo } from 'react';
8
9
 
9
- import { live } from '@dxos/live-object';
10
- import { withTheme } from '@dxos/react-ui/testing';
11
- import { ghostHover, mx } from '@dxos/react-ui-theme';
10
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
+ import { withRegistry } from '@dxos/storybook-utils';
12
+ import { ghostHover, mx } from '@dxos/ui-theme';
12
13
  import { arrayMove } from '@dxos/util';
13
14
 
14
15
  import { List, type ListRootProps } from './List';
15
- import { TestItemSchema, type TestItemType, createList } from './testing';
16
+ import { TestItemSchema, type TestItemType, type TestList, createList } from './testing';
16
17
 
17
18
  // TODO(burdon): var-icon-size.
18
- const grid = 'grid grid-cols-[32px_1fr_32px] min-bs-[2rem] rounded';
19
+ const grid = 'grid grid-cols-[32px_1fr_32px] min-h-[2rem] rounded-sm';
20
+
21
+ const DefaultStory = (props: Omit<ListRootProps<TestItemType>, 'items'>) => {
22
+ const registry = useContext(RegistryContext);
23
+ const listAtom = useMemo(() => Atom.make<TestList>(createList(100)).pipe(Atom.keepAlive), []);
24
+ const list = useAtomValue(listAtom);
25
+ const items = list.items;
19
26
 
20
- const DefaultStory = ({ items = [], ...props }: ListRootProps<TestItemType>) => {
21
27
  const handleSelect = (item: TestItemType) => {
22
28
  console.log('select', item);
23
29
  };
24
30
  const handleDelete = (item: TestItemType) => {
25
- const idx = items.findIndex((i) => i.id === item.id);
26
- items.splice(idx, 1);
31
+ const prev = registry.get(listAtom);
32
+ registry.set(listAtom, {
33
+ ...prev,
34
+ items: prev.items.filter((i) => i.id !== item.id),
35
+ });
27
36
  };
28
37
  const handleMove = (from: number, to: number) => {
29
- arrayMove(items, from, to);
38
+ const prev = registry.get(listAtom);
39
+ const newItems = [...prev.items];
40
+ arrayMove(newItems, from, to);
41
+ registry.set(listAtom, { ...prev, items: newItems });
30
42
  };
31
43
 
32
44
  return (
@@ -57,7 +69,7 @@ const DefaultStory = ({ items = [], ...props }: ListRootProps<TestItemType>) =>
57
69
 
58
70
  <List.ItemDragPreview<TestItemType>>
59
71
  {({ item }) => (
60
- <List.ItemWrapper classNames={mx(grid, 'bg-modalSurface border border-separator')}>
72
+ <List.ItemWrapper classNames={mx(grid, 'bg-modal-surface border border-separator')}>
61
73
  <List.ItemDragHandle />
62
74
  <div className='flex items-center'>{item.name}</div>
63
75
  </List.ItemWrapper>
@@ -69,7 +81,11 @@ const DefaultStory = ({ items = [], ...props }: ListRootProps<TestItemType>) =>
69
81
  );
70
82
  };
71
83
 
72
- const SimpleStory = ({ items = [], ...props }: ListRootProps<TestItemType>) => {
84
+ const SimpleStory = (props: Omit<ListRootProps<TestItemType>, 'items'>) => {
85
+ const listAtom = useMemo(() => Atom.make<TestList>(createList(100)).pipe(Atom.keepAlive), []);
86
+ const list = useAtomValue(listAtom);
87
+ const items = list.items;
88
+
73
89
  return (
74
90
  <List.Root<TestItemType> dragPreview items={items} {...props}>
75
91
  {({ items }) => (
@@ -87,12 +103,10 @@ const SimpleStory = ({ items = [], ...props }: ListRootProps<TestItemType>) => {
87
103
  );
88
104
  };
89
105
 
90
- const list = live(createList(100));
91
-
92
106
  const meta = {
93
107
  title: 'ui/react-ui-list/List',
94
108
  component: List.Root,
95
- decorators: [withTheme],
109
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' }), withRegistry],
96
110
  parameters: {
97
111
  layout: 'fullscreen',
98
112
  },
@@ -103,7 +117,6 @@ export default meta;
103
117
  export const Default: StoryObj<typeof DefaultStory> = {
104
118
  render: DefaultStory,
105
119
  args: {
106
- items: list.items,
107
120
  isItem: Schema.is(TestItemSchema),
108
121
  },
109
122
  };
@@ -111,7 +124,6 @@ export const Default: StoryObj<typeof DefaultStory> = {
111
124
  export const Simple: StoryObj<typeof SimpleStory> = {
112
125
  render: SimpleStory,
113
126
  args: {
114
- items: list.items,
115
127
  isItem: Schema.is(TestItemSchema),
116
128
  },
117
129
  };
@@ -31,7 +31,7 @@ import {
31
31
  type ThemedClassName,
32
32
  useTranslation,
33
33
  } from '@dxos/react-ui';
34
- import { mx } from '@dxos/react-ui-theme';
34
+ import { mx, osTranslations } from '@dxos/ui-theme';
35
35
 
36
36
  import { useListContext } from './ListRoot';
37
37
 
@@ -46,17 +46,17 @@ export type ItemDragState =
46
46
  container: HTMLElement;
47
47
  }
48
48
  | {
49
- type: 'is-dragging';
49
+ type: 'w-dragging';
50
50
  }
51
51
  | {
52
- type: 'is-dragging-over';
52
+ type: 'w-dragging-over';
53
53
  closestEdge: Edge | null;
54
54
  };
55
55
 
56
56
  export const idle: ItemDragState = { type: 'idle' };
57
57
 
58
58
  const stateStyles: { [Key in ItemDragState['type']]?: HTMLAttributes<HTMLDivElement>['className'] } = {
59
- 'is-dragging': 'opacity-50',
59
+ 'w-dragging': 'opacity-50',
60
60
  };
61
61
 
62
62
  type ListItemContext<T extends ListItemRecord> = {
@@ -124,8 +124,8 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
124
124
  }
125
125
  : undefined,
126
126
  onDragStart: () => {
127
- setState({ type: 'is-dragging' });
128
- setRootState({ type: 'is-dragging', item });
127
+ setState({ type: 'w-dragging' });
128
+ setRootState({ type: 'w-dragging', item });
129
129
  },
130
130
  onDrop: () => {
131
131
  setState(idle);
@@ -147,7 +147,7 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
147
147
  getIsSticky: () => true,
148
148
  onDragEnter: ({ self }) => {
149
149
  const closestEdge = extractClosestEdge(self.data);
150
- setState({ type: 'is-dragging-over', closestEdge });
150
+ setState({ type: 'w-dragging-over', closestEdge });
151
151
  },
152
152
  onDragLeave: () => {
153
153
  setState(idle);
@@ -155,10 +155,10 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
155
155
  onDrag: ({ self }) => {
156
156
  const closestEdge = extractClosestEdge(self.data);
157
157
  setState((current) => {
158
- if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
158
+ if (current.type === 'w-dragging-over' && current.closestEdge === closestEdge) {
159
159
  return current;
160
160
  }
161
- return { type: 'is-dragging-over', closestEdge };
161
+ return { type: 'w-dragging-over', closestEdge };
162
162
  });
163
163
  },
164
164
  onDrop: () => {
@@ -172,7 +172,7 @@ export const ListItem = <T extends ListItemRecord>({ children, classNames, item,
172
172
  <ListItemProvider item={item} dragHandleRef={dragHandleRef}>
173
173
  <div ref={ref} role='listitem' className={mx('flex relative', classNames, stateStyles[state.type])} {...props}>
174
174
  {children}
175
- {state.type === 'is-dragging-over' && state.closestEdge && (
175
+ {state.type === 'w-dragging-over' && state.closestEdge && (
176
176
  <NaturalListItem.DropIndicator edge={state.closestEdge} />
177
177
  )}
178
178
  </div>
@@ -195,7 +195,7 @@ export const ListItemDeleteButton = ({
195
195
  Omit<IconButtonProps, 'icon' | 'label'> & { autoHide?: boolean; label?: string }) => {
196
196
  const { state } = useListContext('DELETE_BUTTON');
197
197
  const isDisabled = state.type !== 'idle' || disabled;
198
- const { t } = useTranslation('os');
198
+ const { t } = useTranslation(osTranslations);
199
199
  return (
200
200
  <IconButton
201
201
  iconOnly
@@ -232,7 +232,7 @@ export const ListItemButton = ({
232
232
 
233
233
  export const ListItemDragHandle = ({ disabled }: Pick<IconButtonProps, 'disabled'>) => {
234
234
  const { dragHandleRef } = useListItemContext('DRAG_HANDLE');
235
- const { t } = useTranslation('os');
235
+ const { t } = useTranslation(osTranslations);
236
236
  return (
237
237
  <IconButton
238
238
  iconOnly
@@ -255,7 +255,7 @@ export const ListItemDragPreview = <T extends ListItemRecord>({
255
255
  };
256
256
 
257
257
  export const ListItemWrapper = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => (
258
- <div className={mx('flex is-full gap-2', classNames)}>{children}</div>
258
+ <div className={mx('flex w-full gap-2', classNames)}>{children}</div>
259
259
  );
260
260
 
261
261
  export const ListItemTitle = ({
@@ -26,14 +26,14 @@ export const [ListProvider, useListContext] = createContext<ListContext<any>>(LI
26
26
 
27
27
  export type ListRendererProps<T extends ListItemRecord> = {
28
28
  state: ListContext<T>['state'];
29
- items: T[];
29
+ items: readonly T[];
30
30
  };
31
31
 
32
32
  const defaultGetId = <T extends ListItemRecord>(item: T) => (item as any)?.id;
33
33
 
34
34
  export type ListRootProps<T extends ListItemRecord> = {
35
35
  children?: (props: ListRendererProps<T>) => ReactNode;
36
- items?: T[];
36
+ items?: readonly T[];
37
37
  onMove?: (fromIndex: number, toIndex: number) => void;
38
38
  } & Pick<ListContext<T>, 'isItem' | 'getId' | 'readonly' | 'dragPreview'>;
39
39
 
@@ -4,11 +4,11 @@
4
4
 
5
5
  import * as Schema from 'effect/Schema';
6
6
 
7
- import { ObjectId } from '@dxos/echo/internal';
7
+ import { Obj } from '@dxos/echo';
8
8
  import { faker } from '@dxos/random';
9
9
 
10
10
  export const TestItemSchema = Schema.Struct({
11
- id: ObjectId,
11
+ id: Obj.ID,
12
12
  name: Schema.String,
13
13
  });
14
14
 
@@ -4,28 +4,156 @@
4
4
 
5
5
  import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
6
6
  import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
7
+ import { Atom, RegistryContext } from '@effect-atom/atom-react';
7
8
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
- import React, { useEffect } from 'react';
9
+ import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
9
10
 
10
- import { type Live, live } from '@dxos/live-object';
11
11
  import { faker } from '@dxos/random';
12
12
  import { Icon } from '@dxos/react-ui';
13
13
  import { withTheme } from '@dxos/react-ui/testing';
14
+ import { withRegistry } from '@dxos/storybook-utils';
14
15
 
15
16
  import { Path } from '../../util';
16
17
 
17
18
  import { type TestItem, createTree, updateState } from './testing';
18
- import { Tree, type TreeProps } from './Tree';
19
+ import { Tree } from './Tree';
20
+ import { type TreeModel } from './TreeContext';
19
21
  import { type TreeData } from './TreeItem';
20
22
 
21
23
  faker.seed(1234);
22
24
 
23
- const DefaultStory = (props: TreeProps) => {
25
+ const tree = createTree() as TestItem;
26
+
27
+ const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
28
+ const registry = useContext(RegistryContext);
29
+ const stateAtomsRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
30
+
31
+ const getOrCreateStateAtom = useCallback((pathKey: string) => {
32
+ let atom = stateAtomsRef.current.get(pathKey);
33
+ if (!atom) {
34
+ atom = Atom.make({ open: false, current: false }).pipe(Atom.keepAlive);
35
+ stateAtomsRef.current.set(pathKey, atom);
36
+ }
37
+ return atom;
38
+ }, []);
39
+
40
+ // Build a lookup map of all items by ID.
41
+ const itemMap = useMemo(() => {
42
+ const map = new Map<string, TestItem>();
43
+ const walk = (item: TestItem) => {
44
+ map.set(item.id, item);
45
+ item.items?.forEach(walk);
46
+ };
47
+ walk(tree);
48
+ return map;
49
+ }, []);
50
+
51
+ // Build a child IDs map keyed by parent ID.
52
+ const childIdsMap = useMemo(() => {
53
+ const map = new Map<string, string[]>();
54
+ const walk = (item: TestItem) => {
55
+ if (item.items) {
56
+ map.set(
57
+ item.id,
58
+ item.items.map((child) => child.id),
59
+ );
60
+ item.items.forEach(walk);
61
+ }
62
+ };
63
+ // Root children.
64
+ map.set(
65
+ tree.id,
66
+ (tree.items ?? []).map((child) => child.id),
67
+ );
68
+ walk(tree);
69
+ return map;
70
+ }, []);
71
+
72
+ const childIdsFamily = useMemo(
73
+ () => Atom.family((id: string) => Atom.make(() => childIdsMap.get(id) ?? []).pipe(Atom.keepAlive)),
74
+ [childIdsMap],
75
+ );
76
+
77
+ const itemFamily = useMemo(
78
+ () => Atom.family((id: string) => Atom.make(() => itemMap.get(id)).pipe(Atom.keepAlive)),
79
+ [itemMap],
80
+ );
81
+
82
+ const itemPropsFamily = useMemo(
83
+ () =>
84
+ Atom.family((pathKey: string) => {
85
+ const id = pathKey.split('~').pop()!;
86
+ return Atom.make(() => {
87
+ const parent = itemMap.get(id);
88
+ if (!parent) {
89
+ return { id, label: id };
90
+ }
91
+ return {
92
+ id: parent.id,
93
+ label: parent.name,
94
+ icon: parent.icon,
95
+ ...((parent.items?.length ?? 0) > 0 && {
96
+ parentOf: parent.items!.map(({ id }) => id),
97
+ }),
98
+ };
99
+ }).pipe(Atom.keepAlive);
100
+ }),
101
+ [itemMap],
102
+ );
103
+
104
+ const itemOpenFamily = useMemo(
105
+ () =>
106
+ Atom.family((pathKey: string) => {
107
+ const stateAtom = getOrCreateStateAtom(pathKey);
108
+ return Atom.make((get) => get(stateAtom).open).pipe(Atom.keepAlive);
109
+ }),
110
+ [getOrCreateStateAtom],
111
+ );
112
+
113
+ const itemCurrentFamily = useMemo(
114
+ () =>
115
+ Atom.family((pathKey: string) => {
116
+ const stateAtom = getOrCreateStateAtom(pathKey);
117
+ return Atom.make((get) => get(stateAtom).current).pipe(Atom.keepAlive);
118
+ }),
119
+ [getOrCreateStateAtom],
120
+ );
121
+
122
+ const model: TreeModel<TestItem> = useMemo(
123
+ () => ({
124
+ childIds: (parentId?: string) => childIdsFamily(parentId ?? tree.id),
125
+ item: (id: string) => itemFamily(id),
126
+ itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
127
+ itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
128
+ itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
129
+ }),
130
+ [childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
131
+ );
132
+
133
+ const handleOpenChange = useCallback(
134
+ ({ path: pathProp, open }: { path: string[]; open: boolean }) => {
135
+ const path = Path.create(...pathProp);
136
+ const atom = getOrCreateStateAtom(path);
137
+ const prev = registry.get(atom);
138
+ registry.set(atom, { ...prev, open });
139
+ },
140
+ [getOrCreateStateAtom, registry],
141
+ );
142
+
143
+ const handleSelect = useCallback(
144
+ ({ path: pathProp, current }: { path: string[]; current: boolean }) => {
145
+ const path = Path.create(...pathProp);
146
+ const atom = getOrCreateStateAtom(path);
147
+ const prev = registry.get(atom);
148
+ registry.set(atom, { ...prev, current });
149
+ },
150
+ [getOrCreateStateAtom, registry],
151
+ );
152
+
24
153
  useEffect(() => {
25
154
  return monitorForElements({
26
155
  canMonitor: ({ source }) => typeof source.data.id === 'string' && Array.isArray(source.data.path),
27
156
  onDrop: ({ location, source }) => {
28
- // Didn't drop on anything.
29
157
  if (!location.current.dropTargets.length) {
30
158
  return;
31
159
  }
@@ -44,72 +172,34 @@ const DefaultStory = (props: TreeProps) => {
44
172
  });
45
173
  }, []);
46
174
 
47
- return <Tree {...props} />;
175
+ return (
176
+ <Tree
177
+ model={model}
178
+ id={tree.id}
179
+ rootId={tree.id}
180
+ draggable={draggable}
181
+ renderColumns={() => (
182
+ <div className='flex items-center'>
183
+ <Icon icon='ph--placeholder--regular' size={5} />
184
+ </div>
185
+ )}
186
+ onOpenChange={handleOpenChange}
187
+ onSelect={handleSelect}
188
+ />
189
+ );
48
190
  };
49
191
 
50
- const tree = live<TestItem>(createTree());
51
- const state = new Map<string, Live<{ open: boolean; current: boolean }>>();
52
-
53
192
  const meta = {
54
193
  title: 'ui/react-ui-list/Tree',
55
194
 
56
- decorators: [withTheme],
195
+ decorators: [withTheme(), withRegistry],
57
196
  component: Tree,
58
197
  render: DefaultStory,
59
- args: {
60
- id: tree.id,
61
- useItems: (parent?: TestItem) => {
62
- return parent?.items ?? tree.items;
63
- },
64
- getProps: (parent: TestItem) => ({
65
- id: parent.id,
66
- label: parent.name,
67
- icon: parent.icon,
68
- ...((parent.items?.length ?? 0) > 0 && {
69
- parentOf: parent.items!.map(({ id }) => id),
70
- }),
71
- }),
72
- isOpen: (_path: string[]) => {
73
- const path = Path.create(..._path);
74
- const object = state.get(path) ?? live({ open: false, current: false });
75
- if (!state.has(path)) {
76
- state.set(path, object);
77
- }
78
-
79
- return object.open;
80
- },
81
- isCurrent: (_path: string[]) => {
82
- const path = Path.create(..._path);
83
- const object = state.get(path) ?? live({ open: false, current: false });
84
- if (!state.has(path)) {
85
- state.set(path, object);
86
- }
87
-
88
- return object.current;
89
- },
90
- renderColumns: () => {
91
- return (
92
- <div className='flex items-center'>
93
- <Icon icon='ph--placeholder--regular' size={5} />
94
- </div>
95
- );
96
- },
97
- onOpenChange: ({ path: _path, open }) => {
98
- const path = Path.create(..._path);
99
- const object = state.get(path);
100
- object!.open = open;
101
- },
102
- onSelect: ({ path: _path, current }) => {
103
- const path = Path.create(..._path);
104
- const object = state.get(path);
105
- object!.current = current;
106
- },
107
- },
108
198
  } satisfies Meta<typeof Tree<TestItem>>;
109
199
 
110
200
  export default meta;
111
201
 
112
- type Story = StoryObj<typeof meta>;
202
+ type Story = StoryObj<typeof DefaultStory>;
113
203
 
114
204
  export const Default: Story = {};
115
205