@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
@@ -1,201 +1,358 @@
1
1
  //
2
- // Copyright 2023 DXOS.org
2
+ // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- // `Listbox` — single-select listbox with optional check indicator.
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.
6
8
  //
7
- // Internally composes `RowList` from this same package: `Listbox.Root`
8
- // is `RowList.Root` + `RowList.Content`, and `Listbox.Option` is `Row`.
9
- // The compound API (`Listbox.Root` / `.Option` / `.OptionLabel` /
10
- // `.OptionIndicator`) is preserved so existing call sites keep working.
9
+ // Compound shape (matches Radix Select / Toolbar / Tabs):
11
10
  //
12
- // Why this shape (when `RowList` is right there): `Listbox` historically
13
- // rendered as a flat `<ul>` with no `ScrollArea` wrapper it's used
14
- // inside dialogs / popovers / panels that own their own scroll. Skipping
15
- // `RowList.Viewport` keeps that behaviour. If a caller wants the styled
16
- // scroll surface, they wrap the listbox in `RowList.Viewport` themselves.
17
-
18
- import { type Scope, createContextScope } from '@radix-ui/react-context';
19
- import React, { type ComponentPropsWithRef, type ReactNode, forwardRef } from 'react';
20
-
21
- import { Icon, type IconProps, type ThemedClassName } from '@dxos/react-ui';
11
+ // <Listbox.Root value={…} onValueChange={…}>
12
+ // {/* Viewport is optional include for full-pane pickers, omit for popovers. */}
13
+ // <Listbox.Viewport thin padding>
14
+ // <Listbox.Content aria-label='Tools'>
15
+ // <Listbox.Item id='a'>
16
+ // <Listbox.ItemLabel>Alpha</Listbox.ItemLabel>
17
+ // <Listbox.Indicator />
18
+ // </Listbox.Item>
19
+ // <Listbox.Item id='b'>…</Listbox.Item>
20
+ // </Listbox.Content>
21
+ // </Listbox.Viewport>
22
+ // </Listbox.Root>
23
+ //
24
+ // - `Root` — headless context provider (no DOM). Owns the single-selection `value` model.
25
+ // - `Viewport` — optional `ScrollArea.Root` + `ScrollArea.Viewport`. Always scrolls when
26
+ // present. Forwards ScrollArea knobs (`thin`, `padding`, `centered`).
27
+ // - `Content` — the `<ul role='listbox'>` holding the items. Applies the navigation aspect's
28
+ // container props (Tabster arrow nav, focus-on-entry redirect, role + aria-orientation).
29
+ // - `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`.
31
+ // - `ItemLabel` — text helper that truncates and takes most of the row width.
32
+ // - `Indicator` — optional checkmark icon next to the selected item (confirmatory, since
33
+ // `dx-selected` already styles the row).
34
+ //
35
+ // Selection model: single-select (`value: string | undefined`). Selection follows focus,
36
+ // so arrow keys + click both update it. Matches the codebase's existing
37
+ // `useSelected(_, 'single')` convention from `@dxos/react-ui-attention`.
38
+ //
39
+ // What this layer deliberately does NOT do:
40
+ // - Virtualization or drag-and-drop. Reach for `@dxos/react-ui-mosaic`.
41
+ // - Multi-select. Future expansion — the aspect (`useListSelection`) already supports it.
42
+
43
+ import { createContext } from '@radix-ui/react-context';
44
+ import React, {
45
+ type ComponentPropsWithRef,
46
+ type FocusEvent,
47
+ type ForwardedRef,
48
+ type MouseEvent,
49
+ type PropsWithChildren,
50
+ forwardRef,
51
+ useCallback,
52
+ useMemo,
53
+ } from 'react';
54
+
55
+ import { List, ListItem } from '@dxos/react-list';
56
+ import {
57
+ Icon,
58
+ type IconProps,
59
+ ScrollArea,
60
+ type ScrollAreaRootProps,
61
+ type ThemedClassName,
62
+ composable,
63
+ composableProps,
64
+ } from '@dxos/react-ui';
22
65
  import { mx } from '@dxos/ui-theme';
23
66
 
24
- import { Row, RowList, createRowListScope, useRowListSelection } from '../RowList';
25
-
26
- const commandItem = 'flex items-center overflow-hidden';
67
+ import {
68
+ type SelectionItemBinding,
69
+ type UseListSelectionReturn,
70
+ useListNavigation,
71
+ useListSelection,
72
+ } from '../../aspects';
27
73
 
