@dxos/react-ui-list 0.8.4-main.ef1bc66f44 → 0.8.4-main.f466a3d56e

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 (94) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +955 -270
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/node-esm/index.mjs +955 -270
  6. package/dist/lib/node-esm/index.mjs.map +4 -4
  7. package/dist/lib/node-esm/meta.json +1 -1
  8. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  9. package/dist/types/src/components/Accordion/Accordion.d.ts.map +1 -1
  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 +18 -7
  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.map +1 -1
  27. package/dist/types/src/components/List/testing.d.ts.map +1 -1
  28. package/dist/types/src/components/Listbox/Listbox.d.ts +27 -0
  29. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -0
  30. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +12 -0
  31. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -0
  32. package/dist/types/src/components/Listbox/index.d.ts +2 -0
  33. package/dist/types/src/components/Listbox/index.d.ts.map +1 -0
  34. package/dist/types/src/components/Picker/Picker.d.ts +49 -0
  35. package/dist/types/src/components/Picker/Picker.d.ts.map +1 -0
  36. package/dist/types/src/components/Picker/Picker.stories.d.ts +28 -0
  37. package/dist/types/src/components/Picker/Picker.stories.d.ts.map +1 -0
  38. package/dist/types/src/components/Picker/context.d.ts +29 -0
  39. package/dist/types/src/components/Picker/context.d.ts.map +1 -0
  40. package/dist/types/src/components/Picker/index.d.ts +3 -0
  41. package/dist/types/src/components/Picker/index.d.ts.map +1 -0
  42. package/dist/types/src/components/RowList/RowList.d.ts +61 -0
  43. package/dist/types/src/components/RowList/RowList.d.ts.map +1 -0
  44. package/dist/types/src/components/RowList/RowList.stories.d.ts +35 -0
  45. package/dist/types/src/components/RowList/RowList.stories.d.ts.map +1 -0
  46. package/dist/types/src/components/RowList/index.d.ts +3 -0
  47. package/dist/types/src/components/RowList/index.d.ts.map +1 -0
  48. package/dist/types/src/components/Tree/Tree.d.ts +6 -5
  49. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  50. package/dist/types/src/components/Tree/Tree.stories.d.ts +1 -1
  51. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  52. package/dist/types/src/components/Tree/TreeContext.d.ts +21 -10
  53. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  54. package/dist/types/src/components/Tree/TreeItem.d.ts +8 -0
  55. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  56. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  57. package/dist/types/src/components/Tree/helpers.d.ts.map +1 -1
  58. package/dist/types/src/components/Tree/index.d.ts +2 -0
  59. package/dist/types/src/components/Tree/index.d.ts.map +1 -1
  60. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  61. package/dist/types/src/components/index.d.ts +4 -0
  62. package/dist/types/src/components/index.d.ts.map +1 -1
  63. package/dist/types/src/util/path.d.ts.map +1 -1
  64. package/dist/types/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +22 -21
  66. package/src/components/Accordion/Accordion.stories.tsx +5 -5
  67. package/src/components/Accordion/AccordionItem.tsx +2 -5
  68. package/src/components/Combobox/Combobox.stories.tsx +60 -0
  69. package/src/components/Combobox/Combobox.tsx +387 -0
  70. package/src/components/Combobox/index.ts +5 -0
  71. package/src/components/List/List.stories.tsx +12 -12
  72. package/src/components/List/List.tsx +14 -10
  73. package/src/components/List/ListItem.tsx +55 -37
  74. package/src/components/List/ListRoot.tsx +1 -1
  75. package/src/components/List/testing.ts +4 -4
  76. package/src/components/Listbox/Listbox.stories.tsx +48 -0
  77. package/src/components/Listbox/Listbox.tsx +201 -0
  78. package/src/components/Listbox/index.ts +5 -0
  79. package/src/components/Picker/Picker.stories.tsx +131 -0
  80. package/src/components/Picker/Picker.tsx +368 -0
  81. package/src/components/Picker/context.ts +43 -0
  82. package/src/components/Picker/index.ts +6 -0
  83. package/src/components/RowList/RowList.stories.tsx +163 -0
  84. package/src/components/RowList/RowList.tsx +350 -0
  85. package/src/components/RowList/index.ts +6 -0
  86. package/src/components/Tree/Tree.stories.tsx +105 -30
  87. package/src/components/Tree/Tree.tsx +31 -41
  88. package/src/components/Tree/TreeContext.tsx +18 -9
  89. package/src/components/Tree/TreeItem.tsx +179 -108
  90. package/src/components/Tree/TreeItemHeading.tsx +5 -4
  91. package/src/components/Tree/TreeItemToggle.tsx +4 -4
  92. package/src/components/Tree/index.ts +2 -0
  93. package/src/components/Tree/testing.ts +5 -5
  94. package/src/components/index.ts +4 -0
