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

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 +628 -167
  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 +628 -167
  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/useListDisclosure.d.ts +4 -4
  8. package/dist/types/src/aspects/useListDisclosure.d.ts.map +1 -1
  9. package/dist/types/src/aspects/useListNavigation.d.ts +9 -7
  10. package/dist/types/src/aspects/useListNavigation.d.ts.map +1 -1
  11. package/dist/types/src/aspects/useListSelection.d.ts +3 -3
  12. package/dist/types/src/aspects/useListSelection.d.ts.map +1 -1
  13. package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
  14. package/dist/types/src/components/Accordion/AccordionItem.d.ts +3 -1
  15. package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
  16. package/dist/types/src/components/Combobox/Combobox.d.ts +10 -3
  17. package/dist/types/src/components/Combobox/Combobox.d.ts.map +1 -1
  18. package/dist/types/src/components/DropIndicator/DropIndicator.d.ts +12 -0
  19. package/dist/types/src/components/DropIndicator/DropIndicator.d.ts.map +1 -0
  20. package/dist/types/src/components/DropIndicator/index.d.ts +2 -0
  21. package/dist/types/src/components/DropIndicator/index.d.ts.map +1 -0
  22. package/dist/types/src/components/Empty/Empty.d.ts +17 -0
  23. package/dist/types/src/components/Empty/Empty.d.ts.map +1 -0
  24. package/dist/types/src/components/Empty/Empty.stories.d.ts +18 -0
  25. package/dist/types/src/components/Empty/Empty.stories.d.ts.map +1 -0
  26. package/dist/types/src/components/Empty/index.d.ts +2 -0
  27. package/dist/types/src/components/Empty/index.d.ts.map +1 -0
  28. package/dist/types/src/components/List.theme.d.ts +193 -0
  29. package/dist/types/src/components/List.theme.d.ts.map +1 -0
  30. package/dist/types/src/components/Listbox/ListItemContent.d.ts +27 -0
  31. package/dist/types/src/components/Listbox/ListItemContent.d.ts.map +1 -0
  32. package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts +24 -0
  33. package/dist/types/src/components/Listbox/ListItemContent.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Listbox/Listbox.d.ts +19 -2
  35. package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
  36. package/dist/types/src/components/Listbox/Listbox.stories.d.ts +4 -0
  37. package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/OrderedList/OrderedList.d.ts +1 -1
  39. package/dist/types/src/components/OrderedList/OrderedList.d.ts.map +1 -1
  40. package/dist/types/src/components/OrderedList/OrderedListItem.d.ts.map +1 -1
  41. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts +1 -1
  42. package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts.map +1 -1
  43. package/dist/types/src/components/Picker/Picker.d.ts +12 -4
  44. package/dist/types/src/components/Picker/Picker.d.ts.map +1 -1
  45. package/dist/types/src/components/Picker/index.d.ts +1 -1
  46. package/dist/types/src/components/Picker/index.d.ts.map +1 -1
  47. package/dist/types/src/components/Tree/Tree.d.ts +1 -1
  48. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  49. package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -2
  50. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  51. package/dist/types/src/components/Tree/TreeContext.d.ts +2 -0
  52. package/dist/types/src/components/Tree/TreeContext.d.ts.map +1 -1
  53. package/dist/types/src/components/Tree/TreeDropIndicator.d.ts +10 -0
  54. package/dist/types/src/components/Tree/TreeDropIndicator.d.ts.map +1 -0
  55. package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
  56. package/dist/types/src/components/Tree/testing.d.ts +4 -1
  57. package/dist/types/src/components/Tree/testing.d.ts.map +1 -1
  58. package/dist/types/src/components/Treegrid/Treegrid.d.ts +33 -0
  59. package/dist/types/src/components/Treegrid/Treegrid.d.ts.map +1 -0
  60. package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts +11 -0
  61. package/dist/types/src/components/Treegrid/Treegrid.stories.d.ts.map +1 -0
  62. package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts +37 -0
  63. package/dist/types/src/components/Treegrid/Treegrid.theme.d.ts.map +1 -0
  64. package/dist/types/src/components/Treegrid/index.d.ts +2 -0
  65. package/dist/types/src/components/Treegrid/index.d.ts.map +1 -0
  66. package/dist/types/src/components/index.d.ts +3 -0
  67. package/dist/types/src/components/index.d.ts.map +1 -1
  68. package/dist/types/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +23 -22
  70. package/src/aspects/useListDisclosure.ts +8 -8
  71. package/src/aspects/useListNavigation.ts +9 -7
  72. package/src/aspects/useListSelection.ts +4 -4
  73. package/src/components/Accordion/AccordionItem.tsx +31 -13
  74. package/src/components/Combobox/Combobox.tsx +46 -38
  75. package/src/components/DropIndicator/DropIndicator.tsx +74 -0
  76. package/src/components/DropIndicator/index.ts +5 -0
  77. package/src/components/Empty/Empty.stories.tsx +37 -0
  78. package/src/components/Empty/Empty.tsx +41 -0
  79. package/src/components/Empty/index.ts +5 -0
  80. package/src/components/List.theme.ts +99 -0
  81. package/src/components/Listbox/ListItemContent.stories.tsx +41 -0
  82. package/src/components/Listbox/ListItemContent.tsx +49 -0
  83. package/src/components/Listbox/Listbox.stories.tsx +106 -7
  84. package/src/components/Listbox/Listbox.tsx +85 -42
  85. package/src/components/OrderedList/OrderedList.tsx +1 -1
  86. package/src/components/OrderedList/OrderedListItem.tsx +13 -13
  87. package/src/components/OrderedList/OrderedListRoot.tsx +6 -3
  88. package/src/components/Picker/Picker.tsx +25 -15
  89. package/src/components/Picker/index.ts +1 -1
  90. package/src/components/Tree/Tree.stories.tsx +39 -15
  91. package/src/components/Tree/Tree.tsx +13 -3
  92. package/src/components/Tree/TreeContext.tsx +2 -0
  93. package/src/components/Tree/TreeDropIndicator.tsx +72 -0
  94. package/src/components/Tree/TreeItem.tsx +43 -3
  95. package/src/components/Tree/TreeItemHeading.tsx +2 -2
  96. package/src/components/Tree/testing.ts +19 -14
  97. package/src/components/Treegrid/Treegrid.stories.tsx +132 -0
  98. package/src/components/Treegrid/Treegrid.theme.ts +38 -0
  99. package/src/components/Treegrid/Treegrid.tsx +187 -0
  100. package/src/components/Treegrid/index.ts +5 -0
  101. package/src/components/index.ts +3 -0