28
74
  const LISTBOX_NAME = 'Listbox';
29
- const LISTBOX_OPTION_NAME = 'ListboxOption';
30
- const LISTBOX_OPTION_LABEL_NAME = 'ListboxOptionLabel';
31
- const LISTBOX_OPTION_INDICATOR_NAME = 'ListboxOptionIndicator';
75
+ const LISTBOX_ROOT_NAME = 'Listbox.Root';
76
+ const LISTBOX_VIEWPORT_NAME = 'Listbox.Viewport';
77
+ const LISTBOX_CONTENT_NAME = 'Listbox.Content';
78
+ const LISTBOX_ITEM_NAME = 'Listbox.Item';
79
+ const LISTBOX_ITEM_LABEL_NAME = 'Listbox.ItemLabel';
80
+ const LISTBOX_INDICATOR_NAME = 'Listbox.Indicator';
32
81
 
33
82
  //
34
- // Contextonly used to thread `value` through to `OptionIndicator` so
35
- // it knows whether to show the checkmark. Selection state itself lives
36
- // in `RowList`'s context (we delegate to it via composition).
83
+ // Contextsplain Radix contexts (un-scoped). Scoped composition (nested Listboxes,
84
+ // Combobox embeddings) is a future expansion; when needed, switch to `createContextScope`
85
+ // and thread `__listboxScope` through every subcomponent's props in one focused PR.
37
86
  //
38
87
 
39
- type ListboxScopedProps<P> = P & { __listboxScope?: Scope };
40
- type ListboxOptionScopedProps<P> = P & { __listboxOptionScope?: Scope };
41
-
42
- const [createListboxContext, createListboxScope] = createContextScope(LISTBOX_NAME, [createRowListScope]);
43
- const [createListboxOptionContext, createListboxOptionScope] = createContextScope(LISTBOX_OPTION_NAME, [
44
- createListboxScope,
45
- ]);
88
+ type ListboxContextValue = {
89
+ /** Selection aspect binding factory; items consume their own bindings from this. */
90
+ selection: UseListSelectionReturn;
91
+ };
46
92
 
