@dxos/react-ui-list 0.9.0 → 0.9.1-main.c7dcc2e112

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 (110) hide show
  1. package/dist/lib/browser/index.mjs +993 -521
  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 +993 -521
  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/aspects/index.d.ts +6 -0
  8. package/dist/types/src/aspects/index.d.ts.map +1 -0
  9. package/dist/types/src/aspects/useListDisclosure.d.ts +60 -0
  10. package/dist/types/src/aspects/useListDisclosure.d.ts.map +1 -0
  11. package/dist/types/src/aspects/useListDisclosure.test.d.ts +2 -0
  12. package/dist/types/src/aspects/useListDisclosure.test.d.ts.map +1 -0
  13. package/dist/types/src/aspects/useListGrid.d.ts +30 -0
  14. package/dist/types/src/aspects/useListGrid.d.ts.map +1 -0
  15. package/dist/types/src/aspects/useListGrid.test.d.ts +2 -0
  16. package/dist/types/src/aspects/useListGrid.test.d.ts.map +1 -0
  17. package/dist/types/src/aspects/useListNavigation.d.ts +68 -0
  18. package/dist/types/src/aspects/useListNavigation.d.ts.map +1 -0
  19. package/dist/types/src/aspects/useListNavigation.test.d.ts +2 -0
  20. package/dist/types/src/aspects/useListNavigation.test.d.ts.map +1 -0
  21. package/dist/types/src/aspects/useListSelection.d.ts +48 -0
  22. package/dist/types/src/aspects/useListSelection.d.ts.map +1 -0
  23. package/dist/types/src/aspects/useListSelection.test.d.ts +2 -0
  24. package/dist/types/src/aspects/useListSelection.test.d.ts.map +1 -0
  25. package/dist/types/src/aspects/useReorder.d.ts +103 -0
  26. package/dist/types/src/aspects/useReorder.d.ts.map +1 -0
  27. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  28. package/dist/types/src/components/Accordion/AccordionItem.d.ts +5 -3
  29. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  30. package/dist/types/src/components/Accordion/AccordionRoot.d.ts +1 -1
  31. package/dist/types/src/components/Accordion/AccordionRoot.d.ts.map +1 -1
  32. package/dist/types/src/components/Listbox/Listbox.d.ts +60 -20
  33. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
  34. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +27 -3
  35. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -1
  36. package/dist/types/src/components/OrderedList/OrderedList.d.ts +49 -0
  37. package/dist/types/src/components/OrderedList/OrderedList.d.ts.map +1 -0
  38. package/dist/types/src/components/OrderedList/OrderedList.stories.d.ts +11 -0
  39. package/dist/types/src/components/OrderedList/OrderedList.stories.d.ts.map +1 -0
  40. package/dist/types/src/components/OrderedList/OrderedList.test.d.ts +2 -0
  41. package/dist/types/src/components/OrderedList/OrderedList.test.d.ts.map +1 -0
  42. package/dist/types/src/components/OrderedList/OrderedListItem.d.ts +94 -0
  43. package/dist/types/src/components/OrderedList/OrderedListItem.d.ts.map +1 -0
  44. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts +73 -0
  45. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts.map +1 -0
  46. package/dist/types/src/components/OrderedList/index.d.ts +2 -0
  47. package/dist/types/src/components/OrderedList/index.d.ts.map +1 -0
  48. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  49. package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
  50. package/dist/types/src/components/index.d.ts +1 -2
  51. package/dist/types/src/components/index.d.ts.map +1 -1
  52. package/dist/types/src/index.d.ts +1 -0
  53. package/dist/types/src/index.d.ts.map +1 -1
  54. package/dist/types/src/vitest-setup.d.ts +2 -0
  55. package/dist/types/src/vitest-setup.d.ts.map +1 -0
  56. package/dist/types/tsconfig.tsbuildinfo +1 -1
  57. package/package.json +18 -15
  58. package/src/aspects/index.ts +9 -0
  59. package/src/aspects/useListDisclosure.test.ts +72 -0
  60. package/src/aspects/useListDisclosure.ts +160 -0
  61. package/src/aspects/useListGrid.test.ts +41 -0
  62. package/src/aspects/useListGrid.ts +61 -0
  63. package/src/aspects/useListNavigation.test.ts +44 -0
  64. package/src/aspects/useListNavigation.ts +160 -0
  65. package/src/aspects/useListSelection.test.ts +101 -0
  66. package/src/aspects/useListSelection.ts +162 -0
  67. package/src/aspects/useReorder.ts +370 -0
  68. package/src/components/Accordion/Accordion.stories.tsx +1 -1
  69. package/src/components/Accordion/AccordionItem.tsx +11 -6
  70. package/src/components/Accordion/AccordionRoot.tsx +4 -1
  71. package/src/components/Listbox/Listbox.stories.tsx +171 -21
  72. package/src/components/Listbox/Listbox.tsx +302 -145
  73. package/src/components/OrderedList/OrderedList.stories.tsx +379 -0
  74. package/src/components/OrderedList/OrderedList.test.tsx +59 -0
  75. package/src/components/OrderedList/OrderedList.tsx +63 -0
  76. package/src/components/OrderedList/OrderedListItem.tsx +348 -0
  77. package/src/components/OrderedList/OrderedListRoot.tsx +173 -0
  78. package/src/components/OrderedList/index.ts +5 -0
  79. package/src/components/Tree/TreeItem.tsx +2 -0
  80. package/src/components/Tree/TreeItemHeading.tsx +1 -2
  81. package/src/components/index.ts +1 -2
  82. package/src/index.ts +1 -0
  83. package/src/vitest-setup.ts +11 -0
  84. package/dist/types/src/components/List/List.d.ts +0 -40
  85. package/dist/types/src/components/List/List.d.ts.map +0 -1
  86. package/dist/types/src/components/List/List.stories.d.ts +0 -18
  87. package/dist/types/src/components/List/List.stories.d.ts.map +0 -1
  88. package/dist/types/src/components/List/ListItem.d.ts +0 -49
  89. package/dist/types/src/components/List/ListItem.d.ts.map +0 -1
  90. package/dist/types/src/components/List/ListRoot.d.ts +0 -29
  91. package/dist/types/src/components/List/ListRoot.d.ts.map +0 -1
  92. package/dist/types/src/components/List/index.d.ts +0 -2
  93. package/dist/types/src/components/List/index.d.ts.map +0 -1
  94. package/dist/types/src/components/List/testing.d.ts +0 -15
  95. package/dist/types/src/components/List/testing.d.ts.map +0 -1
  96. package/dist/types/src/components/RowList/RowList.d.ts +0 -61
  97. package/dist/types/src/components/RowList/RowList.d.ts.map +0 -1
  98. package/dist/types/src/components/RowList/RowList.stories.d.ts +0 -35
  99. package/dist/types/src/components/RowList/RowList.stories.d.ts.map +0 -1
  100. package/dist/types/src/components/RowList/index.d.ts +0 -3
  101. package/dist/types/src/components/RowList/index.d.ts.map +0 -1
  102. package/src/components/List/List.stories.tsx +0 -129
  103. package/src/components/List/List.tsx +0 -47
  104. package/src/components/List/ListItem.tsx +0 -287
  105. package/src/components/List/ListRoot.tsx +0 -106
  106. package/src/components/List/index.ts +0 -5
  107. package/src/components/List/testing.ts +0 -31
  108. package/src/components/RowList/RowList.stories.tsx +0 -163
  109. package/src/components/RowList/RowList.tsx +0 -350
  110. package/src/components/RowList/index.ts +0 -6
