@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,439 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// `Picker` — generic listbox-with-input compound (the WAI-ARIA combobox
|
|
6
|
+
// keyboard pattern, sans the search-domain bits).
|
|
7
|
+
//
|
|
8
|
+
// Owns:
|
|
9
|
+
// - The virtual-highlight selection model (`selectedValue` updated by
|
|
10
|
+
// arrow keys; items don't receive browser focus, the input retains it).
|
|
11
|
+
// - An item registry (`registerItem` / `unregisterItem`) used by the
|
|
12
|
+
// input's keyboard handler to walk DOM-order siblings.
|
|
13
|
+
// - Auto-select-first-when-items-change behaviour.
|
|
14
|
+
// - `triggerSelect` so Enter from the input fires the highlighted
|
|
15
|
+
// item's `onSelect`.
|
|
16
|
+
//
|
|
17
|
+
// Does NOT own:
|
|
18
|
+
// - Query / search state (`query`, `onSearch`, `debounceMs`) — that
|
|
19
|
+
// lives in `@dxos/react-ui-search`'s `SearchList`, which composes
|
|
20
|
+
// `Picker` + adds the search-themed wrapper.
|
|
21
|
+
// - Filtering / ranking — same, see `useSearchListResults`.
|
|
22
|
+
// - The `<ul role='listbox'>` wrapper. Caller provides one (today's
|
|
23
|
+
// `SearchList.Viewport` / future `Combobox.Content` puts the role
|
|
24
|
+
// on the scroll surface).
|
|
25
|
+
// - Translations.
|
|
26
|
+
//
|
|
27
|
+
// Compound shape (matches Radix Select / Combobox patterns):
|
|
28
|
+
//
|
|
29
|
+
// <Picker.Root>
|
|
30
|
+
// <Picker.Input value={query} onValueChange={setQuery} />
|
|
31
|
+
// <YourScrollWrapper role='listbox'>
|
|
32
|
+
// {items.map(item => (
|
|
33
|
+
// <Picker.Item key={item.id} value={item.id} onSelect={…}>
|
|
34
|
+
// {item.label}
|
|
35
|
+
// </Picker.Item>
|
|
36
|
+
// ))}
|
|
37
|
+
// </YourScrollWrapper>
|
|
38
|
+
// </Picker.Root>
|
|
39
|
+
//
|
|
40
|
+
// Why two contexts (Item / Input) — performance: items don't re-render
|
|
41
|
+
// when the input value changes; the input doesn't re-render when an
|
|
42
|
+
// item registers / unregisters.
|
|
43
|
+
|
|
44
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
45
|
+
import React, {
|
|
46
|
+
type ChangeEvent,
|
|
47
|
+
type ComponentPropsWithRef,
|
|
48
|
+
type KeyboardEvent,
|
|
49
|
+
type MouseEvent as ReactMouseEvent,
|
|
50
|
+
type PropsWithChildren,
|
|
51
|
+
type ReactNode,
|
|
52
|
+
forwardRef,
|
|
53
|
+
useCallback,
|
|
54
|
+
useEffect,
|
|
55
|
+
useMemo,
|
|
56
|
+
useRef,
|
|
57
|
+
useState,
|
|
58
|
+
} from 'react';
|
|
59
|
+
|
|
60
|
+
import { type Density, type Elevation, Input, type ThemedClassName, useThemeContext } from '@dxos/react-ui';
|
|
61
|
+
import { mx } from '@dxos/ui-theme';
|
|
62
|
+
|
|
63
|
+
import {
|
|
64
|
+
PickerInputContextProvider,
|
|
65
|
+
PickerItemContextProvider,
|
|
66
|
+
usePickerInputContext,
|
|
67
|
+
usePickerItemContext,
|
|
68
|
+
} from './context';
|
|
69
|
+
|
|
70
|
+
//
|
|
71
|
+
// Internal types.
|
|
72
|
+
//
|
|
73
|
+
|
|
74
|
+
type ItemData = {
|
|
75
|
+
element: HTMLElement;
|
|
76
|
+
disabled?: boolean;
|
|
77
|
+
onSelect?: () => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
//
|
|
81
|
+
// Root — context provider; renders no DOM.
|
|
82
|
+
//
|
|
83
|
+
|
|
84
|
+
type PickerRootProps = PropsWithChildren<{}>;
|
|
85
|
+
|
|
86
|
+
const PickerRoot = ({ children }: PickerRootProps) => {
|
|
87
|
+
const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined);
|
|
88
|
+
|
|
89
|
+
// Item registry: value → { element, onSelect, disabled }.
|
|
90
|
+
const itemsRef = useRef<Map<string, ItemData>>(new Map());
|
|
91
|
+
|
|
92
|
+
// Bumped on every (un)register so the auto-select-first effect can fire.
|
|
93
|
+
const [itemVersion, setItemVersion] = useState(0);
|
|
94
|
+
|
|
95
|
+
// Auto-select first non-disabled item when the registry changes and the
|
|
96
|
+
// current selection is no longer valid (gone or disabled).
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const current = selectedValue !== undefined ? itemsRef.current.get(selectedValue) : undefined;
|
|
99
|
+
const isValid = current !== undefined && !current.disabled;
|
|
100
|
+
if (!isValid && itemsRef.current.size > 0) {
|
|
101
|
+
const entries = Array.from(itemsRef.current.entries()).filter(([, data]) => !data.disabled);
|
|
102
|
+
if (entries.length > 0) {
|
|
103
|
+
entries.sort(([, a], [, b]) => {
|
|
104
|
+
const position = a.element.compareDocumentPosition(b.element);
|
|
105
|
+
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
});
|
|
113
|
+
const firstValue = entries[0]?.[0];
|
|
114
|
+
if (firstValue !== undefined && firstValue !== selectedValue) {
|
|
115
|
+
setSelectedValue(firstValue);
|
|
116
|
+
}
|
|
117
|
+
} else if (selectedValue !== undefined) {
|
|
118
|
+
setSelectedValue(undefined);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}, [itemVersion, selectedValue]);
|
|
122
|
+
|
|
123
|
+
const registerItem = useCallback(
|
|
124
|
+
(value: string, element: HTMLElement | null, onSelect: (() => void) | undefined, disabled?: boolean) => {
|
|
125
|
+
if (element) {
|
|
126
|
+
itemsRef.current.set(value, { element, onSelect, disabled });
|
|
127
|
+
setItemVersion((v) => v + 1);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
[],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const unregisterItem = useCallback((value: string) => {
|
|
134
|
+
itemsRef.current.delete(value);
|
|
135
|
+
setItemVersion((v) => v + 1);
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
// DOM-order traversal of registered items (excludes disabled).
|
|
139
|
+
const getItemValues = useCallback(() => {
|
|
140
|
+
return Array.from(itemsRef.current.entries())
|
|
141
|
+
.filter(([, data]) => !data.disabled)
|
|
142
|
+
.sort(([, a], [, b]) => {
|
|
143
|
+
const position = a.element.compareDocumentPosition(b.element);
|
|
144
|
+
return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : position & Node.DOCUMENT_POSITION_PRECEDING ? 1 : 0;
|
|
145
|
+
})
|
|
146
|
+
.map(([value]) => value);
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
const triggerSelect = useCallback(() => {
|
|
150
|
+
if (selectedValue !== undefined) {
|
|
151
|
+
const item = itemsRef.current.get(selectedValue);
|
|
152
|
+
item?.onSelect?.();
|
|
153
|
+
}
|
|
154
|
+
}, [selectedValue]);
|
|
155
|
+
|
|
156
|
+
// Stable: items subscribe to this. Excludes the volatile bits (input
|
|
157
|
+
// helpers) that change with every keystroke.
|
|
158
|
+
const itemContextValue = useMemo(
|
|
159
|
+
() => ({
|
|
160
|
+
selectedValue,
|
|
161
|
+
onSelectedValueChange: setSelectedValue,
|
|
162
|
+
registerItem,
|
|
163
|
+
unregisterItem,
|
|
164
|
+
}),
|
|
165
|
+
[selectedValue, registerItem, unregisterItem],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Volatile: input subscribes to this. Includes the keyboard helpers.
|
|
169
|
+
const inputContextValue = useMemo(
|
|
170
|
+
() => ({
|
|
171
|
+
selectedValue,
|
|
172
|
+
onSelectedValueChange: setSelectedValue,
|
|
173
|
+
getItemValues,
|
|
174
|
+
triggerSelect,
|
|
175
|
+
}),
|
|
176
|
+
[selectedValue, getItemValues, triggerSelect],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<PickerInputContextProvider {...inputContextValue}>
|
|
181
|
+
<PickerItemContextProvider {...itemContextValue}>{children}</PickerItemContextProvider>
|
|
182
|
+
</PickerInputContextProvider>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
PickerRoot.displayName = 'Picker.Root';
|
|
187
|
+
|
|
188
|
+
//
|
|
189
|
+
// Input — text input with virtual-highlight keyboard handling.
|
|
190
|
+
//
|
|
191
|
+
|
|
192
|
+
type InputVariant = 'default' | 'subdued';
|
|
193
|
+
|
|
194
|
+
type PickerInputProps = ThemedClassName<
|
|
195
|
+
Omit<ComponentPropsWithRef<'input'>, 'value'> & {
|
|
196
|
+
/** Controlled input value. Caller owns this — e.g. binds to query state. */
|
|
197
|
+
value?: string;
|
|
198
|
+
/** Called on every keystroke with the new input string. */
|
|
199
|
+
onValueChange?: (value: string) => void;
|
|
200
|
+
density?: Density;
|
|
201
|
+
elevation?: Elevation;
|
|
202
|
+
variant?: InputVariant;
|
|
203
|
+
}
|
|
204
|
+
>;
|
|
205
|
+
|
|
206
|
+
const PickerInput = forwardRef<HTMLInputElement, PickerInputProps>(
|
|
207
|
+
({ value, onValueChange, onChange, onKeyDown, autoFocus, ...props }, forwardedRef) => {
|
|
208
|
+
const { hasIosKeyboard } = useThemeContext();
|
|
209
|
+
const { selectedValue, onSelectedValueChange, getItemValues, triggerSelect } =
|
|
210
|
+
usePickerInputContext('Picker.Input');
|
|
211
|
+
|
|
212
|
+
const handleChange = useCallback(
|
|
213
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
214
|
+
onValueChange?.(event.target.value);
|
|
215
|
+
onChange?.(event);
|
|
216
|
+
},
|
|
217
|
+
[onValueChange, onChange],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const handleKeyDown = useCallback(
|
|
221
|
+
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
222
|
+
onKeyDown?.(event);
|
|
223
|
+
if (event.defaultPrevented) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const values = getItemValues();
|
|
227
|
+
if (values.length === 0) {
|
|
228
|
+
if (event.key === 'Escape') {
|
|
229
|
+
onValueChange?.('');
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const currentIndex = selectedValue !== undefined ? values.indexOf(selectedValue) : -1;
|
|
235
|
+
|
|
236
|
+
switch (event.key) {
|
|
237
|
+
case 'ArrowDown': {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
const nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, values.length - 1);
|
|
240
|
+
const nextValue = values[nextIndex];
|
|
241
|
+
if (nextValue !== undefined) {
|
|
242
|
+
onSelectedValueChange(nextValue);
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'ArrowUp': {
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
const prevIndex = currentIndex === -1 ? values.length - 1 : Math.max(currentIndex - 1, 0);
|
|
249
|
+
const prevValue = values[prevIndex];
|
|
250
|
+
if (prevValue !== undefined) {
|
|
251
|
+
onSelectedValueChange(prevValue);
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case 'Enter': {
|
|
256
|
+
if (selectedValue !== undefined) {
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
triggerSelect();
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case 'Home': {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
const firstValue = values[0];
|
|
265
|
+
if (firstValue !== undefined) {
|
|
266
|
+
onSelectedValueChange(firstValue);
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case 'End': {
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
const lastValue = values[values.length - 1];
|
|
273
|
+
if (lastValue !== undefined) {
|
|
274
|
+
onSelectedValueChange(lastValue);
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
case 'Escape': {
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
if (selectedValue !== undefined) {
|
|
281
|
+
onSelectedValueChange(undefined);
|
|
282
|
+
} else {
|
|
283
|
+
onValueChange?.('');
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
[selectedValue, onSelectedValueChange, getItemValues, triggerSelect, onValueChange, onKeyDown],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Spread `value` only when defined: a caller that wants a
|
|
293
|
+
// controlled input passes `value` + `onValueChange`; a caller that
|
|
294
|
+
// just wants the keyboard pattern (Default story) passes neither
|
|
295
|
+
// and gets an uncontrolled input that accepts keystrokes normally.
|
|
296
|
+
// Without this guard `value={value ?? ''}` would force-control the
|
|
297
|
+
// input, swallowing every keystroke when no `onValueChange` is
|
|
298
|
+
// wired (input visually accepts characters then re-renders empty).
|
|
299
|
+
return (
|
|
300
|
+
<Input.Root>
|
|
301
|
+
<Input.TextInput
|
|
302
|
+
{...props}
|
|
303
|
+
autoFocus={autoFocus && !hasIosKeyboard}
|
|
304
|
+
{...(value !== undefined && { value })}
|
|
305
|
+
onChange={handleChange}
|
|
306
|
+
onKeyDown={handleKeyDown}
|
|
307
|
+
ref={forwardedRef}
|
|
308
|
+
/>
|
|
309
|
+
</Input.Root>
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
PickerInput.displayName = 'Picker.Input';
|
|
315
|
+
|
|
316
|
+
//
|
|
317
|
+
// Item — option that registers in the parent's registry.
|
|
318
|
+
//
|
|
319
|
+
|
|
320
|
+
type PickerItemProps = ThemedClassName<{
|
|
321
|
+
/** Unique identifier; used by the registry and DOM-order traversal. */
|
|
322
|
+
value: string;
|
|
323
|
+
/** Callback when the item is committed (click, or Enter while highlighted). */
|
|
324
|
+
onSelect?: () => void;
|
|
325
|
+
/** Disable the item — registry-visible but not focusable, not navigable, not clickable. */
|
|
326
|
+
disabled?: boolean;
|
|
327
|
+
asChild?: boolean;
|
|
328
|
+
children?: ReactNode;
|
|
329
|
+
}>;
|
|
330
|
+
|
|
331
|
+
const PickerItem = forwardRef<HTMLDivElement, PickerItemProps>(
|
|
332
|
+
({ classNames, value, onSelect, disabled, asChild, children, ...props }, forwardedRef) => {
|
|
333
|
+
const { selectedValue, onSelectedValueChange, registerItem, unregisterItem } = usePickerItemContext('Picker.Item');
|
|
334
|
+
const internalRef = useRef<HTMLDivElement>(null);
|
|
335
|
+
|
|
336
|
+
const isSelected = selectedValue === value && !disabled;
|
|
337
|
+
|
|
338
|
+
// Register on mount, unregister on unmount.
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
const element = internalRef.current;
|
|
341
|
+
if (element) {
|
|
342
|
+
registerItem(value, element, onSelect, disabled);
|
|
343
|
+
}
|
|
344
|
+
return () => unregisterItem(value);
|
|
345
|
+
}, [value, onSelect, disabled, registerItem, unregisterItem]);
|
|
346
|
+
|
|
347
|
+
// Smooth-scroll the selected option into view.
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
if (isSelected && internalRef.current) {
|
|
350
|
+
internalRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
351
|
+
}
|
|
352
|
+
}, [isSelected]);
|
|
353
|
+
|
|
354
|
+
const handleClick = useCallback(() => {
|
|
355
|
+
if (disabled) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// Move the virtual highlight to the clicked item so subsequent
|
|
359
|
+
// arrow keys continue from here, then fire the caller's select.
|
|
360
|
+
onSelectedValueChange(value);
|
|
361
|
+
onSelect?.();
|
|
362
|
+
}, [disabled, value, onSelectedValueChange, onSelect]);
|
|
363
|
+
|
|
364
|
+
// Prevent the mousedown from moving focus off `Picker.Input` —
|
|
365
|
+
// browsers focus an element with any `tabIndex` (including `-1`) on
|
|
366
|
+
// click, which would steal focus from the input and break the
|
|
367
|
+
// input's arrow-key handler. Cancelling on `mousedown` keeps focus
|
|
368
|
+
// on the input while still letting the subsequent `click` fire.
|
|
369
|
+
const handleMouseDown = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
|
370
|
+
event.preventDefault();
|
|
371
|
+
}, []);
|
|
372
|
+
|
|
373
|
+
const Comp: any = asChild ? Slot : 'div';
|
|
374
|
+
|
|
375
|
+
// Default styling: pair `aria-selected` with `dx-selected` and add
|
|
376
|
+
// `dx-hover` for the standard hover affordance. Same grammar `Row`
|
|
377
|
+
// uses (see `ui-theme/src/css/components/selected.md`). Horizontal
|
|
378
|
+
// padding follows `--gutter` so item text aligns with sibling
|
|
379
|
+
// `Column.Center` content (input, status row); falls back to
|
|
380
|
+
// `0.75rem` (≈ `px-3`) when not nested under `Column.Root`. Vertical
|
|
381
|
+
// padding and the pointer cursor are baked in so callsites don't
|
|
382
|
+
// have to repeat them; callers can still append / override via
|
|
383
|
+
// `classNames`. `dx-selected` only fires when `aria-selected="true"`,
|
|
384
|
+
// which we set below from the virtual highlight — so unfocused
|
|
385
|
+
// items render plain.
|
|
386
|
+
return (
|
|
387
|
+
<Comp
|
|
388
|
+
{...props}
|
|
389
|
+
ref={(node: HTMLDivElement | null) => {
|
|
390
|
+
internalRef.current = node;
|
|
391
|
+
if (typeof forwardedRef === 'function') {
|
|
392
|
+
forwardedRef(node);
|
|
393
|
+
} else if (forwardedRef) {
|
|
394
|
+
forwardedRef.current = node;
|
|
395
|
+
}
|
|
396
|
+
}}
|
|
397
|
+
role='option'
|
|
398
|
+
aria-selected={isSelected}
|
|
399
|
+
aria-disabled={disabled}
|
|
400
|
+
data-selected={isSelected}
|
|
401
|
+
data-disabled={disabled}
|
|
402
|
+
data-value={value}
|
|
403
|
+
// tabIndex={-1} — combobox pattern keeps browser focus on the
|
|
404
|
+
// input; the selected option is highlighted via `aria-selected`,
|
|
405
|
+
// not via DOM focus. Differs from `Row` (listbox pattern,
|
|
406
|
+
// tabIndex={0}). See header comment.
|
|
407
|
+
tabIndex={-1}
|
|
408
|
+
className={mx(
|
|
409
|
+
'dx-hover dx-selected px-[var(--gutter,0.75rem)] py-1 cursor-pointer select-none',
|
|
410
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
411
|
+
classNames,
|
|
412
|
+
)}
|
|
413
|
+
onMouseDown={handleMouseDown}
|
|
414
|
+
onClick={handleClick}
|
|
415
|
+
>
|
|
416
|
+
{children}
|
|
417
|
+
</Comp>
|
|
418
|
+
);
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
PickerItem.displayName = 'Picker.Item';
|
|
423
|
+
|
|
424
|
+
//
|
|
425
|
+
// Public namespace.
|
|
426
|
+
//
|
|
427
|
+
|
|
428
|
+
export const Picker = {
|
|
429
|
+
Root: PickerRoot,
|
|
430
|
+
Input: PickerInput,
|
|
431
|
+
Item: PickerItem,
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
export type { PickerRootProps, PickerInputProps, PickerItemProps };
|
|
435
|
+
|
|
436
|
+
// Re-export context hooks for higher layers (SearchList) that need to
|
|
437
|
+
// compose: `useSearchListInputContext` etc. previously read these
|
|
438
|
+
// values; they now route through Picker.
|
|
439
|
+
export { usePickerInputContext, usePickerItemContext } from './context';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// Two contexts (Item / Input) instead of one — performance optimization
|
|
6
|
+
// from the original SearchList: items don't subscribe to query / input
|
|
7
|
+
// state, so typing in the input doesn't re-render every option.
|
|
8
|
+
|
|
9
|
+
import { createContext } from '@radix-ui/react-context';
|
|
10
|
+
|
|
11
|
+
/** Stable: items subscribe to selection, registry. Doesn't change on query. */
|
|
12
|
+
export type PickerItemContextValue = {
|
|
13
|
+
/** Currently highlighted item value (virtual; not browser focus). */
|
|
14
|
+
selectedValue: string | undefined;
|
|
15
|
+
/** Update the highlighted value (e.g. arrow keys, hover). */
|
|
16
|
+
onSelectedValueChange: (value: string | undefined) => void;
|
|
17
|
+
/** Register an item for keyboard nav + DOM-order traversal. */
|
|
18
|
+
registerItem: (
|
|
19
|
+
value: string,
|
|
20
|
+
element: HTMLElement | null,
|
|
21
|
+
onSelect: (() => void) | undefined,
|
|
22
|
+
disabled?: boolean,
|
|
23
|
+
) => void;
|
|
24
|
+
/** Unregister an item. */
|
|
25
|
+
unregisterItem: (value: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Volatile: input subscribes to selection + the input keyboard helpers. */
|
|
29
|
+
export type PickerInputContextValue = {
|
|
30
|
+
/** Currently highlighted item value. */
|
|
31
|
+
selectedValue: string | undefined;
|
|
32
|
+
/** Update the highlighted value. */
|
|
33
|
+
onSelectedValueChange: (value: string | undefined) => void;
|
|
34
|
+
/** Get registered item values in DOM order (excludes disabled). */
|
|
35
|
+
getItemValues: () => string[];
|
|
36
|
+
/** Trigger the highlighted item's `onSelect`. */
|
|
37
|
+
triggerSelect: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const [PickerItemContextProvider, usePickerItemContext] = createContext<PickerItemContextValue>('PickerItem');
|
|
41
|
+
|
|
42
|
+
export const [PickerInputContextProvider, usePickerInputContext] =
|
|
43
|
+
createContext<PickerInputContextValue>('PickerInput');
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React, { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { random } from '@dxos/random';
|
|
9
|
+
import { Input, Panel, Toolbar } from '@dxos/react-ui';
|
|
10
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
11
|
+
|
|
12
|
+
import { Row, RowList } from './RowList';
|
|
13
|
+
|
|
14
|
+
random.seed(1);
|
|
15
|
+
|
|
16
|
+
type TestItem = { id: string; name: string; description: string };
|
|
17
|
+
|
|
18
|
+
const allItems: TestItem[] = Array.from({ length: 24 }, (_, i) => ({
|
|
19
|
+
id: `item-${i}`,
|
|
20
|
+
name: random.commerce.productName(),
|
|
21
|
+
description: random.lorem.sentences(2),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
//
|
|
25
|
+
// Single configurable story for the basic-listbox variants
|
|
26
|
+
// (Default / Thin / WithDisabled). MasterDetail and WithToolbar
|
|
27
|
+
// diverge structurally and keep their own render functions per
|
|
28
|
+
// AUDIT.md §11.
|
|
29
|
+
//
|
|
30
|
+
|
|
31
|
+
type StoryArgs = {
|
|
32
|
+
/** Items to render. Defaults to the full 24-item catalog. */
|
|
33
|
+
items?: TestItem[];
|
|
34
|
+
/** Forwards to `RowList.Viewport thin`. */
|
|
35
|
+
thin?: boolean;
|
|
36
|
+
/** Forwards to `RowList.Viewport padding`. */
|
|
37
|
+
padding?: boolean;
|
|
38
|
+
/** Index into `items` that should render disabled. */
|
|
39
|
+
disabledIndex?: number;
|
|
40
|
+
/** Render the description line under each row's name. */
|
|
41
|
+
showDescription?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DefaultStory = ({
|
|
45
|
+
items = allItems,
|
|
46
|
+
thin = false,
|
|
47
|
+
padding = false,
|
|
48
|
+
disabledIndex,
|
|
49
|
+
showDescription = true,
|
|
50
|
+
}: StoryArgs = {}) => {
|
|
51
|
+
const [selected, setSelected] = useState<string | undefined>(items[0]?.id);
|
|
52
|
+
return (
|
|
53
|
+
<RowList.Root selectedId={selected} onSelectChange={setSelected}>
|
|
54
|
+
<RowList.Viewport thin={thin} padding={padding}>
|
|
55
|
+
<RowList.Content aria-label='Items'>
|
|
56
|
+
{items.map((item, i) => {
|
|
57
|
+
const disabled = i === disabledIndex;
|
|
58
|
+
return (
|
|
59
|
+
<Row key={item.id} id={item.id} disabled={disabled}>
|
|
60
|
+
<div className='font-medium'>
|
|
61
|
+
{item.name}
|
|
62
|
+
{disabled && ' (disabled)'}
|
|
63
|
+
</div>
|
|
64
|
+
{showDescription && <div className='text-sm text-description line-clamp-1'>{item.description}</div>}
|
|
65
|
+
</Row>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</RowList.Content>
|
|
69
|
+
</RowList.Viewport>
|
|
70
|
+
</RowList.Root>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
//
|
|
75
|
+
// Master/detail — list is one pane of a layout.
|
|
76
|
+
//
|
|
77
|
+
|
|
78
|
+
const MasterDetailStory = () => {
|
|
79
|
+
const [selected, setSelected] = useState<string | undefined>(allItems[0].id);
|
|
80
|
+
const detail = allItems.find(({ id }) => id === selected);
|
|
81
|
+
return (
|
|
82
|
+
<div role='none' className='dx-container grid grid-cols-[20rem_1fr] divide-x divide-separator'>
|
|
83
|
+
<RowList.Root selectedId={selected} onSelectChange={setSelected}>
|
|
84
|
+
<RowList.Viewport>
|
|
85
|
+
<RowList.Content aria-label='Items'>
|
|
86
|
+
{allItems.map((item) => (
|
|
87
|
+
<Row key={item.id} id={item.id}>
|
|
88
|
+
<div className='font-medium'>{item.name}</div>
|
|
89
|
+
</Row>
|
|
90
|
+
))}
|
|
91
|
+
</RowList.Content>
|
|
92
|
+
</RowList.Viewport>
|
|
93
|
+
</RowList.Root>
|
|
94
|
+
<div role='region' aria-label='Detail' className='dx-container p-4 overflow-auto'>
|
|
95
|
+
{detail && (
|
|
96
|
+
<>
|
|
97
|
+
<h2 className='text-lg font-semibold'>{detail.name}</h2>
|
|
98
|
+
<p className='text-description mt-2'>{detail.description}</p>
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
//
|
|
107
|
+
// Toolbar + viewport siblings — Root is headless, so layout is the
|
|
108
|
+
// caller's responsibility. `Panel` is the canonical chrome wrapper.
|
|
109
|
+
//
|
|
110
|
+
|
|
111
|
+
const WithToolbarStory = () => {
|
|
112
|
+
const [selected, setSelected] = useState<string | undefined>(allItems[0].id);
|
|
113
|
+
const [filter, setFilter] = useState('');
|
|
114
|
+
const filtered = allItems.filter((item) => item.name.toLowerCase().includes(filter.toLowerCase()));
|
|
115
|
+
return (
|
|
116
|
+
<RowList.Root selectedId={selected} onSelectChange={setSelected}>
|
|
117
|
+
<Panel.Root>
|
|
118
|
+
<Panel.Toolbar asChild>
|
|
119
|
+
<Toolbar.Root>
|
|
120
|
+
<Input.Root>
|
|
121
|
+
<Input.Label srOnly>Filter items</Input.Label>
|
|
122
|
+
<Input.TextInput
|
|
123
|
+
placeholder='Filter…'
|
|
124
|
+
value={filter}
|
|
125
|
+
onChange={(event) => setFilter(event.target.value)}
|
|
126
|
+
/>
|
|
127
|
+
</Input.Root>
|
|
128
|
+
</Toolbar.Root>
|
|
129
|
+
</Panel.Toolbar>
|
|
130
|
+
<Panel.Content asChild>
|
|
131
|
+
<RowList.Viewport>
|
|
132
|
+
<RowList.Content aria-label='Items'>
|
|
133
|
+
{filtered.map((item) => (
|
|
134
|
+
<Row key={item.id} id={item.id}>
|
|
135
|
+
{item.name}
|
|
136
|
+
</Row>
|
|
137
|
+
))}
|
|
138
|
+
</RowList.Content>
|
|
139
|
+
</RowList.Viewport>
|
|
140
|
+
</Panel.Content>
|
|
141
|
+
</Panel.Root>
|
|
142
|
+
</RowList.Root>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const meta = {
|
|
147
|
+
title: 'ui/react-ui-list/RowList',
|
|
148
|
+
render: (args) => <DefaultStory {...args} />,
|
|
149
|
+
decorators: [withTheme(), withLayout({ layout: 'column' })],
|
|
150
|
+
parameters: {
|
|
151
|
+
layout: 'fullscreen',
|
|
152
|
+
},
|
|
153
|
+
} satisfies Meta<StoryArgs>;
|
|
154
|
+
|
|
155
|
+
export default meta;
|
|
156
|
+
|
|
157
|
+
type Story = StoryObj<StoryArgs>;
|
|
158
|
+
|
|
159
|
+
export const Default: Story = {};
|
|
160
|
+
export const Thin: Story = { args: { thin: true, padding: true, showDescription: false } };
|
|
161
|
+
export const WithDisabled: Story = { args: { items: allItems.slice(0, 6), disabledIndex: 2 } };
|
|
162
|
+
export const MasterDetail: Story = { render: () => <MasterDetailStory /> };
|
|
163
|
+
export const WithToolbar: Story = { render: () => <WithToolbarStory /> };
|