47
- type ListboxOptionContextValue = {
48
- value: string;
49
- isSelected: boolean;
93
+ type ListboxItemContextValue = {
94
+ id: string;
95
+ selected: boolean;
50
96
  };
51
97
 
52
- const [ListboxOptionProvider, useListboxOptionContext] =
53
- createListboxOptionContext<ListboxOptionContextValue>(LISTBOX_OPTION_NAME);
98
+ const [ListboxProvider, useListboxContext] = createContext<ListboxContextValue>(LISTBOX_NAME);
99
+ const [ListboxItemProvider, useListboxItemContext] = createContext<ListboxItemContextValue>(LISTBOX_ITEM_NAME);
54
100
 
55
101
  //
56
- // Root — composes `RowList.Root` + `RowList.Content`.
57
- //
58
- // Maps the public `value` / `onValueChange` API to RowList's
59
- // `selectedId` / `onSelectChange` so existing consumers don't change.
102
+ // Root — headless context provider. Renders no DOM.
60
103
  //
61
104
 
62
- type ListboxRootProps = ThemedClassName<ComponentPropsWithRef<'ul'>> & {
105
+ type RootProps = PropsWithChildren<{
106
+ /** Currently-selected option id (controlled). */
63
107
  value?: string;
108
+ /** Initial selected option for uncontrolled mode. */
64
109
  defaultValue?: string;
110
+ /**
111
+ * Called when the user picks a different option (click, arrow keys, focus). Receives the
112
+ * option's `id` prop. Selection cannot clear to `undefined` from the UI in single-select
113
+ * mode (clicking an already-selected row is a no-op), so the callback always receives a
114
+ * defined id.
115
+ */
65
116
  onValueChange?: (value: string) => void;
66
- /** Reserved autoFocus on mount. RowList's focus-on-entry covers the typical case. */
117
+ /** Reserved for parity with the prior `Listbox.Root`; focus-on-entry already covers most cases. */
67
118
  autoFocus?: boolean;
119
+ }>;
120
+
121
+ 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.
125
+ const selection = useListSelection({
126
+ mode: 'single',
127
+ value,
128
+ defaultValue,
129
+ onValueChange: (next) => {
130
+ if (next !== undefined) {
131
+ onValueChange?.(next);
132
+ }
133
+ },
134
+ });
135
+
136
+ const context = useMemo(() => ({ selection }), [selection]);
137
+
138
+ return <ListboxProvider {...context}>{children}</ListboxProvider>;
68
139
  };
69
140
 
70
- const ListboxRoot = forwardRef<HTMLUListElement, ListboxRootProps>(
71
- (props: ListboxScopedProps<ListboxRootProps>, forwardedRef) => {
72
- const {
73
- __listboxScope: _scope,
74
- children,
75
- classNames,
76
- value,
77
- defaultValue,
78
- onValueChange,
79
- autoFocus: _autoFocus,
80
- ...rootProps
81
- } = props;
82
-
83
- return (
84
- <RowList.Root selectedId={value} defaultSelectedId={defaultValue} onSelectChange={onValueChange}>
85
- <RowList.Content {...rootProps} classNames={mx('w-full', classNames)} ref={forwardedRef}>
86
- {children}
87
- </RowList.Content>
88
- </RowList.Root>
89
- );
90
- },
91
- );
141
+ Root.displayName = LISTBOX_ROOT_NAME;
142
+
143
+ //
144
+ // Viewport — ScrollArea wrapper. Always scrolls; forwards ScrollArea knobs.
145
+ //
146
+ // Optional — popover/dialog consumers can skip it and provide their own scroll container.
147
+ //
148
+
149
+ type ViewportProps = Pick<ScrollAreaRootProps, 'thin' | 'padding' | 'centered'>;
150
+
151
+ const Viewport = composable<HTMLDivElement, ViewportProps>((props, forwardedRef) => {
152
+ const { thin, padding, centered, children, ...rest } = props as PropsWithChildren<
153
+ ViewportProps & Record<string, unknown>
154
+ >;
155
+ return (
156
+ <ScrollArea.Root
157
+ {...composableProps<HTMLDivElement>(rest, { classNames: 'dx-container' })}
158
+ {...{ thin, padding, centered }}
159
+ orientation='vertical'
160
+ ref={forwardedRef}
161
+ >
162
+ <ScrollArea.Viewport>{children}</ScrollArea.Viewport>
163
+ </ScrollArea.Root>
164
+ );
165
+ });
92
166
 
93
- ListboxRoot.displayName = 'Listbox.Root';
167
+ Viewport.displayName = LISTBOX_VIEWPORT_NAME;
94
168
 
95
169
  //
96
- // Optioncomposes `Row`. Adds the listbox-specific styling and
97
- // publishes `{ value, isSelected }` so `OptionIndicator` can render a
98
- // checkmark.
170
+ // Contentthe listbox `<ul>` (Tabster arrow group + aria-label + role).
99
171
  //
100
172
 
101
- type ListboxOptionProps = ThemedClassName<ComponentPropsWithRef<'li'>> & {
102
- value: string;
173
+ type ContentProps = {
174
+ /**
175
+ * Accessible label for the listbox. Strongly recommended; assistive tech announces this
176
+ * when focus enters the list.
177
+ */
178
+ 'aria-label'?: string;
103
179
  };
104
180
 
105
- const ListboxOption = forwardRef<HTMLLIElement, ListboxOptionProps>(
106
- (props: ListboxScopedProps<ListboxOptionProps>, forwardedRef) => {
107
- const { __listboxScope, children, classNames, value, ...rootProps } = props;
108
-
109
- // Selection state is read inside `ListboxOptionProviderHost` via
110
- // the public `useRowListSelection` hook and republished on the
111
- // listbox-option scope so `OptionIndicator` can render its
112
- // checkmark synchronously.
113
- return (
114
- <Row
115
- id={value}
116
- {...rootProps}
117
- classNames={mx('dx-focus-ring rounded-xs', commandItem, classNames)}
118
- ref={forwardedRef}
119
- >
120
- <ListboxOptionProviderHost value={value}>{children}</ListboxOptionProviderHost>
121
- </Row>
122
- );
123
- },
124
- );
181
+ const Content = composable<HTMLUListElement, ContentProps>((props, forwardedRef) => {
182
+ // Touch the context so Content fails loudly if used outside Root.
183
+ useListboxContext(LISTBOX_CONTENT_NAME);
125
184
 
126
- ListboxOption.displayName = 'Listbox.Option';
127
-
128
- // Reads selection state from RowList's context (via `useRowListSelection`)
129
- // and publishes it on the listbox-option scope so `OptionIndicator` can
130
- // render its checkmark. Tiny adapter keeps Listbox's public option API
131
- // intact while delegating the actual state to RowList.
132
- const ListboxOptionProviderHost = ({
133
- value,
134
- children,
135
- }: ListboxScopedProps<{ value: string; children?: ReactNode }>) => {
136
- const isSelected = useRowListSelection(value);
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' });
188
+
189
+ const { children, ...rest } = props as PropsWithChildren<ContentProps & Record<string, unknown>>;
190
+
191
+ // We render via the primitive `<List>` so descendant `<ListItem>`s satisfy their Radix
192
+ // context-scope check. The container's role/aria/Tabster wiring comes from the navigation
193
+ // aspect rather than the primitive's `selectable` plumbing — that keeps the ARIA grammar
194
+ // (`aria-selected`) owned by `Item` below.
195
+ const composed = composableProps<HTMLUListElement>(rest, { classNames: 'flex flex-col' });
137
196
  return (
138
- <ListboxOptionProvider scope={undefined} value={value} isSelected={isSelected}>
197
+ <List
198
+ variant='unordered'
199
+ {...composed}
200
+ {...navigation.containerProps}
201
+ ref={forwardedRef as unknown as ForwardedRef<HTMLOListElement>}
202
+ >
139
203
  {children}
140
- </ListboxOptionProvider>
204
+ </List>
141
205
  );
142
- };
206
+ });
207
+
208
+ Content.displayName = LISTBOX_CONTENT_NAME;
143
209
 
144
210
  //
145
- // OptionLabel
211
+ // Item — option row.
146
212
  //
147
213
 
148
- const ListboxOptionLabel = forwardRef<HTMLDivElement, ThemedClassName<ComponentPropsWithRef<'div'>>>(
149
- ({ children, classNames, ...rootProps }, forwardedRef) => {
150
- return (
151
- <span {...rootProps} className={mx('grow truncate', classNames)} ref={forwardedRef}>
214
+ type ItemProps = PropsWithChildren<{
215
+ /** Stable identifier; matched against the parent's `value`. */
216
+ id: string;
217
+ /** Disable the row — focusable but doesn't update selection, dimmed. */
218
+ disabled?: boolean;
219
+ /** Optional click handler in addition to selection. */
220
+ onClick?: (event: MouseEvent<HTMLLIElement>) => void;
221
+ /** Optional focus handler in addition to selection-follows-focus. */
222
+ onFocus?: (event: FocusEvent<HTMLLIElement>) => void;
223
+ }>;
224
+
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
+ 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);
232
+ 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.
237
+ const handleClick = useCallback(
238
+ (event: MouseEvent<HTMLLIElement>) => {
239
+ binding.rowProps.onClick(event);
240
+ if (!disabled) {
241
+ onClick?.(event);
242
+ }
243
+ },
244
+ [binding, disabled, onClick],
245
+ );
246
+
247
+ const handleFocus = useCallback(
248
+ (event: FocusEvent<HTMLLIElement>) => {
249
+ binding.rowProps.onFocus?.(event);
250
+ onFocus?.(event);
251
+ },
252
+ [binding, onFocus],
253
+ );
254
+
255
+ const composed = composableProps<HTMLLIElement>(rest, {
256
+ classNames: [ITEM_BASE, disabled && 'opacity-50 cursor-not-allowed'],
257
+ });
258
+
259
+ // 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).
262
+ return (
263
+ <ListItemProviderHost id={id} selected={binding.selected}>
264
+ <ListItem
265
+ {...composed}
266
+ role='option'
267
+ tabIndex={0}
268
+ aria-selected={binding.selected}
269
+ aria-disabled={disabled || undefined}
270
+ onClick={handleClick}
271
+ onFocus={handleFocus}
272
+ ref={forwardedRef}
273
+ >
152
274
  {children}
153
- </span>
154
- );
155
- },
275
+ </ListItem>
276
+ </ListItemProviderHost>
277
+ );
278
+ });
279
+
280
+ Item.displayName = LISTBOX_ITEM_NAME;
281
+
282
+ /**
283
+ * Publishes the item context so `Indicator` (and any future per-item descendant) can read
284
+ * selection state without a second hook subscription. Tiny adapter — separated so `Item`'s
285
+ * own composition stays a single component.
286
+ */
287
+ const ListItemProviderHost = ({ id, selected, children }: PropsWithChildren<ListboxItemContextValue>) => (
288
+ <ListboxItemProvider id={id} selected={selected}>
289
+ {children}
290
+ </ListboxItemProvider>
156
291
  );
