@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.
Files changed (101) hide show
  1. package/dist/lib/browser/index.mjs +1349 -718
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +1349 -718
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  8. package/dist/types/src/components/Accordion/Accordion.d.ts.map +1 -1
  9. package/dist/types/src/components/Accordion/Accordion.stories.d.ts +0 -3
  10. package/dist/types/src/components/Accordion/Accordion.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  12. package/dist/types/src/components/Accordion/AccordionRoot.d.ts +1 -1
  13. package/dist/types/src/components/Accordion/AccordionRoot.d.ts.map +1 -1
  14. package/dist/types/src/components/Combobox/Combobox.d.ts +105 -0
  15. package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -0
  16. package/dist/types/src/components/Combobox/Combobox.stories.d.ts +12 -0
  17. package/dist/types/src/components/Combobox/Combobox.stories.d.ts.map +1 -0
  18. package/dist/types/src/components/Combobox/index.d.ts +2 -0
  19. package/dist/types/src/components/Combobox/index.d.ts.map +1 -0
  20. package/dist/types/src/components/List/List.d.ts +19 -8
  21. package/dist/types/src/components/List/List.d.ts.map +1 -1
  22. package/dist/types/src/components/List/List.stories.d.ts +2 -2
  23. package/dist/types/src/components/List/List.stories.d.ts.map +1 -1
  24. package/dist/types/src/components/List/ListItem.d.ts +10 -8
  25. package/dist/types/src/components/List/ListItem.d.ts.map +1 -1
  26. package/dist/types/src/components/List/ListRoot.d.ts +2 -2
  27. package/dist/types/src/components/List/ListRoot.d.ts.map +1 -1
  28. package/dist/types/src/components/List/testing.d.ts +1 -1
  29. package/dist/types/src/components/List/testing.d.ts.map +1 -1
  30. package/dist/types/src/components/Listbox/Listbox.d.ts +27 -0
  31. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -0
  32. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +12 -0
  33. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Listbox/index.d.ts +2 -0
  35. package/dist/types/src/components/Listbox/index.d.ts.map +1 -0
  36. package/dist/types/src/components/Picker/Picker.d.ts +49 -0
  37. package/dist/types/src/components/Picker/Picker.d.ts.map +1 -0
  38. package/dist/types/src/components/Picker/Picker.stories.d.ts +28 -0
  39. package/dist/types/src/components/Picker/Picker.stories.d.ts.map +1 -0
  40. package/dist/types/src/components/Picker/context.d.ts +29 -0
  41. package/dist/types/src/components/Picker/context.d.ts.map +1 -0
  42. package/dist/types/src/components/Picker/index.d.ts +3 -0
  43. package/dist/types/src/components/Picker/index.d.ts.map +1 -0
  44. package/dist/types/src/components/RowList/RowList.d.ts +61 -0
  45. package/dist/types/src/components/RowList/RowList.d.ts.map +1 -0
  46. package/dist/types/src/components/RowList/RowList.stories.d.ts +35 -0
  47. package/dist/types/src/components/RowList/RowList.stories.d.ts.map +1 -0
  48. package/dist/types/src/components/RowList/index.d.ts +3 -0
  49. package/dist/types/src/components/RowList/index.d.ts.map +1 -0
  50. package/dist/types/src/components/Tree/Tree.d.ts +10 -6
  51. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  52. package/dist/types/src/components/Tree/Tree.stories.d.ts +9 -28
  53. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/Tree/TreeContext.d.ts +24 -10
  55. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  56. package/dist/types/src/components/Tree/TreeItem.d.ts +25 -4
  57. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  58. package/dist/types/src/components/Tree/TreeItemHeading.d.ts +4 -3
  59. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  60. package/dist/types/src/components/Tree/TreeItemToggle.d.ts +3 -3
  61. package/dist/types/src/components/Tree/TreeItemToggle.d.ts.map +1 -1
  62. package/dist/types/src/components/Tree/helpers.d.ts.map +1 -1
  63. package/dist/types/src/components/Tree/index.d.ts +2 -0
  64. package/dist/types/src/components/Tree/index.d.ts.map +1 -1
  65. package/dist/types/src/components/Tree/testing.d.ts +3 -3
  66. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  67. package/dist/types/src/components/index.d.ts +4 -0
  68. package/dist/types/src/components/index.d.ts.map +1 -1
  69. package/dist/types/src/util/path.d.ts.map +1 -1
  70. package/dist/types/tsconfig.tsbuildinfo +1 -1
  71. package/package.json +34 -31
  72. package/src/components/Accordion/Accordion.stories.tsx +5 -8
  73. package/src/components/Accordion/AccordionItem.tsx +3 -4
  74. package/src/components/Accordion/AccordionRoot.tsx +1 -1
  75. package/src/components/Combobox/Combobox.stories.tsx +60 -0
  76. package/src/components/Combobox/Combobox.tsx +387 -0
  77. package/src/components/Combobox/index.ts +5 -0
  78. package/src/components/List/List.stories.tsx +34 -22
  79. package/src/components/List/List.tsx +14 -10
  80. package/src/components/List/ListItem.tsx +60 -40
  81. package/src/components/List/ListRoot.tsx +3 -3
  82. package/src/components/List/testing.ts +7 -7
  83. package/src/components/Listbox/Listbox.stories.tsx +48 -0
  84. package/src/components/Listbox/Listbox.tsx +201 -0
  85. package/src/components/Listbox/index.ts +5 -0
  86. package/src/components/Picker/Picker.stories.tsx +131 -0
  87. package/src/components/Picker/Picker.tsx +439 -0
  88. package/src/components/Picker/context.ts +43 -0
  89. package/src/components/Picker/index.ts +6 -0
  90. package/src/components/RowList/RowList.stories.tsx +163 -0
  91. package/src/components/RowList/RowList.tsx +353 -0
  92. package/src/components/RowList/index.ts +6 -0
  93. package/src/components/Tree/Tree.stories.tsx +153 -64
  94. package/src/components/Tree/Tree.tsx +43 -40
  95. package/src/components/Tree/TreeContext.tsx +21 -9
  96. package/src/components/Tree/TreeItem.tsx +214 -127
  97. package/src/components/Tree/TreeItemHeading.tsx +10 -8
  98. package/src/components/Tree/TreeItemToggle.tsx +29 -18
  99. package/src/components/Tree/index.ts +2 -0
  100. package/src/components/Tree/testing.ts +10 -9
  101. 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,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { Picker, usePickerInputContext, usePickerItemContext } from './Picker';
6
+ export type { PickerRootProps, PickerInputProps, PickerItemProps } from './Picker';
@@ -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 /> };