@@ -0,0 +1,99 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { tv } from '@dxos/ui-theme';
6
+
7
+ /**
8
+ * Tailwind-variants theme for all react-ui-list components.
9
+ * Consumers call `listTheme.styles()` to get the slot functions.
10
+ * Each slot function accepts an optional `{ class: ... }` override for per-instance merging.
11
+ *
12
+ * Structural pattern shared across list components:
13
+ * - viewport: ScrollArea wrapper (`dx-container`)
14
+ * - content: flex-col item container
15
+ * - item: base row (interactive affordances come from dx-hover / dx-selected / dx-current)
16
+ */
17
+ const listStyles = tv({
18
+ slots: {
19
+ //
20
+ // Accordion
21
+ //
22
+ accordionItem: 'overflow-hidden',
23
+ // Row trigger: spans the full width and pins the trailing caret to the inline-end edge.
24
+ accordionTrigger: 'group flex items-start justify-between gap-2 p-2 dx-focus-ring-inset w-full text-start',
25
+ // Leading / trailing icon wrappers: fixed height so they sit on the centerline of the first
26
+ // content line even when the header spans multiple lines.
27
+ accordionTriggerIcon: 'flex items-center h-6 shrink-0',
28
+ accordionTriggerContent: 'min-w-0 flex-1',
29
+ // Slide animations are driven by Radix Accordion's data-state attribute.
30
+ accordionBody: 'overflow-hidden data-[state=closed]:animate-slide-up data-[state=open]:animate-slide-down',
31
+ accordionBodyContent: 'p-2',
32
+
33
+ //
34
+ // Listbox
35
+ //
36
+ listboxViewport: 'dx-container',
37
+ listboxContent: 'flex flex-col',
38
+ // `dx-selected` pairs with `aria-selected="true"` set per-option (see
39
+ // `ui-theme/src/css/components/state.md`). `outline-none` removes the native focus
40
+ // ring; Tabster / `dx-focus-ring` handles keyboard focus at the container level.
41
+ listboxItem: 'flex items-center dx-hover dx-selected px-3 py-2 cursor-pointer outline-none',
42
+ listboxItemLabel: 'grow truncate',
43
+
44
+ //
45
+ // OrderedList
46
+ //
47
+ orderedListViewport: 'dx-container',
48
+ orderedListContent: 'flex flex-col',
49
+ // `dx-current` enables `aria-current` row styling (not listbox/option semantics).
50
+ orderedListItem: 'relative dx-current',
51
+ // Bordered column wrapping title + detail panel in the master-detail layout.
52
+ orderedListDetailColumn: 'flex flex-col ring-1 ring-subdued-separator rounded-sm overflow-hidden',
53
+ // `min-h` matches the shared rail-item track so handles, title, and caret share a baseline.
54
+ orderedListDetailTitleRow: 'flex items-center min-h-[var(--dx-rail-item)]',
55
+ orderedListDetailPanel: 'px-2 pb-2',
56
+ orderedListTitle: 'flex grow items-center truncate cursor-pointer',
57
+
58
+ //
59
+ // Picker
60
+ //
61
+ // `px-[var(--gutter,…)]` aligns padding with sibling `Column.Center` content, falling back to
62
+ // 0.75rem when not nested under `Column.Root`.
63
+ pickerItem: 'dx-hover dx-selected px-[var(--gutter,0.75rem)] py-1 cursor-pointer select-none',
64
+
65
+ //
66
+ // Combobox
67
+ //
68
+ // `m-form-chrome mb-0` mirrors the rest of the form-chrome padding convention.
69
+ comboboxInput: 'm-form-chrome mb-0 w-[calc(100%-2*var(--spacing-form-chrome))]',
70
+ comboboxList: 'py-form-chrome',
71
+ // Trigger value / placeholder text — grows and truncates; subdued when placeholder.
72
+ comboboxTriggerText: 'font-normal text-start flex-1 min-w-0 truncate me-2',
73
+ // Item row adds flex layout; `dx-hover`/`dx-selected` and padding come from `Picker.Item`.
74
+ comboboxItem: 'flex w-full gap-2 items-center',
75
+ comboboxItemDescription: 'text-sm text-description truncate',
76
+
77
+ //
78
+ // ItemContent
79
+ //
80
+ // Two-column grid: fixed rail-item icon track + flexible content track.
81
+ itemContentRoot: 'grid grid-cols-[var(--dx-rail-item)_minmax(0,1fr)] items-center gap-x-2 is-full min-is-0',
82
+ itemContentIcon: 'col-start-1 row-start-1 place-self-center',
83
+ itemContentTitle: 'col-start-2 row-start-1 min-is-0 truncate',
84
+ itemContentDescription: 'col-start-2 row-start-2 min-is-0 truncate text-sm text-description',
85
+
86
+ //
87
+ // Empty
88
+ //
89
+ empty: 'flex flex-col items-center justify-center gap-2 p-4 text-sm text-center text-description',
90
+ },
91
+ });
92
+
93
+ /** react-ui-list theme: call `.styles()` to get per-slot class functions. */
94
+ export const listTheme = {
95
+ styles: listStyles,
96
+ };
97
+
98
+ /** Slot names of {@link listTheme.styles}, for `bridgeTv` registration. */
99
+ export const listSlots = Object.keys(listTheme.styles()) as Array<keyof ReturnType<typeof listTheme.styles>>;
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React from 'react';
7
+
8
+ import { Icon } from '@dxos/react-ui';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { ListItemContent } from './ListItemContent';
12
+
13
+ const meta = {
14
+ title: 'ui/react-ui-list/ItemContent',
15
+ component: ListItemContent,
16
+ render: (args) => (
17
+ <div className='flex flex-col divide-y divide-subdued-separator'>
18
+ <ListItemContent {...args} classNames='p-2' />
19
+ <ListItemContent icon='ph--clock--regular' title='Title only, no description' classNames='p-2' />
20
+ <ListItemContent
21
+ icon={<Icon icon='ph--x-circle--regular' size={5} classNames='text-error-text' />}
22
+ title='Failed run'
23
+ description='Failed · 2.6s'
24
+ classNames='p-2'
25
+ />
26
+ </div>
27
+ ),
28
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
29
+ } satisfies Meta<typeof ListItemContent>;
30
+
31
+ export default meta;
32
+
33
+ type Story = StoryObj<typeof meta>;
34
+
35
+ export const Default: Story = {
36
+ args: {
37
+ icon: <Icon icon='ph--check-circle--regular' size={5} classNames='text-success-text' />,
38
+ title: '6/26/2026, 5:00:00 AM',
39
+ description: 'Success · 2.5s',
40
+ },
41
+ };
@@ -0,0 +1,49 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { type ReactElement, type ReactNode, forwardRef } from 'react';
6
+
7
+ import { Icon, type ThemedClassName } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/ui-theme';
9
+
10
+ import { listTheme } from '../List.theme';
11
+
12
+ /**
13
+ * Presentational row layout: a rail-sized leading icon centered on the primary line, with an
14
+ * optional secondary line aligned beneath the title in the content column. Drop inside a
15
+ * `Listbox.Item`, an `Accordion.ItemHeader`, or any row container.
16
+ *
17
+ * The grid uses the same `var(--dx-rail-item)` rail track as `useListGrid`, so an adjacent body
18
+ * (e.g. an `Accordion.ItemBody`) can reuse `grid-cols-[var(--dx-rail-item)_1fr]` to line its
19
+ * content up under the same content column.
20
+ */
21
+ const styles = listTheme.styles();
22
+
23
+ export type ListItemContentProps = ThemedClassName<{
24
+ /**
25
+ * Leading icon: an icon name (rendered as a neutral size-5 `Icon`) or a custom `Icon` element
26
+ * carrying its own size/colour (e.g. `<Icon icon='…' classNames='text-success-text' />`).
27
+ */
28
+ icon?: string | ReactElement;
29
+ /** Primary line. */
30
+ title: ReactNode;
31
+ /** Optional secondary line, aligned under the title in the content column. */
32
+ description?: ReactNode;
33
+ }>;
34
+
35
+ export const ListItemContent = forwardRef<HTMLDivElement, ListItemContentProps>(
36
+ ({ classNames, icon, title, description }, forwardedRef) => (
37
+ <div ref={forwardedRef} className={styles.itemContentRoot({ class: mx(classNames) })}>
38
+ {icon != null && (
39
+ <div className={styles.itemContentIcon()}>
40
+ {typeof icon === 'string' ? <Icon icon={icon} size={5} /> : icon}
41
+ </div>
42
+ )}
43
+ <span className={styles.itemContentTitle()}>{title}</span>
44
+ {description != null && <span className={styles.itemContentDescription()}>{description}</span>}
45
+ </div>
46
+ ),
47
+ );
48
+
49
+ ListItemContent.displayName = 'ItemContent';
@@ -6,9 +6,11 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import React, { useState } from 'react';
7
7
 
