@dxos/react-ui-list 0.8.4-main.a4bbb77 → 0.8.4-main.abd8ff62ef

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 +1349 -718
  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 +1349 -718
  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.d.ts +1 -1
  8. package/dist/types/src/components/Accordion/Accordion.d.ts.map +1 -1
  9. package/dist/types/src/components/Accordion/Accordion.stories.d.ts +0 -3
  10. package/dist/types/src/components/Accordion/Accordion.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  12. package/dist/types/src/components/Accordion/AccordionRoot.d.ts +1 -1
  13. package/dist/types/src/components/Accordion/AccordionRoot.d.ts.map +1 -1
  14. package/dist/types/src/components/Combobox/Combobox.d.ts +105 -0
  15. package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -0
  16. package/dist/types/src/components/Combobox/Combobox.stories.d.ts +12 -0
  17. package/dist/types/src/components/Combobox/Combobox.stories.d.ts.map +1 -0
  18. package/dist/types/src/components/Combobox/index.d.ts +2 -0
  19. package/dist/types/src/components/Combobox/index.d.ts.map +1 -0
  20. package/dist/types/src/components/List/List.d.ts +19 -8
  21. package/dist/types/src/components/List/List.d.ts.map +1 -1
  22. package/dist/types/src/components/List/List.stories.d.ts +2 -2
  23. package/dist/types/src/components/List/List.stories.d.ts.map +1 -1
  24. package/dist/types/src/components/List/ListItem.d.ts +10 -8
  25. package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
  26. package/dist/types/src/components/List/ListRoot.d.ts +2 -2
  27. package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
  28. package/dist/types/src/components/List/testing.d.ts +1 -1
  29. package/dist/types/src/components/List/testing.d.ts.map +1 -1
  30. package/dist/types/src/components/Listbox/Listbox.d.ts +27 -0
  31. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -0
  32. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +12 -0
  33. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Listbox/index.d.ts +2 -0
  35. package/dist/types/src/components/Listbox/index.d.ts.map +1 -0
  36. package/dist/types/src/components/Picker/Picker.d.ts +49 -0
  37. package/dist/types/src/components/Picker/Picker.d.ts.map +1 -0
  38. package/dist/types/src/components/Picker/Picker.stories.d.ts +28 -0
  39. package/dist/types/src/components/Picker/Picker.stories.d.ts.map +1 -0
  40. package/dist/types/src/components/Picker/context.d.ts +29 -0
  41. package/dist/types/src/components/Picker/context.d.ts.map +1 -0
  42. package/dist/types/src/components/Picker/index.d.ts +3 -0
  43. package/dist/types/src/components/Picker/index.d.ts.map +1 -0
  44. package/dist/types/src/components/RowList/RowList.d.ts +61 -0
  45. package/dist/types/src/components/RowList/RowList.d.ts.map +1 -0
  46. package/dist/types/src/components/RowList/RowList.stories.d.ts +35 -0
  47. package/dist/types/src/components/RowList/RowList.stories.d.ts.map +1 -0
  48. package/dist/types/src/components/RowList/index.d.ts +3 -0
  49. package/dist/types/src/components/RowList/index.d.ts.map +1 -0
  50. package/dist/types/src/components/Tree/Tree.d.ts +10 -6
  51. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  52. package/dist/types/src/components/Tree/Tree.stories.d.ts +9 -28
  53. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/Tree/TreeContext.d.ts +24 -10
  55. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  56. package/dist/types/src/components/Tree/TreeItem.d.ts +25 -4
  57. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  58. package/dist/types/src/components/Tree/TreeItemHeading.d.ts +4 -3
  59. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  60. package/dist/types/src/components/Tree/TreeItemToggle.d.ts +3 -3
  61. package/dist/types/src/components/Tree/TreeItemToggle.d.ts.map +1 -1
  62. package/dist/types/src/components/Tree/helpers.d.ts.map +1 -1
  63. package/dist/types/src/components/Tree/index.d.ts +2 -0
  64. package/dist/types/src/components/Tree/index.d.ts.map +1 -1
  65. package/dist/types/src/components/Tree/testing.d.ts +3 -3
  66. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  67. package/dist/types/src/components/index.d.ts +4 -0
  68. package/dist/types/src/components/index.d.ts.map +1 -1
  69. package/dist/types/src/util/path.d.ts.map +1 -1
  70. package/dist/types/tsconfig.tsbuildinfo +1 -1
  71. package/package.json +34 -31
  72. package/src/components/Accordion/Accordion.stories.tsx +5 -8
  73. package/src/components/Accordion/AccordionItem.tsx +3 -4
  74. package/src/components/Accordion/AccordionRoot.tsx +1 -1
  75. package/src/components/Combobox/Combobox.stories.tsx +60 -0
  76. package/src/components/Combobox/Combobox.tsx +387 -0
  77. package/src/components/Combobox/index.ts +5 -0
  78. package/src/components/List/List.stories.tsx +34 -22
  79. package/src/components/List/List.tsx +14 -10
  80. package/src/components/List/ListItem.tsx +60 -40
  81. package/src/components/List/ListRoot.tsx +3 -3
  82. package/src/components/List/testing.ts +7 -7
  83. package/src/components/Listbox/Listbox.stories.tsx +48 -0
  84. package/src/components/Listbox/Listbox.tsx +201 -0
  85. package/src/components/Listbox/index.ts +5 -0
  86. package/src/components/Picker/Picker.stories.tsx +131 -0
  87. package/src/components/Picker/Picker.tsx +439 -0
  88. package/src/components/Picker/context.ts +43 -0
  89. package/src/components/Picker/index.ts +6 -0
  90. package/src/components/RowList/RowList.stories.tsx +163 -0
  91. package/src/components/RowList/RowList.tsx +353 -0
  92. package/src/components/RowList/index.ts +6 -0
  93. package/src/components/Tree/Tree.stories.tsx +153 -64
  94. package/src/components/Tree/Tree.tsx +43 -40
  95. package/src/components/Tree/TreeContext.tsx +21 -9
  96. package/src/components/Tree/TreeItem.tsx +214 -127
  97. package/src/components/Tree/TreeItemHeading.tsx +10 -8
  98. package/src/components/Tree/TreeItemToggle.tsx +29 -18
  99. package/src/components/Tree/index.ts +2 -0
  100. package/src/components/Tree/testing.ts +10 -9
  101. package/src/components/index.ts +4 -0
