@bento/listbox 0.1.2
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/LICENSE +9 -0
- package/README.mdx +342 -0
- package/dist/index.cjs +383 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +264 -0
- package/dist/index.d.ts +264 -0
- package/dist/index.js +370 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
- package/src/header.tsx +132 -0
- package/src/index.tsx +9 -0
- package/src/listbox-item.tsx +255 -0
- package/src/listbox-section.tsx +171 -0
- package/src/listbox.tsx +664 -0
- package/src/utils.ts +57 -0
package/src/listbox.tsx
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import React, { createContext, useContext, useMemo, ForwardedRef, RefObject } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
FocusScope,
|
|
4
|
+
mergeProps,
|
|
5
|
+
useCollator,
|
|
6
|
+
useLocale,
|
|
7
|
+
useListBox,
|
|
8
|
+
useFocusRing,
|
|
9
|
+
ListKeyboardDelegate
|
|
10
|
+
} from 'react-aria';
|
|
11
|
+
import { ListState, SelectionBehavior, useListState, Orientation, Node } from 'react-stately';
|
|
12
|
+
import { CollectionBuilder, Collection as AriaCollection } from '@react-aria/collections';
|
|
13
|
+
import { AriaListBoxProps } from '@react-types/listbox';
|
|
14
|
+
import { useDataAttributes } from '@bento/use-data-attributes';
|
|
15
|
+
import { useProps } from '@bento/use-props';
|
|
16
|
+
import { withSlots, Slots } from '@bento/slots';
|
|
17
|
+
import { ListBoxItemImpl } from './listbox-item';
|
|
18
|
+
import { ListBoxSectionInner } from './listbox-section';
|
|
19
|
+
import { useSafeObjectRef } from './utils';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render props provided to ListBox render functions and empty state renderers.
|
|
23
|
+
* @interface ListBoxRenderProps
|
|
24
|
+
*/
|
|
25
|
+
export interface ListBoxRenderProps {
|
|
26
|
+
/**
|
|
27
|
+
* Whether the listbox has no items and should display its empty state.
|
|
28
|
+
* @selector [data-empty]
|
|
29
|
+
*/
|
|
30
|
+
readonly isEmpty: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the listbox is currently focused.
|
|
33
|
+
* @selector [data-focused]
|
|
34
|
+
*/
|
|
35
|
+
readonly isFocused: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether the listbox is currently keyboard focused.
|
|
38
|
+
* @selector [data-focus-visible]
|
|
39
|
+
*/
|
|
40
|
+
readonly isFocusVisible: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Whether the listbox is currently the active drop target.
|
|
43
|
+
* @selector [data-drop-target]
|
|
44
|
+
*/
|
|
45
|
+
readonly isDropTarget: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the items are arranged in a stack or grid.
|
|
48
|
+
* @selector [data-layout="stack | grid"]
|
|
49
|
+
*/
|
|
50
|
+
readonly layout?: 'stack' | 'grid';
|
|
51
|
+
/**
|
|
52
|
+
* State of the listbox.
|
|
53
|
+
*/
|
|
54
|
+
readonly state: ListState<unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* The items array when using dynamic collections.
|
|
57
|
+
*/
|
|
58
|
+
readonly items?: Iterable<unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Props for the ListBox component.
|
|
63
|
+
* @interface ListBoxProps
|
|
64
|
+
* @template T The type of items in the collection
|
|
65
|
+
*/
|
|
66
|
+
export interface ListBoxProps<T>
|
|
67
|
+
extends Omit<AriaListBoxProps<T>, 'label' | 'children'>,
|
|
68
|
+
Omit<React.ComponentProps<'div'>, keyof AriaListBoxProps<T> | 'children'>,
|
|
69
|
+
Slots {
|
|
70
|
+
/**
|
|
71
|
+
* How multiple selection should behave in the collection.
|
|
72
|
+
*/
|
|
73
|
+
readonly selectionBehavior?: SelectionBehavior;
|
|
74
|
+
/**
|
|
75
|
+
* Provides content to display when there are no items in the list.
|
|
76
|
+
*/
|
|
77
|
+
readonly renderEmptyState?: (props: ListBoxRenderProps) => React.ReactNode;
|
|
78
|
+
/**
|
|
79
|
+
* Whether the items are arranged in a stack layout.
|
|
80
|
+
* @default 'stack'
|
|
81
|
+
*/
|
|
82
|
+
readonly layout?: 'stack';
|
|
83
|
+
/**
|
|
84
|
+
* The primary orientation of the items. Usually this is the direction that the collection scrolls.
|
|
85
|
+
* @default 'vertical'
|
|
86
|
+
*/
|
|
87
|
+
readonly orientation?: Orientation;
|
|
88
|
+
/**
|
|
89
|
+
* Static children or render function for the ListBox.
|
|
90
|
+
* When items prop is provided, children receives individual items for React Aria compatibility.
|
|
91
|
+
* When no items prop is provided, children receives Bento render props { isEmpty, isFocused, state, etc. }.
|
|
92
|
+
*/
|
|
93
|
+
readonly children?:
|
|
94
|
+
| React.ReactNode
|
|
95
|
+
| ((item: T) => React.ReactNode)
|
|
96
|
+
| ((props: ListBoxRenderProps) => React.ReactNode);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* React context for sharing ListBox state across components.
|
|
101
|
+
* This context provides the ListBox state to child components like ListBoxItem and ListBoxSection,
|
|
102
|
+
* enabling them to access selection state, collection data, and other shared functionality.
|
|
103
|
+
*
|
|
104
|
+
* @context
|
|
105
|
+
* @internal
|
|
106
|
+
*/
|
|
107
|
+
const ListStateContext = createContext<ListState<unknown> | null>(null);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Custom hook to manage ListBox state creation and context handling.
|
|
111
|
+
* This hook either uses an existing state from context or creates a new one.
|
|
112
|
+
* It's designed to work both as a standalone component and within a parent component
|
|
113
|
+
* that provides ListBox state through context.
|
|
114
|
+
*
|
|
115
|
+
* @param {Record<string, unknown>} props - Configuration object for the ListBox state
|
|
116
|
+
* @returns {object} An object containing the state instance and context state flag
|
|
117
|
+
* @returns {ListState<unknown>} returns.state - The ListBox state instance
|
|
118
|
+
* @returns {ListState<unknown> | null} returns.contextState - Existing context state, if any
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
function useListBoxState(props: Record<string, unknown>) {
|
|
122
|
+
const contextState = useContext(ListStateContext);
|
|
123
|
+
|
|
124
|
+
const stateProps = {
|
|
125
|
+
...props,
|
|
126
|
+
children: undefined,
|
|
127
|
+
items: undefined
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const state = contextState ?? useListState(stateProps);
|
|
131
|
+
|
|
132
|
+
return { state, contextState };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Renders content with optional context provider wrapper.
|
|
137
|
+
* If no context state exists, wraps the content in a ListStateContext.Provider.
|
|
138
|
+
* This allows the ListBox to work both standalone and as part of a larger component tree.
|
|
139
|
+
*
|
|
140
|
+
* @param {React.ReactNode} content - The React content to render
|
|
141
|
+
* @param {ListState<unknown>} state - The ListBox state to provide via context
|
|
142
|
+
* @param {ListState<unknown> | null} contextState - Existing context state, if any
|
|
143
|
+
* @returns {React.ReactNode} The content, optionally wrapped in a context provider
|
|
144
|
+
* @internal
|
|
145
|
+
*/
|
|
146
|
+
function renderWithOptionalContext(
|
|
147
|
+
content: React.ReactNode,
|
|
148
|
+
state: ListState<unknown>,
|
|
149
|
+
contextState: ListState<unknown> | null
|
|
150
|
+
): React.ReactNode {
|
|
151
|
+
/* v8 ignore next */
|
|
152
|
+
return contextState ? content : <ListStateContext.Provider value={state}>{content}</ListStateContext.Provider>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Creates and memoizes a keyboard delegate for the ListBox.
|
|
157
|
+
* The keyboard delegate handles keyboard navigation logic, including
|
|
158
|
+
* arrow key navigation, home/end keys, and type-ahead functionality.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} config - Configuration object for the keyboard delegate
|
|
161
|
+
* @param {ListState<unknown>['collection']} config.collection - The collection of items in the ListBox
|
|
162
|
+
* @param {Intl.Collator} config.collator - Intl collator for string comparison in type-ahead
|
|
163
|
+
* @param {React.RefObject<HTMLDivElement>} config.listBoxRef - Reference to the ListBox DOM element
|
|
164
|
+
* @param {ListState<unknown>['selectionManager']} config.selectionManager - Selection manager from the state
|
|
165
|
+
* @param {'stack' | 'grid'} [config.layout] - Layout mode (stack or grid)
|
|
166
|
+
* @param {Orientation} [config.orientation] - Primary orientation of the items
|
|
167
|
+
* @param {'ltr' | 'rtl'} config.direction - Text direction (ltr or rtl)
|
|
168
|
+
* @param {ListKeyboardDelegate<unknown>} [config.keyboardDelegate] - Custom keyboard delegate to use instead of default
|
|
169
|
+
* @returns {ListKeyboardDelegate<unknown>} A keyboard delegate instance for handling keyboard interactions
|
|
170
|
+
* @internal
|
|
171
|
+
*/
|
|
172
|
+
function useKeyboardDelegate({
|
|
173
|
+
collection,
|
|
174
|
+
collator,
|
|
175
|
+
listBoxRef,
|
|
176
|
+
selectionManager,
|
|
177
|
+
layout,
|
|
178
|
+
orientation,
|
|
179
|
+
direction,
|
|
180
|
+
keyboardDelegate: providedDelegate
|
|
181
|
+
}: {
|
|
182
|
+
readonly collection: ListState<unknown>['collection'];
|
|
183
|
+
readonly collator: Intl.Collator;
|
|
184
|
+
readonly listBoxRef: React.RefObject<HTMLDivElement>;
|
|
185
|
+
readonly selectionManager: ListState<unknown>['selectionManager'];
|
|
186
|
+
readonly layout?: 'stack' | 'grid';
|
|
187
|
+
readonly orientation?: Orientation;
|
|
188
|
+
readonly direction: 'ltr' | 'rtl';
|
|
189
|
+
readonly keyboardDelegate?: ListKeyboardDelegate<unknown>;
|
|
190
|
+
}): ListKeyboardDelegate<unknown> {
|
|
191
|
+
const { disabledBehavior, disabledKeys } = selectionManager;
|
|
192
|
+
|
|
193
|
+
return useMemo(
|
|
194
|
+
function createKeyboardDelegate() {
|
|
195
|
+
return (
|
|
196
|
+
providedDelegate ||
|
|
197
|
+
new ListKeyboardDelegate({
|
|
198
|
+
collection,
|
|
199
|
+
collator,
|
|
200
|
+
ref: listBoxRef,
|
|
201
|
+
disabledKeys,
|
|
202
|
+
disabledBehavior,
|
|
203
|
+
layout,
|
|
204
|
+
orientation,
|
|
205
|
+
direction
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
[collection, collator, listBoxRef, selectionManager, orientation, direction, layout, providedDelegate]
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Generates data attributes for the ListBox element based on its current state.
|
|
215
|
+
* These attributes are used for styling selectors and accessibility indicators.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} config - Configuration object containing ListBox state flags
|
|
218
|
+
* @param {boolean} config.isEmpty - Whether the listbox has no items
|
|
219
|
+
* @param {boolean} config.isFocused - Whether the listbox is currently focused
|
|
220
|
+
* @param {boolean} config.isFocusVisible - Whether focus should be visually indicated
|
|
221
|
+
* @param {'stack' | 'grid'} [config.layout] - Layout mode (stack or grid)
|
|
222
|
+
* @param {Orientation} [config.orientation] - Primary orientation of the items
|
|
223
|
+
* @param {ListState<unknown>['selectionManager']} config.selectionManager - Selection manager containing selection state
|
|
224
|
+
* @param {boolean} [config.allowsTabNavigation] - Whether tab navigation is enabled
|
|
225
|
+
* @param {boolean} [config.shouldFocusWrap] - Whether focus wraps at boundaries
|
|
226
|
+
* @param {SelectionBehavior} [config.originalSelectionBehavior] - Original selection behavior setting
|
|
227
|
+
* @returns {Record<string, unknown>} Object with data attributes for the ListBox element
|
|
228
|
+
* @internal
|
|
229
|
+
*/
|
|
230
|
+
function useListBoxDataAttributes({
|
|
231
|
+
isEmpty,
|
|
232
|
+
isFocused,
|
|
233
|
+
isFocusVisible,
|
|
234
|
+
layout,
|
|
235
|
+
orientation,
|
|
236
|
+
selectionManager,
|
|
237
|
+
allowsTabNavigation,
|
|
238
|
+
shouldFocusWrap,
|
|
239
|
+
originalSelectionBehavior
|
|
240
|
+
}: {
|
|
241
|
+
readonly isEmpty: boolean;
|
|
242
|
+
readonly isFocused: boolean;
|
|
243
|
+
readonly isFocusVisible: boolean;
|
|
244
|
+
readonly layout?: 'stack' | 'grid';
|
|
245
|
+
readonly orientation?: Orientation;
|
|
246
|
+
readonly selectionManager: ListState<unknown>['selectionManager'];
|
|
247
|
+
readonly allowsTabNavigation?: boolean;
|
|
248
|
+
readonly shouldFocusWrap?: boolean;
|
|
249
|
+
readonly originalSelectionBehavior?: SelectionBehavior;
|
|
250
|
+
}) {
|
|
251
|
+
return useDataAttributes({
|
|
252
|
+
empty: isEmpty,
|
|
253
|
+
focused: isFocused,
|
|
254
|
+
'focus-visible': isFocusVisible,
|
|
255
|
+
layout,
|
|
256
|
+
orientation,
|
|
257
|
+
'selection-mode': selectionManager.selectionMode !== 'none' ? selectionManager.selectionMode : undefined,
|
|
258
|
+
'selection-behavior': originalSelectionBehavior !== undefined ? selectionManager.selectionBehavior : undefined,
|
|
259
|
+
'allows-tab-navigation': allowsTabNavigation,
|
|
260
|
+
'focus-wrap': shouldFocusWrap
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Composes all props for the ListBox element including DOM props, ARIA props,
|
|
266
|
+
* focus props, and data attributes. Handles prop application through useProps
|
|
267
|
+
* and manages ref assignment to avoid proxy extensibility issues.
|
|
268
|
+
*
|
|
269
|
+
* @param {object} config - Configuration object containing all props to compose
|
|
270
|
+
* @param {Record<string, unknown>} config.otherProps - Additional props from the component
|
|
271
|
+
* @param {ListBoxRenderProps} config.renderValues - Values available to render functions
|
|
272
|
+
* @param {Record<string, unknown>} config.listBoxProps - Props from useListBox hook
|
|
273
|
+
* @param {Record<string, unknown>} config.focusProps - Props from useFocusRing hook
|
|
274
|
+
* @param {Record<string, unknown>} config.dataAttributes - Data attributes for styling/selectors
|
|
275
|
+
* @param {ListState<unknown>['selectionManager']} config.selectionManager - Selection manager for ARIA attributes
|
|
276
|
+
* @param {React.RefObject<HTMLDivElement>} config.listBoxRef - Reference to attach to the final element
|
|
277
|
+
* @returns {Record<string, unknown>} Composed props object ready for the ListBox element
|
|
278
|
+
* @internal
|
|
279
|
+
*/
|
|
280
|
+
function useComposedProps({
|
|
281
|
+
otherProps,
|
|
282
|
+
renderValues,
|
|
283
|
+
listBoxProps,
|
|
284
|
+
focusProps,
|
|
285
|
+
dataAttributes,
|
|
286
|
+
selectionManager,
|
|
287
|
+
listBoxRef
|
|
288
|
+
}: {
|
|
289
|
+
readonly otherProps: Record<string, unknown>;
|
|
290
|
+
readonly renderValues: ListBoxRenderProps;
|
|
291
|
+
readonly listBoxProps: Record<string, unknown>;
|
|
292
|
+
readonly focusProps: Record<string, unknown>;
|
|
293
|
+
readonly dataAttributes: Record<string, unknown>;
|
|
294
|
+
readonly selectionManager: ListState<unknown>['selectionManager'];
|
|
295
|
+
readonly listBoxRef: React.RefObject<HTMLDivElement>;
|
|
296
|
+
}) {
|
|
297
|
+
const { apply } = useProps(otherProps, renderValues);
|
|
298
|
+
|
|
299
|
+
const propsToExclude = [
|
|
300
|
+
'renderEmptyState',
|
|
301
|
+
'selectionMode',
|
|
302
|
+
'defaultSelectedKeys',
|
|
303
|
+
'disabledKeys',
|
|
304
|
+
'disallowEmptySelection',
|
|
305
|
+
'shouldFocusWrap',
|
|
306
|
+
'items',
|
|
307
|
+
'children',
|
|
308
|
+
'selectionBehavior',
|
|
309
|
+
'keyboardDelegate'
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
// Apply user props directly (preserves className, style, etc.)
|
|
313
|
+
const appliedUserProps = apply(otherProps, propsToExclude);
|
|
314
|
+
|
|
315
|
+
// React Aria and Bento props
|
|
316
|
+
const baseProps = {
|
|
317
|
+
...mergeProps(listBoxProps, focusProps),
|
|
318
|
+
...dataAttributes,
|
|
319
|
+
...(selectionManager.selectionMode !== 'none' && {
|
|
320
|
+
'aria-multiselectable': selectionManager.selectionMode === 'multiple'
|
|
321
|
+
})
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
//
|
|
325
|
+
// Merge all props together with user props taking precedence
|
|
326
|
+
//
|
|
327
|
+
const finalProps = {
|
|
328
|
+
...baseProps,
|
|
329
|
+
...appliedUserProps,
|
|
330
|
+
ref: listBoxRef // Set ref directly to avoid extensibility issues
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return finalProps;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Renders the empty state content for the ListBox when no items are present.
|
|
338
|
+
* Handles both function-based render props and direct JSX elements.
|
|
339
|
+
* If a function is provided, calls it with render values; otherwise returns as-is.
|
|
340
|
+
*
|
|
341
|
+
* @param {(props: ListBoxRenderProps) => React.ReactNode} renderEmptyStateFn - Function or JSX element to render for empty state
|
|
342
|
+
* @param {ListBoxRenderProps} renderValues - Current render values to pass to render function
|
|
343
|
+
* @returns {React.ReactNode} Rendered empty state content
|
|
344
|
+
* @internal
|
|
345
|
+
*/
|
|
346
|
+
function renderEmptyState(
|
|
347
|
+
renderEmptyStateFn: (props: ListBoxRenderProps) => React.ReactNode,
|
|
348
|
+
renderValues: ListBoxRenderProps
|
|
349
|
+
): React.ReactNode {
|
|
350
|
+
//
|
|
351
|
+
// Handle cases where renderEmptyState is not a function (e.g., JSX element passed directly)
|
|
352
|
+
//
|
|
353
|
+
if (typeof renderEmptyStateFn === 'function') {
|
|
354
|
+
return renderEmptyStateFn(renderValues);
|
|
355
|
+
}
|
|
356
|
+
//
|
|
357
|
+
// If it's not a function, just return it as-is (likely a JSX element)
|
|
358
|
+
//
|
|
359
|
+
return renderEmptyStateFn as React.ReactNode;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Renders all items in the collection as React elements.
|
|
364
|
+
* Handles both regular items and section items, using the appropriate
|
|
365
|
+
* components (ListBoxItemImpl for items, ListBoxSectionInner for sections).
|
|
366
|
+
*
|
|
367
|
+
* @param {ListState<unknown>['collection']} collection - The collection of items to render
|
|
368
|
+
* @returns {React.ReactElement[]} Array of rendered React elements for all collection items
|
|
369
|
+
* @internal
|
|
370
|
+
*/
|
|
371
|
+
function renderCollectionItems(collection: ListState<unknown>['collection']): React.ReactElement[] {
|
|
372
|
+
return [...collection].map(function renderCollectionItem(item: Node<unknown>) {
|
|
373
|
+
return item.type === 'section' ? (
|
|
374
|
+
<ListBoxSectionInner key={item.key} section={item} />
|
|
375
|
+
) : (
|
|
376
|
+
<ListBoxItemImpl key={item.key} __node={item as Node<object>}>
|
|
377
|
+
{item.rendered}
|
|
378
|
+
</ListBoxItemImpl>
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Determines what content to render inside the ListBox based on its configuration.
|
|
385
|
+
* Handles three cases:
|
|
386
|
+
* 1. Function children without items (Bento render prop pattern with full render props)
|
|
387
|
+
* 2. Empty state when no items and renderEmptyState is provided
|
|
388
|
+
* 3. Normal collection rendering (including items with children functions for React Aria compatibility)
|
|
389
|
+
*
|
|
390
|
+
* @param {object} config - Configuration object for rendering
|
|
391
|
+
* @param {React.ReactNode | ((item: unknown) => React.ReactNode) | ((props: ListBoxRenderProps) => React.ReactNode)} [config.children] - Children prop (static, item render function, or ListBox render prop)
|
|
392
|
+
* @param {Iterable<unknown>} [config.items] - Items array for dynamic collections
|
|
393
|
+
* @param {boolean} config.isEmpty - Whether the collection is empty
|
|
394
|
+
* @param {(props: ListBoxRenderProps) => React.ReactNode} [config.renderEmptyStateProp] - Function to render empty state
|
|
395
|
+
* @param {ListBoxRenderProps} config.renderValues - Current render values for render functions
|
|
396
|
+
* @param {ListState<unknown>['collection']} config.collection - The collection state to render
|
|
397
|
+
* @returns {React.ReactNode} The appropriate content to render inside the ListBox
|
|
398
|
+
* @internal
|
|
399
|
+
*/
|
|
400
|
+
function renderListBoxContent({
|
|
401
|
+
children,
|
|
402
|
+
items,
|
|
403
|
+
isEmpty,
|
|
404
|
+
renderEmptyStateProp,
|
|
405
|
+
renderValues,
|
|
406
|
+
collection
|
|
407
|
+
}: {
|
|
408
|
+
readonly children?:
|
|
409
|
+
| React.ReactNode
|
|
410
|
+
| ((item: unknown) => React.ReactNode)
|
|
411
|
+
| ((props: ListBoxRenderProps) => React.ReactNode);
|
|
412
|
+
readonly items?: Iterable<unknown>;
|
|
413
|
+
readonly isEmpty: boolean;
|
|
414
|
+
readonly renderEmptyStateProp?: (props: ListBoxRenderProps) => React.ReactNode;
|
|
415
|
+
readonly renderValues: ListBoxRenderProps;
|
|
416
|
+
readonly collection: ListState<unknown>['collection'];
|
|
417
|
+
}): React.ReactNode {
|
|
418
|
+
// If children is a function AND no items provided, use Bento render prop pattern
|
|
419
|
+
/* v8 ignore next */
|
|
420
|
+
const hasRenderChildren = typeof children === 'function' && !items;
|
|
421
|
+
|
|
422
|
+
/* v8 ignore next 3 */
|
|
423
|
+
if (hasRenderChildren) {
|
|
424
|
+
return (children as (props: ListBoxRenderProps) => React.ReactNode)(renderValues);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Handle empty state
|
|
428
|
+
if (isEmpty && renderEmptyStateProp) {
|
|
429
|
+
return renderEmptyState(renderEmptyStateProp, renderValues);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Render collection items (React Aria handles items + children function internally)
|
|
433
|
+
return renderCollectionItems(collection);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Internal ListBox component that handles the core rendering logic.
|
|
438
|
+
* This component manages all the hooks, state, and prop composition needed
|
|
439
|
+
* for a fully functional ListBox. It's wrapped by the main ListBox component
|
|
440
|
+
* which handles collection building.
|
|
441
|
+
*
|
|
442
|
+
* @param {object} props - Component props
|
|
443
|
+
* @param {ListState<unknown>} props.state - The ListBox state instance
|
|
444
|
+
* @param {(props: ListBoxRenderProps) => React.ReactNode} [props.renderEmptyState] - Function to render when no items are present
|
|
445
|
+
* @param {React.ReactNode | ((item: unknown) => React.ReactNode) | ((props: ListBoxRenderProps) => React.ReactNode)} [props.children] - Static children, item render function, or ListBox render function
|
|
446
|
+
* @param {Iterable<unknown>} [props.items] - Items array for dynamic collections
|
|
447
|
+
* @param {React.RefObject<HTMLDivElement>} props.listBoxRef - Reference to the ListBox DOM element
|
|
448
|
+
* @param {'stack'} [props.layout] - Layout mode (stack or grid)
|
|
449
|
+
* @param {Orientation} [props.orientation] - Primary orientation of the items
|
|
450
|
+
* @param {boolean} [props.shouldSelectOnPressUp] - Whether selection occurs on pointer up
|
|
451
|
+
* @param {ListKeyboardDelegate<unknown>} [props.keyboardDelegate] - Custom keyboard navigation delegate
|
|
452
|
+
* @param {boolean} [props.allowsTabNavigation] - Whether tab key navigates between items
|
|
453
|
+
* @param {boolean} [props.shouldFocusWrap] - Whether focus wraps at boundaries
|
|
454
|
+
* @param {'none' | 'single' | 'multiple'} [props.selectionMode] - Selection mode (none, single, multiple)
|
|
455
|
+
* @param {SelectionBehavior} [props.selectionBehavior] - Selection behavior (toggle, replace)
|
|
456
|
+
* @returns {React.ReactElement} A fully functional ListBox element with focus scope
|
|
457
|
+
* @internal
|
|
458
|
+
*/
|
|
459
|
+
const ListBoxInner: React.FC<{
|
|
460
|
+
readonly state: ListState<unknown>;
|
|
461
|
+
readonly renderEmptyState?: (props: ListBoxRenderProps) => React.ReactNode;
|
|
462
|
+
readonly children?:
|
|
463
|
+
| React.ReactNode
|
|
464
|
+
| ((item: unknown) => React.ReactNode)
|
|
465
|
+
| ((props: ListBoxRenderProps) => React.ReactNode);
|
|
466
|
+
readonly items?: Iterable<unknown>;
|
|
467
|
+
readonly listBoxRef: RefObject<HTMLDivElement>;
|
|
468
|
+
readonly layout?: 'stack';
|
|
469
|
+
readonly orientation?: Orientation;
|
|
470
|
+
readonly shouldSelectOnPressUp?: boolean;
|
|
471
|
+
readonly keyboardDelegate?: ListKeyboardDelegate<unknown>;
|
|
472
|
+
readonly allowsTabNavigation?: boolean;
|
|
473
|
+
readonly shouldFocusWrap?: boolean;
|
|
474
|
+
readonly selectionMode?: 'none' | 'single' | 'multiple';
|
|
475
|
+
readonly selectionBehavior?: SelectionBehavior;
|
|
476
|
+
}> = function ListBoxInner({
|
|
477
|
+
state,
|
|
478
|
+
renderEmptyState: renderEmptyStateProp,
|
|
479
|
+
children,
|
|
480
|
+
items,
|
|
481
|
+
listBoxRef,
|
|
482
|
+
...otherProps
|
|
483
|
+
}) {
|
|
484
|
+
const { layout = 'stack', orientation = 'vertical', shouldSelectOnPressUp, selectionBehavior } = otherProps;
|
|
485
|
+
|
|
486
|
+
const { collection, selectionManager } = state;
|
|
487
|
+
const { direction } = useLocale();
|
|
488
|
+
const collator = useCollator({ usage: 'search', sensitivity: 'base' });
|
|
489
|
+
|
|
490
|
+
const keyboardDelegate = useKeyboardDelegate({
|
|
491
|
+
collection,
|
|
492
|
+
collator,
|
|
493
|
+
listBoxRef,
|
|
494
|
+
selectionManager,
|
|
495
|
+
layout,
|
|
496
|
+
orientation,
|
|
497
|
+
direction,
|
|
498
|
+
keyboardDelegate: otherProps.keyboardDelegate
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const { listBoxProps } = useListBox(
|
|
502
|
+
{
|
|
503
|
+
...otherProps,
|
|
504
|
+
shouldSelectOnPressUp,
|
|
505
|
+
keyboardDelegate
|
|
506
|
+
},
|
|
507
|
+
state,
|
|
508
|
+
listBoxRef
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const { focusProps, isFocused, isFocusVisible } = useFocusRing();
|
|
512
|
+
const isEmpty = state.collection.size === 0;
|
|
513
|
+
|
|
514
|
+
const renderValues: ListBoxRenderProps = {
|
|
515
|
+
isEmpty,
|
|
516
|
+
isFocused,
|
|
517
|
+
isFocusVisible,
|
|
518
|
+
isDropTarget: false,
|
|
519
|
+
layout,
|
|
520
|
+
state,
|
|
521
|
+
items
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const dataAttributes = useListBoxDataAttributes({
|
|
525
|
+
isEmpty,
|
|
526
|
+
isFocused,
|
|
527
|
+
isFocusVisible,
|
|
528
|
+
layout,
|
|
529
|
+
orientation,
|
|
530
|
+
selectionManager,
|
|
531
|
+
allowsTabNavigation: otherProps.allowsTabNavigation,
|
|
532
|
+
shouldFocusWrap: otherProps.shouldFocusWrap,
|
|
533
|
+
originalSelectionBehavior: selectionBehavior
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const composedProps = useComposedProps({
|
|
537
|
+
otherProps,
|
|
538
|
+
renderValues,
|
|
539
|
+
listBoxProps: listBoxProps as Record<string, unknown>,
|
|
540
|
+
focusProps: focusProps as Record<string, unknown>,
|
|
541
|
+
dataAttributes,
|
|
542
|
+
selectionManager,
|
|
543
|
+
listBoxRef
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<FocusScope>
|
|
548
|
+
<div {...composedProps}>
|
|
549
|
+
{renderListBoxContent({
|
|
550
|
+
children,
|
|
551
|
+
items,
|
|
552
|
+
isEmpty,
|
|
553
|
+
renderEmptyStateProp,
|
|
554
|
+
renderValues,
|
|
555
|
+
collection
|
|
556
|
+
})}
|
|
557
|
+
</div>
|
|
558
|
+
</FocusScope>
|
|
559
|
+
);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* A complete ListBox component providing accessible selection lists with keyboard navigation.
|
|
564
|
+
* Supports both static children and dynamic collections, with single/multiple selection modes.
|
|
565
|
+
* Built on React Aria with full ARIA compliance and keyboard accessibility.
|
|
566
|
+
*
|
|
567
|
+
* @component
|
|
568
|
+
* @template T The type of items in the collection
|
|
569
|
+
* @param {ListBoxProps<T>} args - The properties passed to the ListBox component
|
|
570
|
+
* @param {React.ForwardedRef<HTMLDivElement>} ref - The ref to the listbox container
|
|
571
|
+
* @returns {React.ReactElement} A ListBox component
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* ```tsx
|
|
575
|
+
* <ListBox aria-label="Fruits" selectionMode="single">
|
|
576
|
+
* <ListBoxItem id="apple" textValue="Apple">Apple</ListBoxItem>
|
|
577
|
+
* <ListBoxItem id="banana" textValue="Banana">Banana</ListBoxItem>
|
|
578
|
+
* </ListBox>
|
|
579
|
+
* ```
|
|
580
|
+
* @public
|
|
581
|
+
*/
|
|
582
|
+
function ListBoxComponent<T>(args: ListBoxProps<T>, ref: React.ForwardedRef<HTMLDivElement>): React.ReactElement {
|
|
583
|
+
return (
|
|
584
|
+
<CollectionBuilder content={<AriaCollection {...(args as unknown as Parameters<typeof AriaCollection>[0])} />}>
|
|
585
|
+
{function buildCollection(collection: unknown) {
|
|
586
|
+
return <StandaloneListBox props={args as ListBoxProps<unknown>} listBoxRef={ref} collection={collection} />;
|
|
587
|
+
}}
|
|
588
|
+
</CollectionBuilder>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Standalone ListBox component that manages its own state and collection.
|
|
594
|
+
* This component is used internally by the main ListBox component after
|
|
595
|
+
* collection building is complete. It handles prop processing, state creation,
|
|
596
|
+
* and context management.
|
|
597
|
+
*
|
|
598
|
+
* @param {object} props - Component props
|
|
599
|
+
* @param {ListBoxProps<unknown>} props.props - The original ListBox props
|
|
600
|
+
* @param {React.ForwardedRef<HTMLDivElement>} props.listBoxRef - Reference to forward to the ListBox element
|
|
601
|
+
* @param {unknown} props.collection - Built collection from CollectionBuilder
|
|
602
|
+
* @returns {React.ReactElement} A complete ListBox with state management and optional context wrapping
|
|
603
|
+
* @internal
|
|
604
|
+
*/
|
|
605
|
+
const StandaloneListBox: React.FC<{
|
|
606
|
+
readonly props: ListBoxProps<unknown>;
|
|
607
|
+
readonly listBoxRef: ForwardedRef<HTMLDivElement>;
|
|
608
|
+
readonly collection: unknown;
|
|
609
|
+
}> = function StandaloneListBox({ props, listBoxRef, collection }) {
|
|
610
|
+
//
|
|
611
|
+
// Extract renderEmptyState before useProps processes it to avoid render prop corruption
|
|
612
|
+
//
|
|
613
|
+
const originalRenderEmptyState = props.renderEmptyState;
|
|
614
|
+
|
|
615
|
+
const { props: processedProps } = useProps(props);
|
|
616
|
+
const processedRef = useSafeObjectRef(listBoxRef);
|
|
617
|
+
const { state, contextState } = useListBoxState({ ...processedProps, collection });
|
|
618
|
+
|
|
619
|
+
const { renderEmptyState: _, ...cleanProcessedProps } = processedProps as ListBoxProps<unknown> & {
|
|
620
|
+
renderEmptyState?: unknown;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const content = (
|
|
624
|
+
<ListBoxInner
|
|
625
|
+
state={state}
|
|
626
|
+
listBoxRef={processedRef}
|
|
627
|
+
renderEmptyState={originalRenderEmptyState}
|
|
628
|
+
{...cleanProcessedProps}
|
|
629
|
+
/>
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
return renderWithOptionalContext(content, state, contextState);
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* A complete ListBox component providing accessible selection lists with keyboard navigation.
|
|
637
|
+
* Supports both static children and dynamic collections, with single/multiple selection modes.
|
|
638
|
+
* Built on React Aria with full ARIA compliance and keyboard accessibility.
|
|
639
|
+
*
|
|
640
|
+
* @component
|
|
641
|
+
* @example
|
|
642
|
+
* ```tsx
|
|
643
|
+
* <ListBox aria-label="Fruits" selectionMode="single">
|
|
644
|
+
* <ListBoxItem id="apple" textValue="Apple">Apple</ListBoxItem>
|
|
645
|
+
* <ListBoxItem id="banana" textValue="Banana">Banana</ListBoxItem>
|
|
646
|
+
* </ListBox>
|
|
647
|
+
* ```
|
|
648
|
+
* @public
|
|
649
|
+
*/
|
|
650
|
+
export const ListBox = withSlots('BentoListBox', ListBoxComponent);
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Collection component for building dynamic collections in ListBox.
|
|
654
|
+
* Re-exported from React Aria Collections.
|
|
655
|
+
* @public
|
|
656
|
+
*/
|
|
657
|
+
export { AriaCollection as Collection };
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Context for sharing ListBox state across components.
|
|
661
|
+
* Used internally by ListBoxItem and ListBoxSection components.
|
|
662
|
+
* @public
|
|
663
|
+
*/
|
|
664
|
+
export { ListStateContext };
|