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

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 (90) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +883 -216
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/node-esm/index.mjs +883 -216
  6. package/dist/lib/node-esm/index.mjs.map +4 -4
  7. package/dist/lib/node-esm/meta.json +1 -1
  8. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  9. package/dist/types/src/components/Accordion/Accordion.d.ts.map +1 -1
  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 +18 -7
  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 +12 -10
  25. package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
  26. package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
  27. package/dist/types/src/components/List/testing.d.ts.map +1 -1
  28. package/dist/types/src/components/Listbox/Listbox.d.ts +27 -0
  29. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -0
  30. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +12 -0
  31. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -0
  32. package/dist/types/src/components/Listbox/index.d.ts +2 -0
  33. package/dist/types/src/components/Listbox/index.d.ts.map +1 -0
  34. package/dist/types/src/components/Picker/Picker.d.ts +49 -0
  35. package/dist/types/src/components/Picker/Picker.d.ts.map +1 -0
  36. package/dist/types/src/components/Picker/Picker.stories.d.ts +28 -0
  37. package/dist/types/src/components/Picker/Picker.stories.d.ts.map +1 -0
  38. package/dist/types/src/components/Picker/context.d.ts +29 -0
  39. package/dist/types/src/components/Picker/context.d.ts.map +1 -0
  40. package/dist/types/src/components/Picker/index.d.ts +3 -0
  41. package/dist/types/src/components/Picker/index.d.ts.map +1 -0
  42. package/dist/types/src/components/RowList/RowList.d.ts +61 -0
  43. package/dist/types/src/components/RowList/RowList.d.ts.map +1 -0
  44. package/dist/types/src/components/RowList/RowList.stories.d.ts +35 -0
  45. package/dist/types/src/components/RowList/RowList.stories.d.ts.map +1 -0
  46. package/dist/types/src/components/RowList/index.d.ts +3 -0
  47. package/dist/types/src/components/RowList/index.d.ts.map +1 -0
  48. package/dist/types/src/components/Tree/Tree.d.ts +1 -1
  49. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  50. package/dist/types/src/components/Tree/Tree.stories.d.ts +1 -1
  51. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  52. package/dist/types/src/components/Tree/TreeContext.d.ts +4 -0
  53. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  54. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  55. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  56. package/dist/types/src/components/Tree/helpers.d.ts.map +1 -1
  57. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  58. package/dist/types/src/components/index.d.ts +4 -0
  59. package/dist/types/src/components/index.d.ts.map +1 -1
  60. package/dist/types/src/util/path.d.ts.map +1 -1
  61. package/dist/types/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +22 -21
  63. package/src/components/Accordion/Accordion.stories.tsx +3 -3
  64. package/src/components/Accordion/AccordionItem.tsx +1 -4
  65. package/src/components/Combobox/Combobox.stories.tsx +60 -0
  66. package/src/components/Combobox/Combobox.tsx +387 -0
  67. package/src/components/Combobox/index.ts +5 -0
  68. package/src/components/List/List.stories.tsx +7 -7
  69. package/src/components/List/List.tsx +14 -10
  70. package/src/components/List/ListItem.tsx +62 -44
  71. package/src/components/List/ListRoot.tsx +1 -1
  72. package/src/components/List/testing.ts +4 -4
  73. package/src/components/Listbox/Listbox.stories.tsx +48 -0
  74. package/src/components/Listbox/Listbox.tsx +201 -0
  75. package/src/components/Listbox/index.ts +5 -0
  76. package/src/components/Picker/Picker.stories.tsx +131 -0
  77. package/src/components/Picker/Picker.tsx +368 -0
  78. package/src/components/Picker/context.ts +43 -0
  79. package/src/components/Picker/index.ts +6 -0
  80. package/src/components/RowList/RowList.stories.tsx +163 -0
  81. package/src/components/RowList/RowList.tsx +350 -0
  82. package/src/components/RowList/index.ts +6 -0
  83. package/src/components/Tree/Tree.stories.tsx +4 -5
  84. package/src/components/Tree/Tree.tsx +1 -1
  85. package/src/components/Tree/TreeContext.tsx +4 -0
  86. package/src/components/Tree/TreeItem.tsx +96 -70
  87. package/src/components/Tree/TreeItemHeading.tsx +1 -2
  88. package/src/components/Tree/TreeItemToggle.tsx +3 -3
  89. package/src/components/Tree/testing.ts +5 -5
  90. package/src/components/index.ts +4 -0