@@ -0,0 +1,353 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // `RowList` — Radix-style compound listbox / single-select picker.
6
+ //
7
+ // Compound shape (matches Radix Select / Toolbar / Tabs):
8
+ //
9
+ // <RowList.Root selectedId={…} onSelectChange={…}>
10
+ // <RowList.Viewport thin padding>
11
+ // <RowList.Content aria-label='Tools'>
12
+ // <Row id='a'>…</Row>
13
+ // <Row id='b'>…</Row>
14
+ // </RowList.Content>
15
+ // </RowList.Viewport>
16
+ // </RowList.Root>
17
+ //
18
+ // - `Root` — headless context provider (no DOM). Owns the
19
+ // single-select `selectedId` model.
20
+ // - `Viewport` — `ScrollArea.Root` + `ScrollArea.Viewport`. Always
21
+ // scrolls. Forwards ScrollArea knobs (`thin`, `padding`,
22
+ // `centered`).
23
+ // - `Content` — the `<ul role="listbox">` holding the items. Carries
24
+ // the tabster arrow-nav group and the `aria-label`.
25
+ // - `Row` — `<li role="option">` with `aria-selected` on the
26
+ // selected row, paired with `dx-selected` styling. See
27
+ // `ui-theme/src/css/components/selected.md`.
28
+ //
29
+ // Single visual variant. Card-style rendering, denser/wider rows,
30
+ // dividers, etc. are styling concerns layered on via `classNames` —
31
+ // not separate components.
32
+ //
33
+ // Selection model:
34
+ //
35
+ // This layer ships single-select (`selectedId: string | undefined`).
36
+ // Selection follows focus, so arrow keys + click both update it. This
37
+ // matches the codebase's existing `useSelected(_, 'single')` convention
38
+ // from `@dxos/react-ui-attention`.
39
+ //
40
+ // Multi-select (`selectedIds: ReadonlySet<string>` + per-row checkbox
41
+ // affordance) is a future expansion point. When it lands it'll likely
42
+ // plumb through `react-ui-attention`'s `SelectionManager` for
43
+ // cross-context state sharing — but RowList itself can stay
44
+ // provider-agnostic, with consumers wiring it as they need.
45
+ //
46
+ // Composability:
47
+ //
48
+ // `Viewport`, `Content`, and `Row` are all `composable()` from
49
+ // `@dxos/ui-theme` — they merge `classNames` + parent-Slot
50
+ // `className` via `composableProps()` and accept any standard HTML
51
+ // attributes. None expose `asChild`: Viewport can't (two nested
52
+ // elements, no coherent slot target), and Content / Row would need
53
+ // to abandon the `@dxos/react-list` primitive's context to honor it
54
+ // — not worth the complexity for this layer. If a consumer needs a
55
+ // `<button>`-as-row or a `<div>`-as-listbox, drop down to
56
+ // `@dxos/react-list` directly.
57
+ //
58
+ // Keyboard:
59
+ //
60
+ // `useArrowNavigationGroup({ axis: 'vertical', memorizeCurrent: true })`
61
+ // from `@fluentui/react-tabster` is applied to `Content`. Tabster
62
+ // auto-initializes (`useTabster` lazy-creates the runtime) so no
63
+ // provider setup is required at the app/storybook level. ArrowUp /
64
+ // ArrowDown move focus among options.
65
+ //
66
+ // When focus first lands on the `<ul>` itself (e.g. user tabs in),
67
+ // `Content` redirects focus into the selected option (or the first
68
+ // one) so arrow keys have an immediate starting point.
69
+ //
70
+ // What this layer deliberately does NOT do:
71
+ //
72
+ // - Virtualization or drag-and-drop. Reach for `@dxos/react-ui-mosaic`.
73
+ // - Multi-select (see "Selection model" above; future expansion).
74
+
75
+ import { useArrowNavigationGroup } from '@fluentui/react-tabster';
76
+ import { createContextScope } from '@radix-ui/react-context';
77
+ import { useControllableState } from '@radix-ui/react-use-controllable-state';
78
+ import React, { type FocusEvent, type ForwardedRef, type MouseEvent, type PropsWithChildren, useCallback } from 'react';
79
+
80
+ import { List, ListItem } from '@dxos/react-list';
81
+ import { ScrollArea, type ScrollAreaRootProps } from '@dxos/react-ui';
82
+ import { composable, composableProps } from '@dxos/ui-theme';
83
+
84
+ const ROW_LIST_NAME = 'RowList';
85
+ const ROW_LIST_ROOT_NAME = 'RowList.Root';
86
+ const ROW_LIST_VIEWPORT_NAME = 'RowList.Viewport';
87
+ const ROW_LIST_CONTENT_NAME = 'RowList.Content';
88
+ const ROW_NAME = 'List.Row';
89
+
90
+ //
91
+ // Context — Radix-scoped so future composition (a tree of nested
92
+ // RowLists, or a parent like a Combobox embedding RowList) can read
93
+ // the right scope.
94
+ //
95
+
96
+ type RowListContextValue = {
97
+ /** The currently-selected option id. */
98
+ selectedId?: string;
99
+ /** Set the selected option (called from click, arrow nav, focus). */
100
+ setSelected: (id: string) => void;
101
+ };
102
+
103
+ const [createRowListContext, createRowListScope] = createContextScope(ROW_LIST_NAME, []);
104
+ const [RowListProvider, useRowListContext] = createRowListContext<RowListContextValue>(ROW_LIST_NAME);
105
+
106
+ //
107
+ // Root — headless context provider. Renders no DOM.
108
+ //
109
+
110
+ type RootProps = PropsWithChildren<{
111
+ /** Currently-selected option id (controlled). */
112
+ selectedId?: string;
113
+ /** Initial selected option for uncontrolled mode. */
114
+ defaultSelectedId?: string;
115
+ /**
116
+ * Called when the user picks a different option (click, arrow keys,
117
+ * focus). Receives the option's `id` prop.
118
+ */
119
+ onSelectChange?: (id: string) => void;
120
+ }>;
121
+
122
+ const Root = ({ selectedId, defaultSelectedId, onSelectChange, children }: RootProps) => {
123
+ // `useControllableState`'s `onChange` is typed `(state: string | undefined) => void`,
124
+ // but our public `onSelectChange` is `(id: string) => void` (an `id` is always
125
+ // a string when emitted). Wrap to satisfy the type without leaking
126
+ // `undefined` to callers.
127
+ const [resolved, setResolved] = useControllableState<string | undefined>({
128
+ prop: selectedId,
129
+ defaultProp: defaultSelectedId,
130
+ onChange: (next) => {
131
+ if (next !== undefined) {
132
+ onSelectChange?.(next);
133
+ }
134
+ },
135
+ });
136
+
137
+ const setSelected = useCallback((id: string) => setResolved(id), [setResolved]);
138
+
139
+ return (
140
+ <RowListProvider scope={undefined} selectedId={resolved} setSelected={setSelected}>
141
+ {children}
142
+ </RowListProvider>
143
+ );
144
+ };
145
+
146
+ Root.displayName = ROW_LIST_ROOT_NAME;
147
+
148
+ //
149
+ // Viewport — ScrollArea wrapper. Always scrolls; forwards ScrollArea knobs.
150
+ //
151
+
152
+ // Subset of ScrollArea.Root props that make sense on a list viewport.
153
+ // `orientation` is fixed to 'vertical' — for other knobs (autoHide,
154
+ // snap, …) build your own ScrollArea wrapper and skip Viewport.
155
+ //
156
+ // `Viewport` is `composable()` rather than `slottable()` because there
157
+ // is no coherent `asChild` semantic for a wrapper that always renders
158
+ // two nested elements (`ScrollArea.Root` containing `ScrollArea.Viewport`).
159
+ type ViewportProps = Pick<ScrollAreaRootProps, 'thin' | 'padding' | 'centered'>;
160
+
161
+ const Viewport = composable<HTMLDivElement, ViewportProps>((props, forwardedRef) => {
162
+ const { thin, padding, centered, children, ...rest } = props as PropsWithChildren<
163
+ ViewportProps & Record<string, unknown>
164
+ >;
165
+ const composed = composableProps<HTMLDivElement>(rest, { classNames: 'dx-container' });
166
+ return (
167
+ <ScrollArea.Root
168
+ orientation='vertical'
169
+ thin={thin}
170
+ padding={padding}
171
+ centered={centered}
172
+ {...composed}
173
+ ref={forwardedRef}
174
+ >
175
+ <ScrollArea.Viewport>{children}</ScrollArea.Viewport>
176
+ </ScrollArea.Root>
177
+ );
178
+ });
179
+
180
+ Viewport.displayName = ROW_LIST_VIEWPORT_NAME;
181
+
182
+ //
183
+ // Content — the listbox `<ul>` (tabster arrow group + aria-label).
184
+ //
185
+
186
+ type ContentProps = {
187
+ /**
188
+ * Accessible label for the listbox. Strongly recommended; assistive
189
+ * tech announces this when focus enters the list.
190
+ */
191
+ 'aria-label'?: string;
192
+ };
193
+
194
+ // Find the first non-disabled `role='option'` descendant in DOM order.
195
+ // Used as the focus-on-entry target so we don't land on a disabled row.
196
+ const firstEnabledOption = (ul: HTMLElement | null): HTMLLIElement | null => {
197
+ if (!ul) {
198
+ return null;
199
+ }
200
+ return ul.querySelector<HTMLLIElement>('[role="option"]:not([aria-disabled="true"])');
201
+ };
202
+
203
+ const Content = composable<HTMLUListElement, ContentProps>((props, forwardedRef) => {
204
+ // Touch the context so Content fails loudly if used outside Root.
205
+ useRowListContext(ROW_LIST_CONTENT_NAME, undefined);
206
+
207
+ // Tabster arrow-key navigation. `useTabster` auto-initializes the
208
+ // runtime on first call, so no app/storybook-level setup is required.
209
+ // The data attributes returned here go onto the focusable container —
210
+ // the `<ul>` rendered by the primitive `<List>`.
211
+ const arrowGroup = useArrowNavigationGroup({ axis: 'vertical', memorizeCurrent: true });
212
+
213
+ const { children, ...rest } = props as PropsWithChildren<ContentProps & Record<string, unknown>>;
214
+
215
+ // When focus first enters the `<ul>` itself (e.g. user tabs in),
216
+ // redirect into the selected option (or the first enabled one) so
217
+ // arrow keys have an immediate starting point. Tabster doesn't do
218
+ // this — it manages traversal once focus is already on a child.
219
+ const handleFocus = useCallback((event: FocusEvent<HTMLUListElement>) => {
220
+ if (event.target !== event.currentTarget) {
221
+ return;
222
+ }
223
+ const ul = event.currentTarget;
224
+ const selected = ul.querySelector<HTMLLIElement>(
225
+ '[role="option"][aria-selected="true"]:not([aria-disabled="true"])',
226
+ );
227
+ const target = selected ?? firstEnabledOption(ul);
228
+ target?.focus();
229
+ }, []);
230
+
231
+ // Render via the primitive `<List>` so descendant `<ListItem>`s
232
+ // satisfy their Radix context-scope check. We don't pass `selectable`
233
+ // — we set `role='listbox'` and `aria-selected` ourselves in `Row`,
234
+ // so the primitive's listbox-mode plumbing isn't needed.
235
+ const composed = composableProps<HTMLUListElement>(rest, { classNames: 'flex flex-col' });
236
+ return (
237
+ <List
238
+ variant='unordered'
239
+ {...composed}
240
+ {...arrowGroup}
241
+ role='listbox'
242
+ onFocus={handleFocus}
243
+ ref={forwardedRef as unknown as ForwardedRef<HTMLOListElement>}
244
+ >
245
+ {children}
246
+ </List>
247
+ );
248
+ });
249
+
250
+ Content.displayName = ROW_LIST_CONTENT_NAME;
251
+
252
+ //
253
+ // Row — option item.
254
+ //
255
+
256
+ type RowProps = PropsWithChildren<{
257
+ /** Stable identifier; matched against the parent's `selectedId`. */
258
+ id: string;
259
+ /** Disable the row — focusable but doesn't update selection, dimmed. */
260
+ disabled?: boolean;
261
+ /** Optional click handler in addition to selection. */
262
+ onClick?: (event: MouseEvent<HTMLLIElement>) => void;
263
+ /** Optional focus handler in addition to selection-follows-focus. */
264
+ onFocus?: (event: FocusEvent<HTMLLIElement>) => void;
265
+ }>;
266
+
267
+ // `dx-selected` pairs with `aria-selected="true"` (set per-option below);
268
+ // see `ui-theme/src/css/components/selected.md`.
269
+ const ROW_BASE = 'dx-hover dx-selected px-3 py-2 cursor-pointer outline-none border-b border-separator last:border-b-0';
270
+
271
+ const Row = composable<HTMLLIElement, RowProps>((props, forwardedRef) => {
272
+ const { id, disabled, onClick, onFocus, children, ...rest } = props as RowProps & Record<string, unknown>;
273
+ const { selectedId, setSelected } = useRowListContext(ROW_NAME, undefined);
274
+ const isSelected = selectedId === id;
275
+
276
+ const handleClick = useCallback(
277
+ (event: MouseEvent<HTMLLIElement>) => {
278
+ if (disabled) {
279
+ return;
280
+ }
281
+ setSelected(id);
282
+ onClick?.(event);
283
+ },
284
+ [disabled, id, setSelected, onClick],
285
+ );
286
+
287
+ // Selection-follows-focus: arrow nav (and any focus path) updates
288
+ // `selectedId` so the model stays in sync with what the user is
289
+ // looking at. Disabled rows are still focusable for screen-reader
290
+ // announcement but don't update the selection model.
291
+ const handleFocus = useCallback(
292
+ (event: FocusEvent<HTMLLIElement>) => {
293
+ if (!disabled && selectedId !== id) {
294
+ setSelected(id);
295
+ }
296
+ onFocus?.(event);
297
+ },
298
+ [disabled, selectedId, id, setSelected, onFocus],
299
+ );
300
+
301
+ const composed = composableProps<HTMLLIElement>(rest, {
302
+ classNames: [ROW_BASE, disabled && 'opacity-50 cursor-not-allowed'],
303
+ });
304
+
305
+ // Per WAI-ARIA APG listbox guidance, disabled options remain
306
+ // keyboard-navigable for SR announcement; the selection model is not
307
+ // updated for disabled rows (see `handleFocus` / `handleClick` above).
308
+ return (
309
+ <ListItem
310
+ {...composed}
311
+ role='option'
312
+ tabIndex={0}
313
+ aria-selected={isSelected}
314
+ aria-disabled={disabled || undefined}
315
+ onClick={handleClick}
316
+ onFocus={handleFocus}
317
+ ref={forwardedRef}
318
+ >
319
+ {children}
320
+ </ListItem>
321
+ );
322
+ });
323
+
324
+ Row.displayName = ROW_NAME;
325
+
326
+ /**
327
+ * Read selection state for a single id, from inside any descendant of
328
+ * `<RowList.Root>`. Returns `true` when the row is currently selected.
329
+ * Lets composing components (e.g. `Listbox.OptionIndicator`) react to
330
+ * selection without re-rendering on unrelated changes.
331
+ */
332
+ const useRowListSelection = (id: string): boolean => {
333
+ const { selectedId } = useRowListContext('useRowListSelection', undefined);
334
+ return selectedId === id;
335
+ };
336
+
337
+ //
338
+ // Public namespace.
339
+ //
340
+
341
+ const RowList = {
342
+ Root,
343
+ Viewport,
344
+ Content,
345
+ };
346
+
347
+ export { RowList, Row, createRowListScope, useRowListSelection };
348
+ export type {
349
+ RootProps as RowListRootProps,
350
+ ViewportProps as RowListViewportProps,
351
+ ContentProps as RowListContentProps,
352
+ RowProps,
353
+ };
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { RowList, Row, createRowListScope, useRowListSelection } from './RowList';
6
+ export type { RowListRootProps, RowListViewportProps, RowListContentProps, RowProps } from './RowList';
@@ -2,30 +2,157 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
6
5
  import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
