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