@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.
- package/LICENSE +102 -5
- package/dist/lib/browser/index.mjs +883 -216
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +883 -216
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
- package/dist/types/src/components/Accordion/Accordion.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/Accordion.stories.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/AccordionRoot.d.ts +1 -1
- package/dist/types/src/components/Accordion/AccordionRoot.d.ts.map +1 -1
- package/dist/types/src/components/Combobox/Combobox.d.ts +105 -0
- package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -0
- package/dist/types/src/components/Combobox/Combobox.stories.d.ts +12 -0
- package/dist/types/src/components/Combobox/Combobox.stories.d.ts.map +1 -0
- package/dist/types/src/components/Combobox/index.d.ts +2 -0
- package/dist/types/src/components/Combobox/index.d.ts.map +1 -0
- package/dist/types/src/components/List/List.d.ts +18 -7
- package/dist/types/src/components/List/List.d.ts.map +1 -1
- package/dist/types/src/components/List/List.stories.d.ts +2 -2
- package/dist/types/src/components/List/List.stories.d.ts.map +1 -1
- package/dist/types/src/components/List/ListItem.d.ts +12 -10
- package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
- package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
- package/dist/types/src/components/List/testing.d.ts.map +1 -1
- package/dist/types/src/components/Listbox/Listbox.d.ts +27 -0
- package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts +12 -0
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -0
- package/dist/types/src/components/Listbox/index.d.ts +2 -0
- package/dist/types/src/components/Listbox/index.d.ts.map +1 -0
- package/dist/types/src/components/Picker/Picker.d.ts +49 -0
- package/dist/types/src/components/Picker/Picker.d.ts.map +1 -0
- package/dist/types/src/components/Picker/Picker.stories.d.ts +28 -0
- package/dist/types/src/components/Picker/Picker.stories.d.ts.map +1 -0
- package/dist/types/src/components/Picker/context.d.ts +29 -0
- package/dist/types/src/components/Picker/context.d.ts.map +1 -0
- package/dist/types/src/components/Picker/index.d.ts +3 -0
- package/dist/types/src/components/Picker/index.d.ts.map +1 -0
- package/dist/types/src/components/RowList/RowList.d.ts +61 -0
- package/dist/types/src/components/RowList/RowList.d.ts.map +1 -0
- package/dist/types/src/components/RowList/RowList.stories.d.ts +35 -0
- package/dist/types/src/components/RowList/RowList.stories.d.ts.map +1 -0
- package/dist/types/src/components/RowList/index.d.ts +3 -0
- package/dist/types/src/components/RowList/index.d.ts.map +1 -0
- package/dist/types/src/components/Tree/Tree.d.ts +1 -1
- package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/Tree.stories.d.ts +1 -1
- package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeContext.d.ts +4 -0
- package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
- package/dist/types/src/components/Tree/helpers.d.ts.map +1 -1
- package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +4 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/util/path.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +22 -21
- package/src/components/Accordion/Accordion.stories.tsx +3 -3
- package/src/components/Accordion/AccordionItem.tsx +1 -4
- package/src/components/Combobox/Combobox.stories.tsx +60 -0
- package/src/components/Combobox/Combobox.tsx +387 -0
- package/src/components/Combobox/index.ts +5 -0
- package/src/components/List/List.stories.tsx +7 -7
- package/src/components/List/List.tsx +14 -10
- package/src/components/List/ListItem.tsx +62 -44
- package/src/components/List/ListRoot.tsx +1 -1
- package/src/components/List/testing.ts +4 -4
- package/src/components/Listbox/Listbox.stories.tsx +48 -0
- package/src/components/Listbox/Listbox.tsx +201 -0
- package/src/components/Listbox/index.ts +5 -0
- package/src/components/Picker/Picker.stories.tsx +131 -0
- package/src/components/Picker/Picker.tsx +368 -0
- package/src/components/Picker/context.ts +43 -0
- package/src/components/Picker/index.ts +6 -0
- package/src/components/RowList/RowList.stories.tsx +163 -0
- package/src/components/RowList/RowList.tsx +350 -0
- package/src/components/RowList/index.ts +6 -0
- package/src/components/Tree/Tree.stories.tsx +4 -5
- package/src/components/Tree/Tree.tsx +1 -1
- package/src/components/Tree/TreeContext.tsx +4 -0
- package/src/components/Tree/TreeItem.tsx +96 -70
- package/src/components/Tree/TreeItemHeading.tsx +1 -2
- package/src/components/Tree/TreeItemToggle.tsx +3 -3
- package/src/components/Tree/testing.ts +5 -5
- 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
|
+
};
|
|
@@ -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 {
|
|
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
|
-
|
|
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'
|
|
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 {
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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(
|
|
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-
|
|
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
|