6
+ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
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
- import { faker } from '@dxos/random';
11
+ import { random } 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
  import { type TestItem, createTree, updateState } from './testing';
18
- import { Tree, type TreeProps } from './Tree';
18
+ import { Tree } from './Tree';
19
+ import { type TreeModel } from './TreeContext';
19
20
  import { type TreeData } from './TreeItem';
20
21
 
21
- faker.seed(1234);
22
+ random.seed(1234);
23
+
24
+ const tree = createTree() as TestItem;
25
+
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((pathKey: string) => {
31
+ let atom = stateAtomsRef.current.get(pathKey);
32
+ if (!atom) {
33
+ atom = Atom.make({ open: false, current: false }).pipe(Atom.keepAlive);
34
+ stateAtomsRef.current.set(pathKey, atom);
35
+ }
36
+ return atom;
37
+ }, []);
38
+
39
+ // Build a lookup map of all items by ID.
40
+ const itemMap = useMemo(() => {
41
+ const map = new Map<string, TestItem>();
42
+ const walk = (item: TestItem) => {
43
+ map.set(item.id, item);
44
+ item.items?.forEach(walk);
45
+ };
46
+ walk(tree);
47
+ return map;
48
+ }, []);
49
+
50
+ // Build a child IDs map keyed by parent ID.
51
+ const childIdsMap = useMemo(() => {
52
+ const map = new Map<string, string[]>();
53
+ const walk = (item: TestItem) => {
54
+ if (item.items) {
55
+ map.set(
56
+ item.id,
57
+ item.items.map((child) => child.id),
58
+ );
59
+ item.items.forEach(walk);
60
+ }
61
+ };
62
+ // Root children.
63
+ map.set(
64
+ tree.id,
65
+ (tree.items ?? []).map((child) => child.id),
66
+ );
67
+ walk(tree);
68
+ return map;
69
+ }, []);
70
+
71
+ const childIdsFamily = useMemo(
72
+ () => Atom.family((id: string) => Atom.make(() => childIdsMap.get(id) ?? []).pipe(Atom.keepAlive)),
73
+ [childIdsMap],
74
+ );
75
+
76
+ const itemFamily = useMemo(
77
+ () => Atom.family((id: string) => Atom.make(() => itemMap.get(id)).pipe(Atom.keepAlive)),
78
+ [itemMap],
79
+ );
80
+
81
+ const itemPropsFamily = useMemo(
82
+ () =>
83
+ Atom.family((pathKey: string) => {
84
+ const id = pathKey.split('~').pop()!;
85
+ return Atom.make(() => {
86
+ const parent = itemMap.get(id);
87
+ if (!parent) {
88
+ return { id, label: id };
89
+ }
90
+ return {
91
+ id: parent.id,
92
+ label: parent.name,
93
+ icon: parent.icon,
94
+ ...((parent.items?.length ?? 0) > 0 && {
95
+ parentOf: parent.items!.map(({ id }) => id),
96
+ }),
97
+ };
98
+ }).pipe(Atom.keepAlive);
99
+ }),
100
+ [itemMap],
101
+ );
102
+
103
+ const itemOpenFamily = useMemo(
104
+ () =>
105
+ Atom.family((pathKey: string) => {
106
+ const stateAtom = getOrCreateStateAtom(pathKey);
107
+ return Atom.make((get) => get(stateAtom).open).pipe(Atom.keepAlive);
108
+ }),
109
+ [getOrCreateStateAtom],
110
+ );
111
+
112
+ const itemCurrentFamily = useMemo(
113
+ () =>
114
+ Atom.family((pathKey: string) => {
115
+ const stateAtom = getOrCreateStateAtom(pathKey);
116
+ return Atom.make((get) => get(stateAtom).current).pipe(Atom.keepAlive);
117
+ }),
118
+ [getOrCreateStateAtom],
119
+ );
120
+
121
+ const model: TreeModel<TestItem> = useMemo(
122
+ () => ({
123
+ childIds: (parentId?: string) => childIdsFamily(parentId ?? tree.id),
124
+ item: (id: string) => itemFamily(id),
125
+ itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
126
+ itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
127
+ itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
128
+ }),
129
+ [childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
130
+ );
131
+
132
+ const handleOpenChange = useCallback(
133
+ ({ path: pathProp, open }: { path: string[]; open: boolean }) => {
134
+ const path = Path.create(...pathProp);
135
+ const atom = getOrCreateStateAtom(path);
136
+ const prev = registry.get(atom);
137
+ registry.set(atom, { ...prev, open });
138
+ },
139
+ [getOrCreateStateAtom, registry],
140
+ );
141
+
142
+ const handleSelect = useCallback(
143
+ ({ path: pathProp, current }: { path: string[]; current: boolean }) => {
144
+ const path = Path.create(...pathProp);
145
+ const atom = getOrCreateStateAtom(path);
146
+ const prev = registry.get(atom);
147
+ registry.set(atom, { ...prev, current });
148
+ },
149
+ [getOrCreateStateAtom, registry],
150
+ );
22
151
 
23
- const DefaultStory = (props: TreeProps) => {
24
152
  useEffect(() => {
25
153
  return monitorForElements({
26
154
  canMonitor: ({ source }) => typeof source.data.id === 'string' && Array.isArray(source.data.path),
27
155
  onDrop: ({ location, source }) => {
28
- // Didn't drop on anything.
29
156
  if (!location.current.dropTargets.length) {
30
157
  return;
31
158
  }
@@ -44,72 +171,34 @@ const DefaultStory = (props: TreeProps) => {
44
171
  });
45
172
  }, []);
46
173
 
47
- return <Tree {...props} />;
174
+ return (
175
+ <Tree
176
+ model={model}
177
+ id={tree.id}
178
+ rootId={tree.id}
179
+ draggable={draggable}
180
+ renderColumns={() => (
181
+ <div className='flex items-center'>
182
+ <Icon icon='ph--placeholder--regular' />
183
+ </div>
184
+ )}
185
+ onOpenChange={handleOpenChange}
186
+ onSelect={handleSelect}
187
+ />
188
+ );
48
189
  };
49
190
 
50
- const tree = live<TestItem>(createTree());
51
- const state = new Map<string, Live<{ open: boolean; current: boolean }>>();
52
-
53
191
  const meta = {
54
192
  title: 'ui/react-ui-list/Tree',
55
193
 
56
- decorators: [withTheme],
194
+ decorators: [withTheme(), withRegistry],
57
195
  component: Tree,
58
196
  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
197
  } satisfies Meta<typeof Tree<TestItem>>;
109
198
 
110
199
  export default meta;
111
200
 
112
- type Story = StoryObj<typeof meta>;
201
+ type Story = StoryObj<typeof DefaultStory>;
113
202
 
114
203
  export const Default: Story = {};
115
204