@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.
- package/dist/lib/browser/index.mjs +993 -521
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +993 -521
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/aspects/index.d.ts +6 -0
- package/dist/types/src/aspects/index.d.ts.map +1 -0
- package/dist/types/src/aspects/useListDisclosure.d.ts +60 -0
- package/dist/types/src/aspects/useListDisclosure.d.ts.map +1 -0
- package/dist/types/src/aspects/useListDisclosure.test.d.ts +2 -0
- package/dist/types/src/aspects/useListDisclosure.test.d.ts.map +1 -0
- package/dist/types/src/aspects/useListGrid.d.ts +30 -0
- package/dist/types/src/aspects/useListGrid.d.ts.map +1 -0
- package/dist/types/src/aspects/useListGrid.test.d.ts +2 -0
- package/dist/types/src/aspects/useListGrid.test.d.ts.map +1 -0
- package/dist/types/src/aspects/useListNavigation.d.ts +68 -0
- package/dist/types/src/aspects/useListNavigation.d.ts.map +1 -0
- package/dist/types/src/aspects/useListNavigation.test.d.ts +2 -0
- package/dist/types/src/aspects/useListNavigation.test.d.ts.map +1 -0
- package/dist/types/src/aspects/useListSelection.d.ts +48 -0
- package/dist/types/src/aspects/useListSelection.d.ts.map +1 -0
- package/dist/types/src/aspects/useListSelection.test.d.ts +2 -0
- package/dist/types/src/aspects/useListSelection.test.d.ts.map +1 -0
- package/dist/types/src/aspects/useReorder.d.ts +103 -0
- package/dist/types/src/aspects/useReorder.d.ts.map +1 -0
- package/dist/types/src/components/Accordion/Accordion.d.ts +1 -1
- package/dist/types/src/components/Accordion/AccordionItem.d.ts +5 -3
- package/dist/types/src/components/Accordion/AccordionItem.d.ts.map +1 -1
- package/dist/types/src/components/Accordion/AccordionRoot.d.ts +1 -1
- package/dist/types/src/components/Accordion/AccordionRoot.d.ts.map +1 -1
- package/dist/types/src/components/Listbox/Listbox.d.ts +60 -20
- package/dist/types/src/components/Listbox/Listbox.d.ts.map +1 -1
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts +27 -3
- package/dist/types/src/components/Listbox/Listbox.stories.d.ts.map +1 -1
- package/dist/types/src/components/OrderedList/OrderedList.d.ts +49 -0
- package/dist/types/src/components/OrderedList/OrderedList.d.ts.map +1 -0
- package/dist/types/src/components/OrderedList/OrderedList.stories.d.ts +11 -0
- package/dist/types/src/components/OrderedList/OrderedList.stories.d.ts.map +1 -0
- package/dist/types/src/components/OrderedList/OrderedList.test.d.ts +2 -0
- package/dist/types/src/components/OrderedList/OrderedList.test.d.ts.map +1 -0
- package/dist/types/src/components/OrderedList/OrderedListItem.d.ts +94 -0
- package/dist/types/src/components/OrderedList/OrderedListItem.d.ts.map +1 -0
- package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts +73 -0
- package/dist/types/src/components/OrderedList/OrderedListRoot.d.ts.map +1 -0
- package/dist/types/src/components/OrderedList/index.d.ts +2 -0
- package/dist/types/src/components/OrderedList/index.d.ts.map +1 -0
- package/dist/types/src/components/Tree/TreeItem.d.ts.map +1 -1
- package/dist/types/src/components/Tree/TreeItemHeading.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -2
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/vitest-setup.d.ts +2 -0
- package/dist/types/src/vitest-setup.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +18 -15
- package/src/aspects/index.ts +9 -0
- package/src/aspects/useListDisclosure.test.ts +72 -0
- package/src/aspects/useListDisclosure.ts +160 -0
- package/src/aspects/useListGrid.test.ts +41 -0
- package/src/aspects/useListGrid.ts +61 -0
- package/src/aspects/useListNavigation.test.ts +44 -0
- package/src/aspects/useListNavigation.ts +160 -0
- package/src/aspects/useListSelection.test.ts +101 -0
- package/src/aspects/useListSelection.ts +162 -0
- package/src/aspects/useReorder.ts +370 -0
- package/src/components/Accordion/Accordion.stories.tsx +1 -1
- package/src/components/Accordion/AccordionItem.tsx +11 -6
- package/src/components/Accordion/AccordionRoot.tsx +4 -1
- package/src/components/Listbox/Listbox.stories.tsx +171 -21
- package/src/components/Listbox/Listbox.tsx +302 -145
- package/src/components/OrderedList/OrderedList.stories.tsx +379 -0
- package/src/components/OrderedList/OrderedList.test.tsx +59 -0
- package/src/components/OrderedList/OrderedList.tsx +63 -0
- package/src/components/OrderedList/OrderedListItem.tsx +348 -0
- package/src/components/OrderedList/OrderedListRoot.tsx +173 -0
- package/src/components/OrderedList/index.ts +5 -0
- package/src/components/Tree/TreeItem.tsx +2 -0
- package/src/components/Tree/TreeItemHeading.tsx +1 -2
- package/src/components/index.ts +1 -2
- package/src/index.ts +1 -0
- package/src/vitest-setup.ts +11 -0
- package/dist/types/src/components/List/List.d.ts +0 -40
- package/dist/types/src/components/List/List.d.ts.map +0 -1
- package/dist/types/src/components/List/List.stories.d.ts +0 -18
- package/dist/types/src/components/List/List.stories.d.ts.map +0 -1
- package/dist/types/src/components/List/ListItem.d.ts +0 -49
- package/dist/types/src/components/List/ListItem.d.ts.map +0 -1
- package/dist/types/src/components/List/ListRoot.d.ts +0 -29
- package/dist/types/src/components/List/ListRoot.d.ts.map +0 -1
- package/dist/types/src/components/List/index.d.ts +0 -2
- package/dist/types/src/components/List/index.d.ts.map +0 -1
- package/dist/types/src/components/List/testing.d.ts +0 -15
- package/dist/types/src/components/List/testing.d.ts.map +0 -1
- package/dist/types/src/components/RowList/RowList.d.ts +0 -61
- package/dist/types/src/components/RowList/RowList.d.ts.map +0 -1
- package/dist/types/src/components/RowList/RowList.stories.d.ts +0 -35
- package/dist/types/src/components/RowList/RowList.stories.d.ts.map +0 -1
- package/dist/types/src/components/RowList/index.d.ts +0 -3
- package/dist/types/src/components/RowList/index.d.ts.map +0 -1
- package/src/components/List/List.stories.tsx +0 -129
- package/src/components/List/List.tsx +0 -47
- package/src/components/List/ListItem.tsx +0 -287
- package/src/components/List/ListRoot.tsx +0 -106
- package/src/components/List/index.ts +0 -5
- package/src/components/List/testing.ts +0 -31
- package/src/components/RowList/RowList.stories.tsx +0 -163
- package/src/components/RowList/RowList.tsx +0 -350
- package/src/components/RowList/index.ts +0 -6
|
@@ -1,201 +1,358 @@
|
|
|
1
1
|
//
|
|
2
|
-
// Copyright
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
// `Listbox` — single-select
|
|
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
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 {
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
30
|
-
const
|
|
31
|
-
const
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
83
|
+
// Contexts — plain 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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
type ListboxItemContextValue = {
|
|
94
|
+
id: string;
|
|
95
|
+
selected: boolean;
|
|
50
96
|
};
|
|
51
97
|
|
|
52
|
-
const [
|
|
53
|
-
|
|
98
|
+
const [ListboxProvider, useListboxContext] = createContext<ListboxContextValue>(LISTBOX_NAME);
|
|
99
|
+
const [ListboxItemProvider, useListboxItemContext] = createContext<ListboxItemContextValue>(LISTBOX_ITEM_NAME);
|
|
54
100
|
|
|
55
101
|
//
|
|
56
|
-
// Root —
|
|
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
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
167
|
+
Viewport.displayName = LISTBOX_VIEWPORT_NAME;
|
|
94
168
|
|
|
95
169
|
//
|
|
96
|
-
//
|
|
97
|
-
// publishes `{ value, isSelected }` so `OptionIndicator` can render a
|
|
98
|
-
// checkmark.
|
|
170
|
+
// Content — the listbox `<ul>` (Tabster arrow group + aria-label + role).
|
|
99
171
|
//
|
|
100
172
|
|
|
101
|
-
type
|
|
102
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
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
|
-
<
|
|
197
|
+
<List
|
|
198
|
+
variant='unordered'
|
|
199
|
+
{...composed}
|
|
200
|
+
{...navigation.containerProps}
|
|
201
|
+
ref={forwardedRef as unknown as ForwardedRef<HTMLOListElement>}
|
|
202
|
+
>
|
|
139
203
|
{children}
|
|
140
|
-
</
|
|
204
|
+
</List>
|
|
141
205
|
);
|
|
142
|
-
};
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
Content.displayName = LISTBOX_CONTENT_NAME;
|
|
143
209
|
|
|
144
210
|
//
|
|
145
|
-
//
|
|
211
|
+
// Item — option row.
|
|
146
212
|
//
|
|
147
213
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
</
|
|
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
|
-
//
|
|
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
|
|
297
|
+
type ItemLabelProps = ThemedClassName<ComponentPropsWithRef<'span'>>;
|
|
169
298
|
|
|
170
|
-
const
|
|
171
|
-
(
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
308
|
+
// Indicator — checkmark icon for the selected item.
|
|
190
309
|
//
|
|
191
310
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
337
|
+
//
|
|
338
|
+
// Public namespace.
|
|
339
|
+
//
|
|
200
340
|
|
|
201
|
-
|
|
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
|
+
};
|