@@ -0,0 +1,379 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useCallback, useState } from 'react';
7
+
8
+ import { Input, useTranslation } from '@dxos/react-ui';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+ import { mx, osTranslations } from '@dxos/ui-theme';
11
+ import { arrayMove } from '@dxos/util';
12
+
13
+ import { OrderedList } from './OrderedList';
14
+
15
+ type Item = { id: string; label: string };
16
+
17
+ const initialItems: Item[] = [
18
+ { id: 'a', label: 'Alpha' },
19
+ { id: 'b', label: 'Bravo' },
20
+ { id: 'c', label: 'Charlie' },
21
+ { id: 'd', label: 'Delta' },
22
+ { id: 'e', label: 'Echo' },
23
+ ];
24
+
25
+ const isItem = (value: any): value is Item => !!value && typeof value === 'object' && typeof value.id === 'string';
26
+
27
+ //
28
+ // Simple — a static list with title only. No drag, no toggle, no delete. The lowest-noise
29
+ // shape `OrderedList` supports; useful when the chrome (drag/expand/delete) is overkill.
30
+ //
31
+
32
+ const SimpleStory = () => {
33
+ const [items] = useState<Item[]>(initialItems);
34
+ return (
35
+ <OrderedList.Root<Item> items={items} isItem={isItem} getId={(item) => item.id}>
36
+ {({ items: resolved }) => (
37
+ <OrderedList.Content>
38
+ {resolved.map((item) => (
39
+ <OrderedList.Item key={item.id} id={item.id} item={item} hover classNames='px-3 py-2'>
40
+ {item.label}
41
+ </OrderedList.Item>
42
+ ))}
43
+ </OrderedList.Content>
44
+ )}
45
+ </OrderedList.Root>
46
+ );
47
+ };
48
+
49
+ //
50
+ // Scrollable — Simple variant wrapped in `OrderedList.Viewport` so it fills its parent
51
+ // pane and scrolls independently. Use this shape when the list lives inside a constrained
52
+ // container (settings panel, sidebar) and may overflow.
53
+ //
54
+
55
+ const longItems: Item[] = Array.from({ length: 40 }, (_, i) => ({
56
+ id: `item-${i}`,
57
+ label: `Item ${i + 1}`,
58
+ }));
59
+
60
+ const ScrollableStory = () => {
61
+ return (
62
+ <OrderedList.Root<Item> items={longItems} isItem={isItem} getId={(item) => item.id}>
63
+ {({ items: resolved }) => (
64
+ <OrderedList.Viewport>
65
+ <OrderedList.Content>
66
+ {resolved.map((item) => (
67
+ <OrderedList.Item key={item.id} id={item.id} item={item} hover classNames='px-3 py-2'>
68
+ {item.label}
69
+ </OrderedList.Item>
70
+ ))}
71
+ </OrderedList.Content>
72
+ </OrderedList.Viewport>
73
+ )}
74
+ </OrderedList.Root>
75
+ );
76
+ };
77
+
78
+ //
79
+ // Draggable / Ordered — drag handle + title, reorder via `onMove`. No expand, no delete.
80
+ // The canonical "user-curates-the-order" shape (matches the ordered ArrayField pattern).
81
+ //
82
+
83
+ const DraggableStory = () => {
84
+ const [items, setItems] = useState<Item[]>(longItems);
85
+
86
+ const handleMove = useCallback((fromIndex: number, toIndex: number) => {
87
+ setItems((prev) => {
88
+ const next = [...prev];
89
+ arrayMove(next, fromIndex, toIndex);
90
+ return next;
91
+ });
92
+ }, []);
93
+
94
+ return (
95
+ <OrderedList.Root<Item> items={items} isItem={isItem} getId={(item) => item.id} onMove={handleMove}>
96
+ {({ items: resolved }) => (
97
+ <OrderedList.Viewport>
98
+ <OrderedList.Content>
99
+ {resolved.map((item) => (
100
+ <OrderedList.Item
101
+ key={item.id}
102
+ id={item.id}
103
+ item={item}
104
+ hover
105
+ classNames='grid grid-cols-[var(--dx-rail-item)_1fr] items-center gap-1'
106
+ >
107
+ <OrderedList.DragHandle />
108
+ <OrderedList.Title>{item.label}</OrderedList.Title>
109
+ </OrderedList.Item>
110
+ ))}
111
+ </OrderedList.Content>
112
+ </OrderedList.Viewport>
113
+ )}
114
+ </OrderedList.Root>
115
+ );
116
+ };
117
+
118
+ //
119
+ // Checkbox + Delete — checkbox-led row that records a `done` flag, plus an `OrderedList.DeleteButton`
120
+ // in the trailing slot. No drag (the order is intrinsic). Matches todo-list shapes
121
+ // (`plugin-sidekick/ActionItems` etc.).
122
+ //
123
+
124
+ type TodoItem = Item & { done: boolean };
125
+
126
+ const initialTodos: TodoItem[] = initialItems.map((item) => ({ ...item, done: false }));
127
+
128
+ const CheckboxWithDeleteStory = () => {
129
+ const [items, setItems] = useState<TodoItem[]>(initialTodos);
130
+
131
+ const handleToggle = useCallback((id: string, checked: boolean) => {
132
+ setItems((prev) => prev.map((item) => (item.id === id ? { ...item, done: checked } : item)));
133
+ }, []);
134
+
135
+ const handleDelete = useCallback((id: string) => {
136
+ setItems((prev) => prev.filter((item) => item.id !== id));
137
+ }, []);
138
+
139
+ return (
140
+ <OrderedList.Root<TodoItem> items={items} isItem={isItem} getId={(item) => item.id}>
141
+ {({ items: resolved }) => (
142
+ <OrderedList.Content>
143
+ {resolved.map((item) => (
144
+ <OrderedList.Item
145
+ key={item.id}
146
+ id={item.id}
147
+ item={item}
148
+ hover
149
+ classNames='grid grid-cols-[var(--dx-rail-item)_1fr_var(--dx-rail-item)] items-center gap-1 px-2'
150
+ >
151
+ <Input.Root>
152
+ <Input.Checkbox checked={item.done} onCheckedChange={(next) => handleToggle(item.id, next === true)} />
153
+ </Input.Root>
154
+ <OrderedList.Title classNames={mx(item.done && 'line-through text-subdued')}>
155
+ {item.label}
156
+ </OrderedList.Title>
157
+ <OrderedList.DeleteButton onClick={() => handleDelete(item.id)} />
158
+ </OrderedList.Item>
159
+ ))}
160
+ </OrderedList.Content>
161
+ )}
162
+ </OrderedList.Root>
163
+ );
164
+ };
165
+
166
+ //
167
+ // Draggable with Toggle — drag handle + clickable title that opens an inline detail panel,
168
+ // plus a trailing delete button. The full master-detail editor shape — same as the
169
+ // canonical `OrderedList.DetailItem` and the way `PipelineProperties` / `FieldList` /
170
+ // the ordered `ArrayField` consume the compound.
171
+ //
172
+
173
+ const DraggableWithToggleStory = () => {
174
+ const { t } = useTranslation(osTranslations);
175
+ const [items, setItems] = useState<Item[]>(initialItems);
176
+ const [expandedId, setExpandedId] = useState<string>();
177
+
178
+ const handleMove = useCallback((fromIndex: number, toIndex: number) => {
179
+ setItems((prev) => {
180
+ const next = [...prev];
181
+ arrayMove(next, fromIndex, toIndex);
182
+ return next;
183
+ });
184
+ }, []);
185
+
186
+ const handleDelete = useCallback((id: string) => {
187
+ setItems((prev) => prev.filter((item) => item.id !== id));
188
+ setExpandedId((current) => (current === id ? undefined : current));
189
+ }, []);
190
+
191
+ return (
192
+ <OrderedList.Root<Item>
193
+ items={items}
194
+ isItem={isItem}
195
+ getId={(item) => item.id}
196
+ onMove={handleMove}
197
+ expandedId={expandedId}
198
+ onExpandedChange={setExpandedId}
199
+ >
200
+ {({ items: resolved }) => (
201
+ <OrderedList.Content>
202
+ {resolved.map((item) => (
203
+ <OrderedList.DetailItem<Item>
204
+ key={item.id}
205
+ id={item.id}
206
+ item={item}
207
+ title={item.label}
208
+ trailing={
209
+ <OrderedList.DeleteButton
210
+ label={t('delete.label')}
211
+ onClick={() => handleDelete(item.id)}
212
+ data-testid={`delete-${item.id}`}
213
+ />
214
+ }
215
+ >
216
+ <div data-testid={`panel-${item.id}`} className='p-2'>
217
+ Details for {item.label}
218
+ </div>
219
+ </OrderedList.DetailItem>
220
+ ))}
221
+ </OrderedList.Content>
222
+ )}
223
+ </OrderedList.Root>
224
+ );
225
+ };
226
+
227
+ //
228
+ // Nested — a parent `OrderedList.DetailItem` whose detail panel contains another
229
+ // `OrderedList`. Exercises Radix context shadowing (each `<OrderedList.Root>` provides its
230
+ // own reorder/disclosure/nav controllers) and pragmatic-dnd's per-list `canDrop` filter
231
+ // (so a sub-item can't drop into the parent list, and vice versa).
232
+ //
233
+
234
+ type Group = { id: string; label: string; subItems: Item[] };
235
+
236
+ const initialGroups: Group[] = [
237
+ {
238
+ id: 'g-a',
239
+ label: 'Vowels',
240
+ subItems: [
241
+ { id: 'sa-1', label: 'A' },
242
+ { id: 'sa-2', label: 'E' },
243
+ { id: 'sa-3', label: 'I' },
244
+ ],
245
+ },
246
+ {
247
+ id: 'g-b',
248
+ label: 'Numbers',
249
+ subItems: [
250
+ { id: 'sb-1', label: 'One' },
251
+ { id: 'sb-2', label: 'Two' },
252
+ { id: 'sb-3', label: 'Three' },
253
+ ],
254
+ },
255
+ {
256
+ id: 'g-c',
257
+ label: 'Colors',
258
+ subItems: [
259
+ { id: 'sc-1', label: 'Red' },
260
+ { id: 'sc-2', label: 'Green' },
261
+ { id: 'sc-3', label: 'Blue' },
262
+ ],
263
+ },
264
+ ];
265
+
266
+ const isGroup = (value: any): value is Group => isItem(value) && Array.isArray((value as Group).subItems);
267
+
268
+ const NestedStory = () => {
269
+ const [groups, setGroups] = useState<Group[]>(initialGroups);
270
+ const [expandedId, setExpandedId] = useState<string>();
271
+
272
+ const handleGroupMove = useCallback((fromIndex: number, toIndex: number) => {
273
+ setGroups((prev) => {
274
+ const next = [...prev];
275
+ arrayMove(next, fromIndex, toIndex);
276
+ return next;
277
+ });
278
+ }, []);
279
+
280
+ const handleGroupDelete = useCallback((id: string) => {
281
+ setGroups((prev) => prev.filter((group) => group.id !== id));
282
+ setExpandedId((current) => (current === id ? undefined : current));
283
+ }, []);
284
+
285
+ // Per-group sub-item reorder. Each nested list owns its own onMove that updates the
286
+ // matching group; the parent list's `onMove` (above) is independent.
287
+ const handleSubMove = useCallback(
288
+ (groupId: string) => (fromIndex: number, toIndex: number) => {
289
+ setGroups((prev) =>
290
+ prev.map((group) => {
291
+ if (group.id !== groupId) {
292
+ return group;
293
+ }
294
+ const subItems = [...group.subItems];
295
+ arrayMove(subItems, fromIndex, toIndex);
296
+ return { ...group, subItems };
297
+ }),
298
+ );
299
+ },
300
+ [],
301
+ );
302
+
303
+ const handleSubDelete = useCallback((groupId: string, subId: string) => {
304
+ setGroups((prev) =>
305
+ prev.map((group) =>
306
+ group.id === groupId ? { ...group, subItems: group.subItems.filter((sub) => sub.id !== subId) } : group,
307
+ ),
308
+ );
309
+ }, []);
310
+
311
+ return (
312
+ <OrderedList.Root<Group>
313
+ items={groups}
314
+ isItem={isGroup}
315
+ getId={(group) => group.id}
316
+ onMove={handleGroupMove}
317
+ expandedId={expandedId}
318
+ onExpandedChange={setExpandedId}
319
+ >
320
+ {({ items: resolved }) => (
321
+ <OrderedList.Content>
322
+ {resolved.map((group) => (
323
+ <OrderedList.DetailItem<Group>
324
+ key={group.id}
325
+ id={group.id}
326
+ item={group}
327
+ title={group.label}
328
+ trailing={<OrderedList.DeleteButton onClick={() => handleGroupDelete(group.id)} />}
329
+ >
330
+ <OrderedList.Root<Item>
331
+ items={group.subItems}
332
+ isItem={isItem}
333
+ getId={(sub) => sub.id}
334
+ onMove={handleSubMove(group.id)}
335
+ >
336
+ {({ items: subs }) => (
337
+ <OrderedList.Content>
338
+ {subs.map((sub) => (
339
+ <OrderedList.Item
340
+ key={sub.id}
341
+ id={sub.id}
342
+ item={sub}
343
+ hover
344
+ classNames='grid grid-cols-[var(--dx-rail-item)_1fr_var(--dx-rail-item)] items-center gap-1'
345
+ >
346
+ <OrderedList.DragHandle />
347
+ <OrderedList.Title>{sub.label}</OrderedList.Title>
348
+ <OrderedList.DeleteButton onClick={() => handleSubDelete(group.id, sub.id)} />
349
+ </OrderedList.Item>
350
+ ))}
351
+ </OrderedList.Content>
352
+ )}
353
+ </OrderedList.Root>
354
+ </OrderedList.DetailItem>
355
+ ))}
356
+ </OrderedList.Content>
357
+ )}
358
+ </OrderedList.Root>
359
+ );
360
+ };
361
+
362
+ const meta: Meta = {
363
+ title: 'ui/react-ui-list/OrderedList',
364
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
365
+ };
366
+
367
+ export default meta;
368
+
369
+ type Story = StoryObj;
370
+
371
+ // `Default` shows the lowest-noise shape of the compound (no drag / no expand / no
372
+ // delete). The composed-story tests below import the specific variant they exercise
373
+ // rather than reading `Default`.
374
+ export const Default: Story = { render: () => <SimpleStory /> };
375
+ export const Scrollable: Story = { render: () => <ScrollableStory /> };
376
+ export const Draggable: Story = { render: () => <DraggableStory /> };
377
+ export const CheckboxWithDelete: Story = { render: () => <CheckboxWithDeleteStory /> };
378
+ export const DraggableWithToggle: Story = { render: () => <DraggableWithToggleStory /> };
379
+ export const Nested: Story = { render: () => <NestedStory /> };
@@ -0,0 +1,59 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { composeStories } from '@storybook/react';
6
+ import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
7
+ import React from 'react';
8
+ import { afterEach, describe, expect, test } from 'vitest';
9
+
10
+ import * as stories from './OrderedList.stories';
11
+
12
+ // Tests exercise the master-detail editor variant directly — the most feature-rich shape
13
+ // of the compound (drag handle + clickable title + expand caret + detail panel + delete).
14
+ const { DraggableWithToggle } = composeStories(stories);
15
+
16
+ describe('OrderedList', () => {
17
+ afterEach(() => {
18
+ cleanup();
19
+ });
20
+
21
+ test('renders all items', () => {
22
+ render(<DraggableWithToggle />);
23
+ expect(screen.getByText('Alpha')).toBeInTheDocument();
24
+ expect(screen.getByText('Bravo')).toBeInTheDocument();
25
+ expect(screen.getByText('Charlie')).toBeInTheDocument();
26
+ });
27
+
28
+ test('clicking a title expands and collapses it', () => {
29
+ render(<DraggableWithToggle />);
30
+ expect(screen.queryByTestId('panel-a')).not.toBeInTheDocument();
31
+ fireEvent.click(screen.getByText('Alpha'));
32
+ expect(screen.getByTestId('panel-a')).toBeInTheDocument();
33
+ fireEvent.click(screen.getByText('Alpha'));
34
+ expect(screen.queryByTestId('panel-a')).not.toBeInTheDocument();
35
+ });
36
+
37
+ test('expanding one collapses the previously expanded (single-expand)', () => {
38
+ render(<DraggableWithToggle />);
39
+ fireEvent.click(screen.getByText('Alpha'));
40
+ expect(screen.getByTestId('panel-a')).toBeInTheDocument();
41
+ fireEvent.click(screen.getByText('Bravo'));
42
+ expect(screen.queryByTestId('panel-a')).not.toBeInTheDocument();
43
+ expect(screen.getByTestId('panel-b')).toBeInTheDocument();
44
+ });
45
+
46
+ test('caret toggles expansion', () => {
47
+ render(<DraggableWithToggle />);
48
+ const row = screen.getByText('Charlie').closest('[role="listitem"]')!;
49
+ fireEvent.click(within(row as HTMLElement).getByRole('button', { name: /toggle-expand/i }));
50
+ expect(screen.getByTestId('panel-c')).toBeInTheDocument();
51
+ });
52
+
53
+ test('delete removes the item', () => {
54
+ render(<DraggableWithToggle />);
55
+ fireEvent.click(screen.getByTestId('delete-b'));
56
+ expect(screen.queryByText('Bravo')).not.toBeInTheDocument();
57
+ expect(screen.getByText('Alpha')).toBeInTheDocument();
58
+ });
59
+ });
@@ -0,0 +1,63 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import {
6
+ OrderedListDeleteButton,
7
+ OrderedListDetailItem,
8
+ type OrderedListDetailItemProps,
9
+ OrderedListDragHandle,
10
+ OrderedListExpandCaret,
11
+ OrderedListIconButton,
12
+ OrderedListItem,
13
+ type OrderedListItemProps,
14
+ OrderedListTitle,
15
+ } from './OrderedListItem';
16
+ import {
17
+ OrderedListContent,
18
+ OrderedListRoot,
19
+ type OrderedListRootProps,
20
+ OrderedListViewport,
21
+ type OrderedListViewportProps,
22
+ } from './OrderedListRoot';
23
+
24
+ /**
25
+ * Reorderable, single-expandable master-detail list.
26
+ *
27
+ * `DetailItem` encapsulates the common master-detail row (drag handle + bordered column with a
28
+ * name row that toggles an inline detail panel + a trailing action). Compose the lower-level
29
+ * `Item` / `DragHandle` / `Title` / `ExpandCaret` / `DeleteButton` directly for other layouts.
30
+ *
31
+ * @example
32
+ * <OrderedList.Root items={…} isItem={…} getId={…} onMove={…} expandedId={…} onExpandedChange={…}>
33
+ * {({ items }) => (
34
+ * <OrderedList.Content>
35
+ * {items.map((item) => (
36
+ * <OrderedList.DetailItem
37
+ * key={item.id}
38
+ * id={item.id}
39
+ * item={item}
40
+ * title={item.label}
41
+ * trailing={<OrderedList.DeleteButton onClick={…} />}
42
+ * >
43
+ * {detail}
44
+ * </OrderedList.DetailItem>
45
+ * ))}
46
+ * </OrderedList.Content>
47
+ * )}
48
+ * </OrderedList.Root>
49
+ */
50
+ export const OrderedList = {
51
+ Root: OrderedListRoot,
52
+ Viewport: OrderedListViewport,
53
+ Content: OrderedListContent,
54
+ Item: OrderedListItem,
55
+ DetailItem: OrderedListDetailItem,
56
+ DragHandle: OrderedListDragHandle,
57
+ Title: OrderedListTitle,
58
+ IconButton: OrderedListIconButton,
59
+ DeleteButton: OrderedListDeleteButton,
60
+ ExpandCaret: OrderedListExpandCaret,
61
+ };
62
+
63
+ export type { OrderedListRootProps, OrderedListItemProps, OrderedListDetailItemProps, OrderedListViewportProps };