@@ -0,0 +1,350 @@
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
+ return (
166
+ <ScrollArea.Root
167
+ {...composableProps<HTMLDivElement>(rest, { classNames: 'dx-container' })}
168
+ {...{ thin, padding, centered }}
169
+ orientation='vertical'
170
+ ref={forwardedRef}
171
+ >
172
+ <ScrollArea.Viewport>{children}</ScrollArea.Viewport>
173
+ </ScrollArea.Root>
174
+ );
175
+ });
176
+
177
+ Viewport.displayName = ROW_LIST_VIEWPORT_NAME;
178
+
179
+ //
180
+ // Content — the listbox `<ul>` (tabster arrow group + aria-label).
181
+ //
182
+
183
+ type ContentProps = {
184
+ /**
185
+ * Accessible label for the listbox. Strongly recommended; assistive
186
+ * tech announces this when focus enters the list.
187
+ */
188
+ 'aria-label'?: string;
189
+ };
190
+
191
+ // Find the first non-disabled `role='option'` descendant in DOM order.
192
+ // Used as the focus-on-entry target so we don't land on a disabled row.
193
+ const firstEnabledOption = (ul: HTMLElement | null): HTMLLIElement | null => {
194
+ if (!ul) {
195
+ return null;
196
+ }
197
+ return ul.querySelector<HTMLLIElement>('[role="option"]:not([aria-disabled="true"])');
198
+ };
199
+
200
+ const Content = composable<HTMLUListElement, ContentProps>((props, forwardedRef) => {
201
+ // Touch the context so Content fails loudly if used outside Root.
202
+ useRowListContext(ROW_LIST_CONTENT_NAME, undefined);
203
+
204
+ // Tabster arrow-key navigation. `useTabster` auto-initializes the
205
+ // runtime on first call, so no app/storybook-level setup is required.
206
+ // The data attributes returned here go onto the focusable container —
207
+ // the `<ul>` rendered by the primitive `<List>`.
208
+ const arrowGroup = useArrowNavigationGroup({ axis: 'vertical', memorizeCurrent: true });
209
+
210
+ const { children, ...rest } = props as PropsWithChildren<ContentProps & Record<string, unknown>>;
211
+
212
+ // When focus first enters the `<ul>` itself (e.g. user tabs in),
213
+ // redirect into the selected option (or the first enabled one) so
214
+ // arrow keys have an immediate starting point. Tabster doesn't do
215
+ // this — it manages traversal once focus is already on a child.
216
+ const handleFocus = useCallback((event: FocusEvent<HTMLUListElement>) => {
217
+ if (event.target !== event.currentTarget) {
218
+ return;
219
+ }
220
+ const ul = event.currentTarget;
221
+ const selected = ul.querySelector<HTMLLIElement>(
222
+ '[role="option"][aria-selected="true"]:not([aria-disabled="true"])',
223
+ );
224
+ const target = selected ?? firstEnabledOption(ul);
225
+ target?.focus();
226
+ }, []);
227
+
228
+ // Render via the primitive `<List>` so descendant `<ListItem>`s
229
+ // satisfy their Radix context-scope check. We don't pass `selectable`
230
+ // — we set `role='listbox'` and `aria-selected` ourselves in `Row`,
231
+ // so the primitive's listbox-mode plumbing isn't needed.
232
+ const composed = composableProps<HTMLUListElement>(rest, { classNames: 'flex flex-col' });
233
+ return (
234
+ <List
235
+ variant='unordered'
236
+ {...composed}
237
+ {...arrowGroup}
238
+ role='listbox'
239
+ onFocus={handleFocus}
240
+ ref={forwardedRef as unknown as ForwardedRef<HTMLOListElement>}
241
+ >
242
+ {children}
243
+ </List>
244
+ );
245
+ });
246
+
247
+ Content.displayName = ROW_LIST_CONTENT_NAME;
248
+
249
+ //
250
+ // Row — option item.
251
+ //
252
+
253
+ type RowProps = PropsWithChildren<{
254
+ /** Stable identifier; matched against the parent's `selectedId`. */
255
+ id: string;
256
+ /** Disable the row — focusable but doesn't update selection, dimmed. */
257
+ disabled?: boolean;
258
+ /** Optional click handler in addition to selection. */
259
+ onClick?: (event: MouseEvent<HTMLLIElement>) => void;
260
+ /** Optional focus handler in addition to selection-follows-focus. */
261
+ onFocus?: (event: FocusEvent<HTMLLIElement>) => void;
262
+ }>;
263
+
264
+ // `dx-selected` pairs with `aria-selected="true"` (set per-option below);
265
+ // see `ui-theme/src/css/components/selected.md`.
266
+ const ROW_BASE = 'dx-hover dx-selected px-3 py-2 cursor-pointer outline-none'; // border-b border-separator last:border-b-0';
267
+
268
+ const Row = composable<HTMLLIElement, RowProps>((props, forwardedRef) => {
269
+ const { id, disabled, onClick, onFocus, children, ...rest } = props as RowProps & Record<string, unknown>;
270
+ const { selectedId, setSelected } = useRowListContext(ROW_NAME, undefined);
271
+ const isSelected = selectedId === id;
272
+
273
+ const handleClick = useCallback(
274
+ (event: MouseEvent<HTMLLIElement>) => {
275
+ if (disabled) {
276
+ return;
277
+ }
278
+ setSelected(id);
279
+ onClick?.(event);
280
+ },
281
+ [disabled, id, setSelected, onClick],
282
+ );
283
+
284
+ // Selection-follows-focus: arrow nav (and any focus path) updates
285
+ // `selectedId` so the model stays in sync with what the user is
286
+ // looking at. Disabled rows are still focusable for screen-reader
287
+ // announcement but don't update the selection model.
288
+ const handleFocus = useCallback(
289
+ (event: FocusEvent<HTMLLIElement>) => {
290
+ if (!disabled && selectedId !== id) {
291
+ setSelected(id);
292
+ }
293
+ onFocus?.(event);
294
+ },
295
+ [disabled, selectedId, id, setSelected, onFocus],
296
+ );
297
+
298
+ const composed = composableProps<HTMLLIElement>(rest, {
299
+ classNames: [ROW_BASE, disabled && 'opacity-50 cursor-not-allowed'],
300
+ });
301
+
302
+ // Per WAI-ARIA APG listbox guidance, disabled options remain
303
+ // keyboard-navigable for SR announcement; the selection model is not
304
+ // updated for disabled rows (see `handleFocus` / `handleClick` above).
305
+ return (
306
+ <ListItem
307
+ {...composed}
308
+ role='option'
309
+ tabIndex={0}
310
+ aria-selected={isSelected}
311
+ aria-disabled={disabled || undefined}
312
+ onClick={handleClick}
313
+ onFocus={handleFocus}
314
+ ref={forwardedRef}
315
+ >
316
+ {children}
317
+ </ListItem>
318
+ );
319
+ });
320
+
321
+ Row.displayName = ROW_NAME;
322
+
323
+ /**
324
+ * Read selection state for a single id, from inside any descendant of
325
+ * `<RowList.Root>`. Returns `true` when the row is currently selected.
326
+ * Lets composing components (e.g. `Listbox.OptionIndicator`) react to
327
+ * selection without re-rendering on unrelated changes.
328
+ */
329
+ const useRowListSelection = (id: string): boolean => {
330
+ const { selectedId } = useRowListContext('useRowListSelection', undefined);
331
+ return selectedId === id;
332
+ };
333
+
334
+ //
335
+ // Public namespace.
336
+ //
337
+
338
+ const RowList = {
339
+ Root,
340
+ Viewport,
341
+ Content,
342
+ };
343
+
344
+ export { RowList, Row, createRowListScope, useRowListSelection };
345
+ export type {
346
+ RootProps as RowListRootProps,
347
+ ViewportProps as RowListViewportProps,
348
+ ContentProps as RowListContentProps,
349
+ RowProps,
350
+ };
@@ -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,25 +2,24 @@
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
7
  import { Atom, RegistryContext } from '@effect-atom/atom-react';