@@ -0,0 +1,368 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // `Picker` — generic listbox-with-input compound implementing the
6
+ // WAI-ARIA combobox keyboard pattern. Search / filtering live one layer
7
+ // up in `@dxos/react-ui-search`.
8
+ //
9
+ // The two contexts (Input / Item) are split so items don't re-render on
10
+ // every keystroke and the input doesn't re-render on every (un)register.
11
+
12
+ import { Slot } from '@radix-ui/react-slot';
13
+ import React, {
14
+ type ChangeEvent,
15
+ type ComponentPropsWithRef,
16
+ type KeyboardEvent,
17
+ type MouseEvent as ReactMouseEvent,
18
+ type PropsWithChildren,
19
+ type ReactNode,
20
+ forwardRef,
21
+ useCallback,
22
+ useEffect,
23
+ useMemo,
24
+ useRef,
25
+ useState,
26
+ } from 'react';
27
+
28
+ import { type Density, type Elevation, Input, type ThemedClassName, useThemeContext } from '@dxos/react-ui';
29
+ import { mx } from '@dxos/ui-theme';
30
+
31
+ import {
32
+ PickerInputContextProvider,
33
+ PickerItemContextProvider,
34
+ usePickerInputContext,
35
+ usePickerItemContext,
36
+ } from './context';
37
+
38
+ type ItemData = {
39
+ element: HTMLElement;
40
+ disabled?: boolean;
41
+ onSelect?: () => void;
42
+ };
43
+
44
+ //
45
+ // Root
46
+ //
47
+
48
+ type PickerRootProps = PropsWithChildren<{}>;
49
+
50
+ const PickerRoot = ({ children }: PickerRootProps) => {
51
+ const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined);
52
+ const itemsRef = useRef<Map<string, ItemData>>(new Map());
53
+ // Bumped on every (un)register to retrigger auto-select.
54
+ const [itemVersion, setItemVersion] = useState(0);
55
+
56
+ // Auto-select first non-disabled item when the current selection is
57
+ // gone or disabled.
58
+ useEffect(() => {
59
+ const current = selectedValue !== undefined ? itemsRef.current.get(selectedValue) : undefined;
60
+ const isValid = current !== undefined && !current.disabled;
61
+ if (!isValid && itemsRef.current.size > 0) {
62
+ const entries = Array.from(itemsRef.current.entries()).filter(([, data]) => !data.disabled);
63
+ if (entries.length > 0) {
64
+ entries.sort(([, a], [, b]) => {
65
+ const position = a.element.compareDocumentPosition(b.element);
66
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
67
+ return -1;
68
+ }
69
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) {
70
+ return 1;
71
+ }
72
+ return 0;
73
+ });
74
+ const firstValue = entries[0]?.[0];
75
+ if (firstValue !== undefined && firstValue !== selectedValue) {
76
+ setSelectedValue(firstValue);
77
+ }
78
+ } else if (selectedValue !== undefined) {
79
+ setSelectedValue(undefined);
80
+ }
81
+ }
82
+ }, [itemVersion, selectedValue]);
83
+
84
+ const registerItem = useCallback(
85
+ (value: string, element: HTMLElement | null, onSelect: (() => void) | undefined, disabled?: boolean) => {
86
+ if (element) {
87
+ itemsRef.current.set(value, { element, onSelect, disabled });
88
+ setItemVersion((v) => v + 1);
89
+ }
90
+ },
91
+ [],
92
+ );
93
+
94
+ const unregisterItem = useCallback((value: string) => {
95
+ itemsRef.current.delete(value);
96
+ setItemVersion((v) => v + 1);
97
+ }, []);
98
+
99
+ // DOM-order list of enabled item values.
100
+ const getItemValues = useCallback(() => {
101
+ return Array.from(itemsRef.current.entries())
102
+ .filter(([, data]) => !data.disabled)
103
+ .sort(([, a], [, b]) => {
104
+ const position = a.element.compareDocumentPosition(b.element);
105
+ return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : position & Node.DOCUMENT_POSITION_PRECEDING ? 1 : 0;
106
+ })
107
+ .map(([value]) => value);
108
+ }, []);
109
+
110
+ const triggerSelect = useCallback(() => {
111
+ if (selectedValue !== undefined) {
112
+ const item = itemsRef.current.get(selectedValue);
113
+ item?.onSelect?.();
114
+ }
115
+ }, [selectedValue]);
116
+
117
+ // Stable values items subscribe to.
118
+ const itemContextValue = useMemo(
119
+ () => ({
120
+ selectedValue,
121
+ onSelectedValueChange: setSelectedValue,
122
+ registerItem,
123
+ unregisterItem,
124
+ }),
125
+ [selectedValue, registerItem, unregisterItem],
126
+ );
127
+
128
+ // Volatile values the input subscribes to (keyboard helpers).
129
+ const inputContextValue = useMemo(
130
+ () => ({
131
+ selectedValue,
132
+ onSelectedValueChange: setSelectedValue,
133
+ getItemValues,
134
+ triggerSelect,
135
+ }),
136
+ [selectedValue, getItemValues, triggerSelect],
137
+ );
138
+
139
+ return (
140
+ <PickerInputContextProvider {...inputContextValue}>
141
+ <PickerItemContextProvider {...itemContextValue}>{children}</PickerItemContextProvider>
142
+ </PickerInputContextProvider>
143
+ );
144
+ };
145
+
146
+ PickerRoot.displayName = 'Picker.Root';
147
+
148
+ //
149
+ // Input
150
+ //
151
+
152
+ type InputVariant = 'default' | 'subdued';
153
+
154
+ type PickerInputProps = ThemedClassName<
155
+ Omit<ComponentPropsWithRef<'input'>, 'value'> & {
156
+ /** Controlled input value. Caller owns this — e.g. binds to query state. */
157
+ value?: string;
158
+ /** Called on every keystroke with the new input string. */
159
+ onValueChange?: (value: string) => void;
160
+ density?: Density;
161
+ elevation?: Elevation;
162
+ variant?: InputVariant;
163
+ }
164
+ >;
165
+
166
+ const PickerInput = forwardRef<HTMLInputElement, PickerInputProps>(
167
+ ({ value, onValueChange, onChange, onKeyDown, autoFocus, ...props }, forwardedRef) => {
168
+ const { hasIosKeyboard } = useThemeContext();
169
+ const { selectedValue, onSelectedValueChange, getItemValues, triggerSelect } =
170
+ usePickerInputContext('Picker.Input');
171
+
172
+ const handleChange = useCallback(
173
+ (event: ChangeEvent<HTMLInputElement>) => {
174
+ onValueChange?.(event.target.value);
175
+ onChange?.(event);
176
+ },
177
+ [onValueChange, onChange],
178
+ );
179
+
180
+ const handleKeyDown = useCallback(
181
+ (event: KeyboardEvent<HTMLInputElement>) => {
182
+ onKeyDown?.(event);
183
+ if (event.defaultPrevented) {
184
+ return;
185
+ }
186
+ const values = getItemValues();
187
+ if (values.length === 0) {
188
+ if (event.key === 'Escape') {
189
+ onValueChange?.('');
190
+ }
191
+ return;
192
+ }
193
+
194
+ const currentIndex = selectedValue !== undefined ? values.indexOf(selectedValue) : -1;
195
+
196
+ switch (event.key) {
197
+ case 'ArrowDown': {
198
+ event.preventDefault();
199
+ const nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, values.length - 1);
200
+ const nextValue = values[nextIndex];
201
+ if (nextValue !== undefined) {
202
+ onSelectedValueChange(nextValue);
203
+ }
204
+ break;
205
+ }
206
+ case 'ArrowUp': {
207
+ event.preventDefault();
208
+ const prevIndex = currentIndex === -1 ? values.length - 1 : Math.max(currentIndex - 1, 0);
209
+ const prevValue = values[prevIndex];
210
+ if (prevValue !== undefined) {
211
+ onSelectedValueChange(prevValue);
212
+ }
213
+ break;
214
+ }
215
+ case 'Enter': {
216
+ if (selectedValue !== undefined) {
217
+ event.preventDefault();
218
+ triggerSelect();
219
+ }
220
+ break;
221
+ }
222
+ case 'Home': {
223
+ event.preventDefault();
224
+ const firstValue = values[0];
225
+ if (firstValue !== undefined) {
226
+ onSelectedValueChange(firstValue);
227
+ }
228
+ break;
229
+ }
230
+ case 'End': {
231
+ event.preventDefault();
232
+ const lastValue = values[values.length - 1];
233
+ if (lastValue !== undefined) {
234
+ onSelectedValueChange(lastValue);
235
+ }
236
+ break;
237
+ }
238
+ case 'Escape': {
239
+ event.preventDefault();
240
+ if (selectedValue !== undefined) {
241
+ onSelectedValueChange(undefined);
242
+ } else {
243
+ onValueChange?.('');
244
+ }
245
+ break;
246
+ }
247
+ }
248
+ },
249
+ [selectedValue, onSelectedValueChange, getItemValues, triggerSelect, onValueChange, onKeyDown],
250
+ );
251
+
252
+ // Only force-control when `value` is provided; otherwise leave the
253
+ // input uncontrolled so it accepts keystrokes without `onValueChange`.
254
+ return (
255
+ <Input.Root>
256
+ <Input.TextInput
257
+ {...props}
258
+ autoFocus={autoFocus && !hasIosKeyboard}
259
+ {...(value !== undefined && { value })}
260
+ onChange={handleChange}
261
+ onKeyDown={handleKeyDown}
262
+ ref={forwardedRef}
263
+ />
264
+ </Input.Root>
265
+ );
266
+ },
267
+ );
268
+
269
+ PickerInput.displayName = 'Picker.Input';
270
+
271
+ //
272
+ // Item
273
+ //
274
+
275
+ type PickerItemProps = ThemedClassName<{
276
+ /** Unique identifier; used by the registry and DOM-order traversal. */
277
+ value: string;
278
+ /** Callback when the item is committed (click, or Enter while highlighted). */
279
+ onSelect?: () => void;
280
+ /** Disable the item — registry-visible but not focusable, not navigable, not clickable. */
281
+ disabled?: boolean;
282
+ asChild?: boolean;
283
+ children?: ReactNode;
284
+ }>;
285
+
286
+ const PickerItem = forwardRef<HTMLDivElement, PickerItemProps>(
287
+ ({ classNames, value, onSelect, disabled, asChild, children, ...props }, forwardedRef) => {
288
+ const { selectedValue, onSelectedValueChange, registerItem, unregisterItem } = usePickerItemContext('Picker.Item');
289
+ const internalRef = useRef<HTMLDivElement>(null);
290
+
291
+ const isSelected = selectedValue === value && !disabled;
292
+
293
+ useEffect(() => {
294
+ const element = internalRef.current;
295
+ if (element) {
296
+ registerItem(value, element, onSelect, disabled);
297
+ }
298
+ return () => unregisterItem(value);
299
+ }, [value, onSelect, disabled, registerItem, unregisterItem]);
300
+
301
+ useEffect(() => {
302
+ if (isSelected && internalRef.current) {
303
+ internalRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
304
+ }
305
+ }, [isSelected]);
306
+
307
+ const handleClick = useCallback(() => {
308
+ if (disabled) {
309
+ return;
310
+ }
311
+ onSelectedValueChange(value);
312
+ onSelect?.();
313
+ }, [disabled, value, onSelectedValueChange, onSelect]);
314
+
315
+ // Keep focus on `Picker.Input`: any `tabIndex` (incl. `-1`) would
316
+ // steal focus on click and break the input's arrow-key handler.
317
+ const handleMouseDown = useCallback((event: ReactMouseEvent<HTMLElement>) => {
318
+ event.preventDefault();
319
+ }, []);
320
+
321
+ const Comp: any = asChild ? Slot : 'div';
322
+
323
+ // Padding follows `--gutter` to align with sibling `Column.Center`
324
+ // content; falls back to `0.75rem` when not nested under `Column.Root`.
325
+ return (
326
+ <Comp
327
+ {...props}
328
+ ref={(node: HTMLDivElement | null) => {
329
+ internalRef.current = node;
330
+ if (typeof forwardedRef === 'function') {
331
+ forwardedRef(node);
332
+ } else if (forwardedRef) {
333
+ forwardedRef.current = node;
334
+ }
335
+ }}
336
+ role='option'
337
+ aria-selected={isSelected}
338
+ aria-disabled={disabled}
339
+ data-selected={isSelected}
340
+ data-disabled={disabled}
341
+ data-value={value}
342
+ // Browser focus stays on the input; highlight is via `aria-selected`.
343
+ tabIndex={-1}
344
+ className={mx(
345
+ 'dx-hover dx-selected px-[var(--gutter,0.75rem)] py-1 cursor-pointer select-none',
346
+ disabled && 'opacity-50 cursor-not-allowed',
347
+ classNames,
348
+ )}
349
+ onMouseDown={handleMouseDown}
350
+ onClick={handleClick}
351
+ >
352
+ {children}
353
+ </Comp>
354
+ );
355
+ },
356
+ );
357
+
358
+ PickerItem.displayName = 'Picker.Item';
359
+
360
+ export const Picker = {
361
+ Root: PickerRoot,
362
+ Input: PickerInput,
363
+ Item: PickerItem,
364
+ };
365
+
366
+ export type { PickerRootProps, PickerInputProps, PickerItemProps };
367
+
368
+ 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 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 /> };