157
292
 
158
- ListboxOptionLabel.displayName = 'Listbox.OptionLabel';
159
-
160
- //
161
- // OptionIndicator — checkmark for the selected option.
162
293
  //
163
- // Reads `isSelected` from the option context. The visual indicator is
164
- // also covered by `dx-selected` on the row, so the checkmark is purely
165
- // confirmatory.
294
+ // ItemLabel text content for the item; grows and truncates.
166
295
  //
167
296
 
168
- type ListboxOptionIndicatorProps = Omit<IconProps, 'icon'> & Partial<Pick<IconProps, 'icon'>>;
297
+ type ItemLabelProps = ThemedClassName<ComponentPropsWithRef<'span'>>;
169
298
 
170
- const ListboxOptionIndicator = forwardRef<SVGSVGElement, ListboxOptionIndicatorProps>(
171
- (props: ListboxOptionScopedProps<ListboxOptionIndicatorProps>, forwardedRef) => {
172
- const { __listboxOptionScope, classNames, ...rootProps } = props;
173
- const { isSelected } = useListboxOptionContext(LISTBOX_OPTION_INDICATOR_NAME, __listboxOptionScope);
299
+ const ItemLabel = forwardRef<HTMLSpanElement, ItemLabelProps>(({ classNames, children, ...rest }, forwardedRef) => (
300
+ <span {...rest} className={mx('grow truncate', classNames)} ref={forwardedRef}>
301
+ {children}
302
+ </span>
303
+ ));
174
304
 
175
- return (
176
- <Icon
177
- icon='ph--check--regular'
178
- {...rootProps}
179
- classNames={mx(!isSelected && 'invisible', classNames)}
180
- ref={forwardedRef}
181
- />
182
- );
183
- },
184
- );
185
-
186
- ListboxOptionIndicator.displayName = 'Listbox.OptionIndicator';
305
+ ItemLabel.displayName = LISTBOX_ITEM_LABEL_NAME;
187
306
 