8
8
  import { type Meta, type StoryObj } from '@storybook/react-vite';
9
9
  import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
10
10
 
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
14
  import { withRegistry } from '@dxos/storybook-utils';
15
15
 
16
16
  import { Path } from '../../util';
17
-
18
17
  import { type TestItem, createTree, updateState } from './testing';
19
18
  import { Tree } from './Tree';
20
19
  import { type TreeModel } from './TreeContext';
21
20
  import { type TreeData } from './TreeItem';
22
21
 
23
- faker.seed(1234);
22
+ random.seed(1234);
24
23
 
25
24
  const tree = createTree() as TestItem;
26
25
 
@@ -180,7 +179,7 @@ const DefaultStory = ({ draggable }: { draggable?: boolean }) => {
180
179
  draggable={draggable}
181
180
  renderColumns={() => (
182
181
  <div className='flex items-center'>
183
- <Icon icon='ph--placeholder--regular' size={5} />
182
+ <Icon icon='ph--placeholder--regular' />
184
183
  </div>
185
184
  )}
186
185
  onOpenChange={handleOpenChange}
@@ -29,13 +29,13 @@ export type TreeProps<T extends { id: string } = any> = {
29
29
  >;
30
30
 
31
31
  export const Tree = <T extends { id: string } = any>({
32
+ classNames,
32
33
  model,
33
34
  rootId,
34
35
  path,
35
36
  id,
36
37
  draggable = false,
37
38
  gridTemplateColumns = '[tree-row-start] 1fr min-content [tree-row-end]',
38
- classNames,
39
39
  levelOffset,
40
40
  renderColumns,
41
41
  blockInstruction,
@@ -12,6 +12,10 @@ export type TreeItemDataProps = {
12
12
  id: string;
13
13
  label: Label;
14
14
  parentOf?: string[];
15
+ /** When `false`, the item cannot be dragged (overrides tree-level `draggable`). */
16
+ draggable?: boolean;
17
+ /** When `false`, the item does not participate as a drop target. */
18
+ droppable?: boolean;
15
19
  className?: string;
16
20
  headingClassName?: string;
17
21
  icon?: string;
@@ -2,14 +2,14 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
6
- import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
7
5
  import {
8
6
  type Instruction,
9
7
  type ItemMode,
10
8
  attachInstruction,
11
9
  extractInstruction,
12
10
  } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
11
+ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
12
+ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
13
13
  import { useAtomValue } from '@effect-atom/atom-react';
14
14
  import * as Schema from 'effect/Schema';
15
15
  import React, {
@@ -25,7 +25,7 @@ import React, {
25
25
  } from 'react';
26
26
 
27
27
  import { invariant } from '@dxos/invariant';
28
- import { TreeItem as NaturalTreeItem, Treegrid } from '@dxos/react-ui';
28
+ import { TreeItem as NaturalTreeItem, Treegrid, TREEGRID_PARENT_OF_SEPARATOR } from '@dxos/react-ui';
29
29
  import {
30
30
  ghostFocusWithin,
31
31
  ghostHover,
@@ -107,9 +107,19 @@ const RawTreeItem = <T extends { id: string } = any>({
107
107
  } = useTree();
108
108
  const path = useMemo(() => [...pathProp, item.id], [pathProp, item.id]);
109
109
 
110
- const { id, parentOf, label, className, headingClassName, icon, iconHue, disabled, testId } = useAtomValue(
111
- itemPropsAtom(path),
112
- );
110
+ const {
111
+ id,
112
+ parentOf,
113
+ draggable: itemDraggable,
114
+ droppable: itemDroppable,
115
+ label,
116
+ className,
117
+ headingClassName,
118
+ icon,
119
+ iconHue,
120
+ disabled,
121
+ testId,
122
+ } = useAtomValue(itemPropsAtom(path));
113
123
  const childIds = useAtomValue(childIdsAtom(item.id));
114
124
  const open = useAtomValue(itemOpenAtom(path));
115
125
  const current = useAtomValue(itemCurrentAtom(path));
@@ -119,6 +129,7 @@ const RawTreeItem = <T extends { id: string } = any>({
119
129
  const mode: ItemMode = last ? 'last-in-group' : open ? 'expanded' : 'standard';
120
130
  const canSelectItem = canSelect?.({ item, path }) ?? true;
121
131
  const data = { id, path, item } satisfies TreeData;
132
+ const shouldSeedNativeDragData = typeof document !== 'undefined' && document.body.hasAttribute('data-platform');
122
133
 
123
134
  const cancelExpand = useCallback(() => {
124
135
  if (cancelExpandRef.current) {
@@ -127,6 +138,10 @@ const RawTreeItem = <T extends { id: string } = any>({
127
138
  }
128
139
  }, []);
129
140
 
141
+ const isItemDraggable = draggableProp && itemDraggable !== false;
142
+ const isItemDroppable = itemDroppable !== false;
143
+ const nativeDragText = id;
144
+
130
145
  useEffect(() => {
131
146
  if (!draggableProp) {
132
147
  return;
@@ -134,11 +149,16 @@ const RawTreeItem = <T extends { id: string } = any>({
134
149
 
135
150
  invariant(buttonRef.current);
136
151
 
137
- // https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/about
138
- return combine(
152
+ const makeDraggable = () =>
139
153
  draggable({
140
- element: buttonRef.current,
154
+ element: buttonRef.current!,
141
155
  getInitialData: () => data,
156
+ getInitialDataForExternal: () => {
157
+ if (!shouldSeedNativeDragData) {
158
+ return {};
159
+ }
160
+ return { 'text/plain': nativeDragText };
161
+ },
142
162
  onDragStart: () => {
143
163
  setState('dragging');
144
164
  if (open) {
@@ -152,62 +172,72 @@ const RawTreeItem = <T extends { id: string } = any>({
152
172
  onOpenChange?.({ item, path, open: true });
153
173
  }
154
174
  },
155
- }),
156
- // https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/packages/hitbox/constellation/index/about.mdx
157
- dropTargetForElements({
158
- element: buttonRef.current,
159
- getData: ({ input, element }) => {
160
- return attachInstruction(data, {
161
- input,
162
- element,
163
- indentPerLevel: DEFAULT_INDENTATION,
164
- currentLevel: level,
165
- mode,
166
- block: isBranch ? [] : ['make-child'],
167
- });
168
- },
169
- canDrop: ({ source }) => {
170
- const _canDrop = canDrop ?? (() => true);
171
- return source.element !== buttonRef.current && _canDrop({ source: source.data as TreeData, target: data });
172
- },
173
- getIsSticky: () => true,
174
- onDrag: ({ self, source }) => {
175
- const desired = extractInstruction(self.data);
176
- const block =
177
- desired && blockInstruction?.({ instruction: desired, source: source.data as TreeData, target: data });
178
- const instruction: Instruction | null =
179
- block && desired.type !== 'instruction-blocked' ? { type: 'instruction-blocked', desired } : desired;
180
-
181
- if (source.data.id !== id) {
182
- if (instruction?.type === 'make-child' && isBranch && !open && !cancelExpandRef.current) {
183
- cancelExpandRef.current = setTimeout(() => {
184
- onOpenChange?.({ item, path, open: true });
185
- }, 500);
186
- }
187
-
188
- if (instruction?.type !== 'make-child') {
189
- cancelExpand();
190
- }
191
-
192
- setInstruction(instruction);
193
- } else if (instruction?.type === 'reparent') {
194
- // TODO(wittjosiah): This is not occurring in the current implementation.
195
- setInstruction(instruction);
196
- } else {
197
- setInstruction(null);
175
+ });
176
+
177
+ if (!isItemDroppable) {
178
+ return isItemDraggable ? makeDraggable() : undefined;
179
+ }
180
+
181
+ const dropTarget = dropTargetForElements({
182
+ element: buttonRef.current,
183
+ getData: ({ input, element }) => {
184
+ return attachInstruction(data, {
185
+ input,
186
+ element,
187
+ indentPerLevel: DEFAULT_INDENTATION,
188
+ currentLevel: level,
189
+ mode,
190
+ block: isBranch ? [] : ['make-child'],
191
+ });
192
+ },
193
+ canDrop: ({ source }) => {
194
+ const _canDrop = canDrop ?? (() => true);
195
+ return source.element !== buttonRef.current && _canDrop({ source: source.data as TreeData, target: data });
196
+ },
197
+ getIsSticky: () => true,
198
+ onDrag: ({ self, source }) => {
199
+ const desired = extractInstruction(self.data);
200
+ const block =
201
+ desired && blockInstruction?.({ instruction: desired, source: source.data as TreeData, target: data });
202
+ const instruction: Instruction | null =
203
+ block && desired.type !== 'instruction-blocked' ? { type: 'instruction-blocked', desired } : desired;
204
+
205
+ if (source.data.id !== id) {
206
+ if (instruction?.type === 'make-child' && isBranch && !open && !cancelExpandRef.current) {
207
+ cancelExpandRef.current = setTimeout(() => {
208
+ onOpenChange?.({ item, path, open: true });
209
+ }, 500);
198
210
  }
199
- },
200
- onDragLeave: () => {
201
- cancelExpand();
202
- setInstruction(null);
203
- },
204
- onDrop: () => {
205
- cancelExpand();
211
+
212
+ if (instruction?.type !== 'make-child') {
213
+ cancelExpand();
214
+ }
215
+
216
+ setInstruction(instruction);
217
+ } else if (instruction?.type === 'reparent') {
218
+ // TODO(wittjosiah): This is not occurring in the current implementation.
219
+ setInstruction(instruction);
220
+ } else {
206
221
  setInstruction(null);
207
- },
208
- }),
209
- );
210
- }, [draggableProp, item, id, mode, path, open, blockInstruction, canDrop]);
222
+ }
223
+ },
224
+ onDragLeave: () => {
225
+ cancelExpand();
226
+ setInstruction(null);
227
+ },
228
+ onDrop: () => {
229
+ cancelExpand();
230
+ setInstruction(null);
231
+ },
232
+ });
233
+
234
+ if (!isItemDraggable) {
235
+ return dropTarget;
236
+ }
237
+
238
+ // https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/about
239
+ return combine(makeDraggable(), dropTarget);
240
+ }, [draggableProp, isItemDraggable, isItemDroppable, item, id, mode, path, open, blockInstruction, canDrop]);
211
241
 
212
242
  // Cancel expand on unmount.
213
243
  useEffect(() => () => cancelExpand(), [cancelExpand]);
@@ -275,7 +305,7 @@ const RawTreeItem = <T extends { id: string } = any>({
275
305
  key={id}
276
306
  id={id}
277
307
  aria-labelledby={`${id}__label`}
278
- parentOf={parentOf?.join(Treegrid.PARENT_OF_SEPARATOR)}
308
+ parentOf={parentOf?.join(TREEGRID_PARENT_OF_SEPARATOR)}
279
309
  data-object-id={id}
280
310
  data-testid={testId}
281
311
  // NOTE(thure): This is intentionally an empty string to for descendents to select by in the CSS
@@ -283,7 +313,7 @@ const RawTreeItem = <T extends { id: string } = any>({
283
313
  // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current#description
284
314
  aria-current={current ? ('' as 'page') : undefined}
285
315
  classNames={mx(
286
- 'grid grid-cols-subgrid col-[tree-row] mt-0.5 is-current:bg-active-surface',
316
+ 'grid grid-cols-subgrid col-[tree-row] mt-0.5 is-current:bg-current-surface',
287
317
  hoverableControls,
288
318
  hoverableFocusedKeyboardControls,
289
319
  hoverableFocusedWithinControls,
@@ -296,11 +326,7 @@ const RawTreeItem = <T extends { id: string } = any>({
296
326
  onMouseEnter={handleItemHover}
297
327
  onContextMenu={handleContextMenu}
298
328
  >
299
- <div
300
- role='none'
301
- className='indent relative grid grid-cols-subgrid col-[tree-row]'
302
- style={paddingIndentation(level)}
303
- >
329
+ <div className='indent relative grid grid-cols-subgrid col-[tree-row]' style={paddingIndentation(level)}>
304
330
  <Treegrid.Cell classNames='flex items-center'>
305
331
  <TreeItemToggle isBranch={isBranch} open={open} onClick={handleOpenToggle} />
306
332
  <TreeItemHeading