@dxos/react-ui-list 0.8.4-main.8360d9e660 → 0.8.4-main.8baae0fced
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 +764 -110
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +764 -110
- 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 +15 -4
- 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 +3 -3
- 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/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 +21 -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 +5 -5
- package/src/components/List/List.tsx +12 -8
- package/src/components/List/ListItem.tsx +7 -9
- 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/TreeItem.tsx +14 -10
- 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,
|
|
@@ -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,
|
|
@@ -129,6 +129,7 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
129
129
|
const mode: ItemMode = last ? 'last-in-group' : open ? 'expanded' : 'standard';
|
|
130
130
|
const canSelectItem = canSelect?.({ item, path }) ?? true;
|
|
131
131
|
const data = { id, path, item } satisfies TreeData;
|
|
132
|
+
const shouldSeedNativeDragData = typeof document !== 'undefined' && document.body.hasAttribute('data-platform');
|
|
132
133
|
|
|
133
134
|
const cancelExpand = useCallback(() => {
|
|
134
135
|
if (cancelExpandRef.current) {
|
|
@@ -139,6 +140,7 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
139
140
|
|
|
140
141
|
const isItemDraggable = draggableProp && itemDraggable !== false;
|
|
141
142
|
const isItemDroppable = itemDroppable !== false;
|
|
143
|
+
const nativeDragText = id;
|
|
142
144
|
|
|
143
145
|
useEffect(() => {
|
|
144
146
|
if (!draggableProp) {
|
|
@@ -151,6 +153,12 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
151
153
|
draggable({
|
|
152
154
|
element: buttonRef.current!,
|
|
153
155
|
getInitialData: () => data,
|
|
156
|
+
getInitialDataForExternal: () => {
|
|
157
|
+
if (!shouldSeedNativeDragData) {
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
return { 'text/plain': nativeDragText };
|
|
161
|
+
},
|
|
154
162
|
onDragStart: () => {
|
|
155
163
|
setState('dragging');
|
|
156
164
|
if (open) {
|
|
@@ -297,7 +305,7 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
297
305
|
key={id}
|
|
298
306
|
id={id}
|
|
299
307
|
aria-labelledby={`${id}__label`}
|
|
300
|
-
parentOf={parentOf?.join(
|
|
308
|
+
parentOf={parentOf?.join(TREEGRID_PARENT_OF_SEPARATOR)}
|
|
301
309
|
data-object-id={id}
|
|
302
310
|
data-testid={testId}
|
|
303
311
|
// NOTE(thure): This is intentionally an empty string to for descendents to select by in the CSS
|
|
@@ -305,7 +313,7 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
305
313
|
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current#description
|
|
306
314
|
aria-current={current ? ('' as 'page') : undefined}
|
|
307
315
|
classNames={mx(
|
|
308
|
-
'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',
|
|
309
317
|
hoverableControls,
|
|
310
318
|
hoverableFocusedKeyboardControls,
|
|
311
319
|
hoverableFocusedWithinControls,
|
|
@@ -318,11 +326,7 @@ const RawTreeItem = <T extends { id: string } = any>({
|
|
|
318
326
|
onMouseEnter={handleItemHover}
|
|
319
327
|
onContextMenu={handleContextMenu}
|
|
320
328
|
>
|
|
321
|
-
<div
|
|
322
|
-
role='none'
|
|
323
|
-
className='indent relative grid grid-cols-subgrid col-[tree-row]'
|
|
324
|
-
style={paddingIndentation(level)}
|
|
325
|
-
>
|
|
329
|
+
<div className='indent relative grid grid-cols-subgrid col-[tree-row]' style={paddingIndentation(level)}>
|
|
326
330
|
<Treegrid.Cell classNames='flex items-center'>
|
|
327
331
|
<TreeItemToggle isBranch={isBranch} open={open} onClick={handleOpenToggle} />
|
|
328
332
|
<TreeItemHeading
|
|
@@ -56,7 +56,6 @@ export const TreeItemHeading = memo(
|
|
|
56
56
|
<Button
|
|
57
57
|
data-testid='treeItem.heading'
|
|
58
58
|
variant='ghost'
|
|
59
|
-
density='fine'
|
|
60
59
|
classNames={[
|
|
61
60
|
'grow gap-2 ps-0.5 hover:bg-transparent dark:hover:bg-transparent',
|
|
62
61
|
'disabled:cursor-default disabled:opacity-100',
|
|
@@ -68,7 +67,7 @@ export const TreeItemHeading = memo(
|
|
|
68
67
|
{...(current && { 'aria-current': 'location' })}
|
|
69
68
|
>
|
|
70
69
|
{icon && (
|
|
71
|
-
<Icon icon={icon ?? 'ph--placeholder--regular'}
|
|
70
|
+
<Icon size={5} icon={icon ?? 'ph--placeholder--regular'} classNames={['my-1', styles?.foreground]} />
|
|
72
71
|
)}
|
|
73
72
|
<span className='flex-1 w-0 truncate text-start font-normal' data-tooltip>
|
|
74
73
|
{toLocalizedString(label, t)}
|
|
@@ -14,7 +14,7 @@ export type TreeItemToggleProps = Omit<IconButtonProps, 'icon' | 'size' | 'label
|
|
|
14
14
|
|
|
15
15
|
export const TreeItemToggle = memo(
|
|
16
16
|
forwardRef<HTMLButtonElement, TreeItemToggleProps>(
|
|
17
|
-
({ open, isBranch, hidden,
|
|
17
|
+
({ classNames, open, isBranch, hidden, ...props }, forwardedRef) => {
|
|
18
18
|
return (
|
|
19
19
|
<IconButton
|
|
20
20
|
ref={forwardedRef}
|
|
@@ -24,8 +24,8 @@ export const TreeItemToggle = memo(
|
|
|
24
24
|
density='fine'
|
|
25
25
|
classNames={[
|
|
26
26
|
'h-full w-6 px-0',
|
|
27
|
-
'[&_svg]:transition-
|
|
28
|
-
open
|
|
27
|
+
'[&_svg]:transition-transform [&_svg]:duration-200',
|
|
28
|
+
open ? '[&_svg]:rotate-90' : '[&_svg]:rotate-0',
|
|
29
29
|
hidden ? 'hidden' : !isBranch && 'invisible',
|
|
30
30
|
classNames,
|
|
31
31
|
]}
|
|
@@ -7,7 +7,7 @@ import * as Schema from 'effect/Schema';
|
|
|
7
7
|
|
|
8
8
|
import { Obj } from '@dxos/echo';
|
|
9
9
|
import { log } from '@dxos/log';
|
|
10
|
-
import {
|
|
10
|
+
import { random } from '@dxos/random';
|
|
11
11
|
|
|
12
12
|
import { type TreeData } from './TreeItem';
|
|
13
13
|
|
|
@@ -26,18 +26,18 @@ export const TestItemSchema = Schema.Struct({
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
export const createTree = (n = 4, d = 4): TestItem => ({
|
|
29
|
-
id:
|
|
30
|
-
name:
|
|
29
|
+
id: random.string.uuid(),
|
|
30
|
+
name: random.commerce.productName(),
|
|
31
31
|
icon:
|
|
32
32
|
d === 3
|
|
33
33
|
? undefined
|
|
34
|
-
:
|
|
34
|
+
: random.helpers.arrayElement([
|
|
35
35
|
'ph--planet--regular',
|
|
36
36
|
'ph--sailboat--regular',
|
|
37
37
|
'ph--house--regular',
|
|
38
38
|
'ph--gear--regular',
|
|
39
39
|
]),
|
|
40
|
-
items: d > 0 ?
|
|
40
|
+
items: d > 0 ? random.helpers.multiple(() => createTree(n, d - 1), { count: n }) : [],
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
const removeItem = (tree: TestItem, source: TreeData) => {
|