188
307
  //
189
- // Listbox
308
+ // Indicator — checkmark icon for the selected item.
190
309
  //
191
310
 
192
- export const Listbox = {
193
- Root: ListboxRoot,
194
- Option: ListboxOption,
195
- OptionLabel: ListboxOptionLabel,
196
- OptionIndicator: ListboxOptionIndicator,
311
+ type IndicatorProps = Omit<IconProps, 'icon'> & Partial<Pick<IconProps, 'icon'>>;
312
+
313
+ const Indicator = forwardRef<SVGSVGElement, IndicatorProps>(({ classNames, ...rootProps }, forwardedRef) => {
314
+ const { selected } = useListboxItemContext(LISTBOX_INDICATOR_NAME);
315
+ return (
316
+ <Icon
317
+ icon='ph--check--regular'
318
+ {...rootProps}
319
+ classNames={mx(!selected && 'invisible', classNames)}
320
+ ref={forwardedRef}
321
+ />
322
+ );
323
+ });
324
+
325
+ Indicator.displayName = LISTBOX_INDICATOR_NAME;
326
+
327
+ /**
328
+ * Read selection state for a single id from inside any descendant of `<Listbox.Root>`.
329
+ * Returns `true` when the row is currently selected. Lets composing components react to
330
+ * selection without re-rendering on unrelated changes.
331
+ */
332
+ const useListboxSelection = (id: string): boolean => {
333
+ const { selection } = useListboxContext('useListboxSelection');
334
+ return selection.bind(id).selected;
197
335
  };
198
336
 
199
- export { createListboxScope };
337
+ //
338
+ // Public namespace.
339
+ //
200
340
 
201
- export type { ListboxRootProps, ListboxOptionProps, ListboxScopedProps };
341
+ const Listbox = {
342
+ Root,
343
+ Viewport,
344
+ Content,
345
+ Item,
346
+ ItemLabel,
347
+ Indicator,
348
+ };
349
+
350
+ export { Listbox, useListboxSelection };
351
+ export type {
352
+ RootProps as ListboxRootProps,
353
+ ViewportProps as ListboxViewportProps,
354
+ ContentProps as ListboxContentProps,
355
+ ItemProps as ListboxItemProps,
356
+ ItemLabelProps as ListboxItemLabelProps,
357
+ IndicatorProps as ListboxIndicatorProps,
358
+ };