8
8
  import { random } from '@dxos/random';
9
- import { Input, Panel, Toolbar } from '@dxos/react-ui';
9
+ import { Icon, Input, Panel, Toolbar } from '@dxos/react-ui';
10
10
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
+ import { mx } from '@dxos/ui-theme';
11
12
 
13
+ import { useListDisclosure } from '../../aspects';
12
14
  import { Listbox } from './Listbox';
13
15
 
14
16
  random.seed(1);
@@ -157,7 +159,7 @@ const popoverOptions: Option[] = random.helpers.multiple(
157
159
  const PopoverStory = () => {
158
160
  const [selected, setSelected] = useState<string | undefined>(popoverOptions[0]?.value);
159
161
  return (
160
- <div className='max-w-xs p-2 ring-1 ring-subdued-separator rounded'>
162
+ <div className='max-w-xs p-2 border-1 border-subdued-separator rounded'>
161
163
  <Listbox.Root value={selected} onValueChange={setSelected}>
162
164
  <Listbox.Content aria-label='Models'>
163
165
  {popoverOptions.map((option) => (
@@ -177,6 +179,70 @@ const PopoverStory = () => {
177
179
  );
178
180
  };
179
181
 
182
+ //
183
+ // Plain (non-selectable) — no value model on Root, so rows render as `role=list`/`listitem`
184
+ // with hover but no `aria-selected`. This is the styled-content-list mode that replaces the
185
+ // deprecated `@dxos/react-ui` `List`/`ListItem`.
186
+ //
187
+
188
+ const PlainStory = () => (
189
+ <Listbox.Root>
190
+ <Listbox.Viewport>
191
+ <Listbox.Content aria-label='Items'>
192
+ {allItems.slice(0, 6).map((item) => (
193
+ <Listbox.Item key={item.id} id={item.id}>
194
+ <Listbox.ItemLabel>{item.name}</Listbox.ItemLabel>
195
+ </Listbox.Item>
196
+ ))}
197
+ </Listbox.Content>
198
+ </Listbox.Viewport>
199
+ </Listbox.Root>
200
+ );
201
+
202
+ //
203
+ // Disclosure — expandable rows showing icon + title, each with a single full-row header
204
+ // button (icon + title + caret on one line) that toggles a description panel via the
205
+ // `useListDisclosure` aspect. Plain (non-selectable) list, so the header button is the only
206
+ // focusable element per row — arrow keys move row-to-row, not into the caret.
207
+ //
208
+
209
+ const DisclosureStory = () => {
210
+ const disclosure = useListDisclosure({ mode: 'multi' });
211
+ return (
212
+ <Listbox.Root>
213
+ <Listbox.Viewport>
214
+ <Listbox.Content aria-label='Items'>
215
+ {allItems.slice(0, 8).map((item) => {
216
+ const { expanded, triggerProps, panelProps } = disclosure.bind(item.id);
217
+ return (
218
+ <Listbox.Item key={item.id} id={item.id} classNames='flex-col items-stretch p-0'>
219
+ <button
220
+ {...triggerProps}
221
+ type='button'
222
+ className='flex items-center gap-2 px-3 py-2 text-start dx-hover dx-focus-ring-inset'
223
+ >
224
+ <Icon icon='ph--package--regular' size={5} classNames='shrink-0' />
225
+ <span className='flex-1 min-w-0 truncate'>{item.name}</span>
226
+ <Icon
227
+ icon='ph--caret-right--regular'
228
+ size={4}
229
+ classNames={mx('shrink-0 transition-transform', expanded && 'rotate-90')}
230
+ />
231
+ </button>
232
+ {expanded && (
233
+ <div {...panelProps} className='ps-[var(--dx-rail-item)] px-3 pb-2 text-sm text-description'>
234
+ {item.description}
235
+ </div>
236
+ )}
237
+ </Listbox.Item>
238
+ );
239
+ })}
240
+ </Listbox.Content>
241
+ </Listbox.Viewport>
242
+ </Listbox.Root>
243
+ );
244
+ };
245
+
180
246
  const meta = {
181
247
  title: 'ui/react-ui-list/Listbox',
182
248
  render: (args) => <DefaultStory {...args} />,
@@ -191,8 +257,41 @@ export default meta;
191
257
  type Story = StoryObj<StoryArgs>;
192
258
 
193
259
  export const Default: Story = {};
194
- export const Thin: Story = { args: { thin: true, padding: true, showDescription: false } };
195
- export const WithDisabled: Story = { args: { items: allItems.slice(0, 6), disabledIndex: 2 } };
196
- export const MasterDetail: Story = { render: () => <MasterDetailStory /> };
197
- export const WithToolbar: Story = { render: () => <WithToolbarStory /> };
198
- export const Popover: Story = { render: () => <PopoverStory /> };
260
+
261
+ export const Thin: Story = {
262
+ args: {
263
+ thin: true,
264
+ padding: true,
265
+ showDescription: false,
266
+ },
267
+ };
268
+
269
+ export const WithDisabled: Story = {
270
+ args: {
271
+ items: allItems.slice(0, 6),
272
+ disabledIndex: 2,
273
+ },
274
+ };
275
+
276
+ export const MasterDetail: Story = {
277
+ render: () => <MasterDetailStory />,
278
+ };
279
+
280
+ export const WithToolbar: Story = {
281
+ render: () => <WithToolbarStory />,
282
+ };
283
+
284
+ export const Popover: Story = {
285
+ decorators: [withTheme(), withLayout({ layout: 'centered' })],
286
+ render: () => <PopoverStory />,
287
+ };
288
+
289
+ /** Non-selectable: opt-out of the selection model — plain styled rows (role=list/listitem). */
290
+ export const Plain: Story = {
291
+ render: () => <PlainStory />,
292
+ };
293
+
294
+ /** Items with icon + title + description and a per-row expand caret (disclosure aspect). */
295
+ export const Disclosure: Story = {
296
+ render: () => <DisclosureStory />,
297
+ };
@@ -2,9 +2,15 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- // `Listbox` — single-select selectable list. Single canonical compound for the picker /
6
- // option-list pattern: full-pane (with `Listbox.Viewport` ScrollArea wrapper) and compact
7
- // popover (no Viewport) usage share the same shape and selection model.
5
+ // `Listbox` — the package's general styled list, with **opt-in selection**. One compound for
6
+ // both the picker / option-list pattern (full-pane with `Listbox.Viewport`, or compact popover
7
+ // without) and plain styled content rows.
8
+ //
9
+ // Selection is inferred from props: pass `value`/`defaultValue`/`onValueChange` on `Root` and
10
+ // it's a single-select `role=listbox` (options carry `aria-selected` + `dx-selected`, selection
11
+ // follows focus); omit them and it's a plain `role=list` of `role=listitem` rows (hover only, no
12
+ // selection semantics) — the shape the deprecated `@dxos/react-ui` `List`/`ListItem` filled.
13
+ // (`Listbox` is slated to be renamed `List` once that legacy component is deleted.)
8
14
  //
9
15
  // Compound shape (matches Radix Select / Toolbar / Tabs):
10
16
  //
@@ -27,7 +33,7 @@
27
33
  // - `Content` — the `<ul role='listbox'>` holding the items. Applies the navigation aspect's
28
34
  // container props (Tabster arrow nav, focus-on-entry redirect, role + aria-orientation).
29
35
  // - `Item` — `<li role='option'>` with `aria-selected` on the selected row, paired with
30
- // `dx-selected` styling. See `ui-theme/src/css/components/selected.md`.
36
+ // `dx-selected` styling. See `ui-theme/src/css/components/state.md`.
31
37
  // - `ItemLabel` — text helper that truncates and takes most of the row width.
32
38
  // - `Indicator` — optional checkmark icon next to the selected item (confirmatory, since
33
39
  // `dx-selected` already styles the row).
@@ -70,6 +76,10 @@ import {
70
76
  useListNavigation,
71
77
  useListSelection,
72
78
  } from '../../aspects';
79
+ import { listTheme } from '../List.theme';
80
+ import { ListItemContent, type ListItemContentProps } from './ListItemContent';
81
+
82
+ const styles = listTheme.styles();
73
83
 
74
84
  const LISTBOX_NAME = 'Listbox';
75
85
  const LISTBOX_ROOT_NAME = 'Listbox.Root';
@@ -86,6 +96,12 @@ const LISTBOX_INDICATOR_NAME = 'Listbox.Indicator';
86
96
  //
87
97
 
88
98
  type ListboxContextValue = {
99
+ /**
100
+ * Whether the list participates in selection. Inferred on `Root` from the presence of
101
+ * `value`/`defaultValue`/`onValueChange`. Drives `role` (listbox/option vs list/listitem),
102
+ * `aria-selected`, and whether row clicks update the selection model.
103
+ */
104
+ selectable: boolean;
89
105
  /** Selection aspect binding factory; items consume their own bindings from this. */
90
106
  selection: UseListSelectionReturn;
91
107
  };
@@ -103,7 +119,10 @@ const [ListboxItemProvider, useListboxItemContext] = createContext<ListboxItemCo
103
119
  //
104
120
 
105
121
  type RootProps = PropsWithChildren<{
106
- /** Currently-selected option id (controlled). */
122
+ /**
123
+ * Currently-selected option id (controlled). Supplying any of `value`/`defaultValue`/
124
+ * `onValueChange` makes the list selectable; omitting all three renders plain rows.
125
+ */
107
126
  value?: string;
108
127
  /** Initial selected option for uncontrolled mode. */
109
128
  defaultValue?: string;
@@ -119,9 +138,15 @@ type RootProps = PropsWithChildren<{
119
138
  }>;
120
139
 
121
140
  const Root = ({ value, defaultValue, onValueChange, autoFocus: _autoFocus, children }: RootProps) => {
122
- // The selection aspect emits `string | undefined` because `useListSelection` is mode-
123
- // generic; in single-select the value only clears when the consumer drives it, never from
124
- // a row click. Filter to keep the public callback narrow.
141
+ // Selection is opt-in: a list is selectable only when the consumer wires the value model.
142
+ // Plain content lists (the migrated `@dxos/react-ui` `List` call sites) pass none of these
143
+ // and render as `role=list`/`listitem` rows.
144
+ const selectable = value !== undefined || defaultValue !== undefined || onValueChange !== undefined;
145
+
146
+ // `useListSelection` is a hook, so it is always called (stable hook order); `selectable`
147
+ // gates whether items actually consume the binding. The aspect emits `string | undefined`
148
+ // because it is mode-generic; in single-select the value only clears when the consumer
149
+ // drives it, never from a row click — filter to keep the public callback narrow.
125
150
  const selection = useListSelection({
126
151
  mode: 'single',
127
152
  value,
@@ -133,7 +158,7 @@ const Root = ({ value, defaultValue, onValueChange, autoFocus: _autoFocus, child
133
158
  },
134
159
  });
135
160
 
136
- const context = useMemo(() => ({ selection }), [selection]);
161
+ const context = useMemo(() => ({ selectable, selection }), [selectable, selection]);
137
162
 
138
163
  return <ListboxProvider {...context}>{children}</ListboxProvider>;
139
164
  };
@@ -154,7 +179,7 @@ const Viewport = composable<HTMLDivElement, ViewportProps>((props, forwardedRef)
154
179
  >;
155
180
  return (
156
181
  <ScrollArea.Root
157
- {...composableProps<HTMLDivElement>(rest, { classNames: 'dx-container' })}
182
+ {...composableProps<HTMLDivElement>(rest, { classNames: styles.listboxViewport() })}
158
183
  {...{ thin, padding, centered }}
159
184
  orientation='vertical'
160
185
  ref={forwardedRef}
@@ -179,12 +204,12 @@ type ContentProps = {
179
204
  };
180
205
 
181
206
  const Content = composable<HTMLUListElement, ContentProps>((props, forwardedRef) => {
182
- // Touch the context so Content fails loudly if used outside Root.
183
- useListboxContext(LISTBOX_CONTENT_NAME);
207
+ const { selectable } = useListboxContext(LISTBOX_CONTENT_NAME);
184
208
 
185
- // `useListNavigation` bundles role=listbox, aria-orientation, Tabster arrow nav, and the
186
- // focus-on-entry redirect (to selected, then first non-disabled option).
187
- const navigation = useListNavigation({ mode: 'listbox' });
209
+ // `useListNavigation` bundles role + aria-orientation + Tabster arrow nav. In `listbox` mode
210
+ // it also adds the focus-on-entry redirect (to selected, then first non-disabled option);
211
+ // `list` mode is for the non-selectable rows (arrow nav across interactive descendants only).
212
+ const navigation = useListNavigation({ mode: selectable ? 'listbox' : 'list' });
188
213
 
189
214
  const { children, ...rest } = props as PropsWithChildren<ContentProps & Record<string, unknown>>;
190
215
 
@@ -192,7 +217,7 @@ const Content = composable<HTMLUListElement, ContentProps>((props, forwardedRef)
192
217
  // context-scope check. The container's role/aria/Tabster wiring comes from the navigation
193
218
  // aspect rather than the primitive's `selectable` plumbing — that keeps the ARIA grammar
194
219
  // (`aria-selected`) owned by `Item` below.
195
- const composed = composableProps<HTMLUListElement>(rest, { classNames: 'flex flex-col' });
220
+ const composed = composableProps<HTMLUListElement>(rest, { classNames: styles.listboxContent() });
196
221
  return (
197
222
  <List
198
223
  variant='unordered'
@@ -220,55 +245,70 @@ type ItemProps = PropsWithChildren<{
220
245
  onClick?: (event: MouseEvent<HTMLLIElement>) => void;
221
246
  /** Optional focus handler in addition to selection-follows-focus. */
222
247
  onFocus?: (event: FocusEvent<HTMLLIElement>) => void;
248
+ /**
249
+ * Optional pointer-down handler. Fires before focus (and therefore before selection-follows-focus
250
+ * mutates the value), so consumers can observe the pre-gesture selection — e.g. to implement
251
+ * click-to-toggle without the focus-then-click double count.
252
+ */
253
+ onMouseDown?: (event: MouseEvent<HTMLLIElement>) => void;
223
254
  }>;
224
255
 
225
- // `dx-selected` pairs with `aria-selected="true"` (set per-option below); see
226
- // `ui-theme/src/css/components/selected.md`.
227
- const ITEM_BASE = 'flex items-center dx-hover dx-selected px-3 py-2 cursor-pointer outline-none';
228
-
229
256
  const Item = composable<HTMLLIElement, ItemProps>((props, forwardedRef) => {
230
- const { id, disabled, onClick, onFocus, children, ...rest } = props as ItemProps & Record<string, unknown>;
231
- const { selection } = useListboxContext(LISTBOX_ITEM_NAME);
257
+ const { id, disabled, onClick, onFocus, onMouseDown, children, ...rest } = props as ItemProps &
258
+ Record<string, unknown>;
259
+ const { selectable, selection } = useListboxContext(LISTBOX_ITEM_NAME);
232
260
  const binding: SelectionItemBinding = selection.bind(id, { disabled });
233
-
234
- // Compose the selection aspect's click/focus handlers with the row's optional ones so
235
- // both wire-ups stay synchronized: selection happens before user code so a click that
236
- // also runs imperative side effects sees the selected value first.
261
+ const selected = selectable && binding.selected;
262
+ // A non-selectable row is interactive only if the caller wired a click; otherwise it's a
263
+ // plain display row (no pointer affordance).
264
+ const interactive = selectable || onClick != null;
265
+
266
+ // Compose the selection aspect's click/focus handlers with the row's optional ones so both
267
+ // wire-ups stay synchronized: selection happens before user code so a click that also runs
268
+ // imperative side effects sees the selected value first. Skipped entirely when not selectable
269
+ // so a plain row click doesn't mutate hidden selection state.
237
270
  const handleClick = useCallback(
238
271
  (event: MouseEvent<HTMLLIElement>) => {
239
- binding.rowProps.onClick(event);
272
+ if (selectable) {
273
+ binding.rowProps.onClick(event);
274
+ }
240
275
  if (!disabled) {
241
276
  onClick?.(event);
242
277
  }
243
278
  },
244
- [binding, disabled, onClick],
279
+ [selectable, binding, disabled, onClick],
245
280
  );
246
281
 
247
282
  const handleFocus = useCallback(
248
283
  (event: FocusEvent<HTMLLIElement>) => {
249
- binding.rowProps.onFocus?.(event);
284
+ if (selectable) {
285
+ binding.rowProps.onFocus?.(event);
286
+ }
250
287
  onFocus?.(event);
251
288
  },
252
- [binding, onFocus],
289
+ [selectable, binding, onFocus],
253
290
  );
254
291
 
255
292
  const composed = composableProps<HTMLLIElement>(rest, {
256
- classNames: [ITEM_BASE, disabled && 'opacity-50 cursor-not-allowed'],
293
+ classNames: styles.listboxItem({
294
+ class: [!interactive && 'cursor-default', disabled && 'opacity-50 cursor-not-allowed'],
295
+ }),
257
296
  });
258
297
 
259
298
  // Per WAI-ARIA APG listbox guidance, disabled options remain keyboard-navigable for SR
260
- // announcement; the selection model is not updated for disabled rows (the aspect's
261
- // binding enforces that internally).
299
+ // announcement; the selection model is not updated for disabled rows (the aspect's binding
300
+ // enforces that internally). Non-selectable rows are `role=listitem` with no `aria-selected`.
262
301
  return (
263
- <ListItemProviderHost id={id} selected={binding.selected}>
302
+ <ListItemProviderHost id={id} selected={selected}>
264
303
  <ListItem
265
304
  {...composed}
266
- role='option'
267
- tabIndex={0}
268
- aria-selected={binding.selected}
305
+ role={selectable ? 'option' : 'listitem'}
306
+ tabIndex={selectable ? 0 : -1}
307
+ aria-selected={selectable ? selected : undefined}
269
308
  aria-disabled={disabled || undefined}
270
309
  onClick={handleClick}
271
310
  onFocus={handleFocus}
311
+ onMouseDown={onMouseDown}
272
312
  ref={forwardedRef}
273
313
  >
274
314
  {children}
@@ -297,7 +337,7 @@ const ListItemProviderHost = ({ id, selected, children }: PropsWithChildren<List
297
337
  type ItemLabelProps = ThemedClassName<ComponentPropsWithRef<'span'>>;
298
338
 
299
339
  const ItemLabel = forwardRef<HTMLSpanElement, ItemLabelProps>(({ classNames, children, ...rest }, forwardedRef) => (
300
- <span {...rest} className={mx('grow truncate', classNames)} ref={forwardedRef}>
340
+ <span {...rest} className={styles.listboxItemLabel({ class: mx(classNames) })} ref={forwardedRef}>
301
341
  {children}
302
342
  </span>
303
343
  ));
@@ -344,15 +384,18 @@ const Listbox = {
344
384
  Content,
345
385
  Item,
346
386
  ItemLabel,
387
+ ItemContent: ListItemContent,
347
388
  Indicator,
348
389
  };
349
390
 
350
391
  export { Listbox, useListboxSelection };
392
+
351
393
  export type {
352
- RootProps as ListboxRootProps,
353
- ViewportProps as ListboxViewportProps,
394
+ ListItemContentProps as ItemContentProps,
354
395
  ContentProps as ListboxContentProps,
355
- ItemProps as ListboxItemProps,
356
- ItemLabelProps as ListboxItemLabelProps,
357
396
  IndicatorProps as ListboxIndicatorProps,
397
+ ItemLabelProps as ListboxItemLabelProps,
398
+ ItemProps as ListboxItemProps,
399
+ RootProps as ListboxRootProps,
400
+ ViewportProps as ListboxViewportProps,
358
401
  };
@@ -60,4 +60,4 @@ export const OrderedList = {
60
60
  ExpandCaret: OrderedListExpandCaret,
61
61
  };
62
62
 
63
- export type { OrderedListRootProps, OrderedListItemProps, OrderedListDetailItemProps, OrderedListViewportProps };
63
+ export type { OrderedListDetailItemProps, OrderedListItemProps, OrderedListRootProps, OrderedListViewportProps };
@@ -17,7 +17,6 @@ import {
17
17
  IconBlock,
18
18
  IconButton,
19
19
  type IconButtonProps,
20
- ListItem as NaturalListItem,
21
20
  type ThemedClassName,
22
21
  ToggleIconButton,
23
22
  useTranslation,
@@ -25,8 +24,12 @@ import {
25
24
  import { mx, osTranslations } from '@dxos/ui-theme';
26
25
 
27
26
  import { useListGrid, useReorderItem } from '../../aspects';
27
+ import { DropIndicator } from '../DropIndicator';
28
+ import { listTheme } from '../List.theme';
28
29
  import { type ListItemRecord, useOrderedListContext } from './OrderedListRoot';
29
30
 
31
+ const styles = listTheme.styles();
32
+
30
33
  const ORDERED_LIST_ITEM_NAME = 'OrderedListItem';
31
34
 
32
35
  type OrderedListItemContextValue = {
@@ -106,15 +109,12 @@ export const OrderedListItem = <T extends ListItemRecord>({
106
109
  style={style}
107
110
  aria-current={selected || undefined}
108
111
  onClick={onClick}
109
- className={mx(
110
- 'relative dx-current',
111
- hover && 'dx-hover',
112
- state.type === 'dragging' && 'opacity-50',
113
- classNames,
114
- )}
112
+ className={styles.orderedListItem({
113
+ class: mx(hover && 'dx-hover', state.type === 'dragging' && 'opacity-50', classNames),
114
+ })}
115
115
  >
116
116
  {children}
117
- {closestEdge && <NaturalListItem.DropIndicator edge={closestEdge} />}
117
+ {closestEdge && <DropIndicator edge={closestEdge} />}
118
118
  </div>
119
119
  </OrderedListItemProvider>
120
120
  );
@@ -169,7 +169,7 @@ export const OrderedListTitle = ({
169
169
  id={triggerProps.id}
170
170
  aria-expanded={triggerProps['aria-expanded']}
171
171
  aria-controls={triggerProps['aria-controls']}
172
- className={mx('flex grow items-center truncate cursor-pointer', classNames)}
172
+ className={styles.orderedListTitle({ class: mx(classNames) })}
173
173
  onClick={handleClick}
174
174
  >
175
175
  {children}
@@ -310,15 +310,15 @@ export const OrderedListDetailItem = <T extends ListItemRecord>({
310
310
  classNames={mx(grid.rowProps.className, 'pb-1', classNames)}
311
311
  >
312
312
  <OrderedListDragHandle />
313
- <div className='flex flex-col ring-1 ring-subdued-separator rounded-sm overflow-hidden'>
314
- <div className='flex items-center min-h-[var(--dx-rail-item)]'>
313
+ <div className={styles.orderedListDetailColumn()}>
314
+ <div className={styles.orderedListDetailTitleRow()}>
315
315
  {expandable ? (
316
316
  <OrderedListTitle classNames={mx('px-2', titleClassNames)}>{title}</OrderedListTitle>
317
317
  ) : (
318
318
  // When the row is not expandable, render a plain (non-toggling) title so a click
319
319
  // doesn't mutate hidden disclosure state. Mirrors `OrderedListTitle`'s structure
320
320
  // minus the trigger plumbing.
321
- <div className={mx('flex grow items-center truncate px-2', titleClassNames)}>{title}</div>
321
+ <div className={styles.orderedListTitle({ class: mx('cursor-auto px-2', titleClassNames) })}>{title}</div>
322
322
  )}
323
323
  {actions}
324
324
  {expandable && <OrderedListExpandCaret />}
@@ -341,7 +341,7 @@ const DetailPanel = ({ children }: PropsWithChildren) => {
341
341
  return null;
342
342
  }
343
343
  return (
344
- <div {...panelProps} className='px-2 pb-2'>
344
+ <div {...panelProps} className={styles.orderedListDetailPanel()}>
345
345
  {children}
346
346
  </div>
347
347
  );