diff --git a/.changeset/proud-chicken-impress.md b/.changeset/proud-chicken-impress.md new file mode 100644 index 0000000000..123e5647ab --- /dev/null +++ b/.changeset/proud-chicken-impress.md @@ -0,0 +1,7 @@ +--- +"@nextui-org/autocomplete": patch +"@nextui-org/listbox": patch +"@nextui-org/theme": patch +--- + +Virtualization support added to Listbox & Autocomplete diff --git a/apps/docs/components/docs/sidebar.tsx b/apps/docs/components/docs/sidebar.tsx index ac25317ad1..d43227582d 100644 --- a/apps/docs/components/docs/sidebar.tsx +++ b/apps/docs/components/docs/sidebar.tsx @@ -171,7 +171,7 @@ function TreeItem(props: TreeItemProps) { {isUpdated && ( { + const items = [ + "Cat", + "Dog", + "Elephant", + "Lion", + "Tiger", + "Giraffe", + "Dolphin", + "Penguin", + "Zebra", + "Shark", + "Whale", + "Otter", + "Crocodile", + ]; + + const dataset = []; + + for (let i = 0; i < n; i++) { + const item = items[i % items.length]; + + dataset.push({ + label: \`\${item}\${i}\`, + value: \`\${item.toLowerCase()}\${i}\`, + description: "Sample description", + }); + } + + return dataset; +}; + +export default function App() { + const items = generateItems(1000); + + return ( +
+ + {(item) => ( + + {item.label} + + )} + +
+ ); +}`; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/autocomplete/virtualization-max-listbox-height.ts b/apps/docs/content/components/autocomplete/virtualization-max-listbox-height.ts new file mode 100644 index 0000000000..eb630f7340 --- /dev/null +++ b/apps/docs/content/components/autocomplete/virtualization-max-listbox-height.ts @@ -0,0 +1,64 @@ +const App = `import {Autocomplete, AutocompleteItem} from "@nextui-org/react"; + +const generateItems = (n) => { + const items = [ + "Cat", + "Dog", + "Elephant", + "Lion", + "Tiger", + "Giraffe", + "Dolphin", + "Penguin", + "Zebra", + "Shark", + "Whale", + "Otter", + "Crocodile", + ]; + + const dataset = []; + + for (let i = 0; i < n; i++) { + const item = items[i % items.length]; + + dataset.push({ + label: \`\${item}\${i}\`, + value: \`\${item.toLowerCase()}\${i}\`, + description: "Sample description", + }); + } + + return dataset; +}; + +export default function App() { + const items = generateItems(1000); + + return ( +
+ + {(item) => ( + + {item.label} + + )} + +
+ ); +}`; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/autocomplete/virtualization-ten-thousand.ts b/apps/docs/content/components/autocomplete/virtualization-ten-thousand.ts new file mode 100644 index 0000000000..ab174f82c7 --- /dev/null +++ b/apps/docs/content/components/autocomplete/virtualization-ten-thousand.ts @@ -0,0 +1,63 @@ +const App = `import {Autocomplete, AutocompleteItem} from "@nextui-org/react"; + +const generateItems = (n) => { + const items = [ + "Cat", + "Dog", + "Elephant", + "Lion", + "Tiger", + "Giraffe", + "Dolphin", + "Penguin", + "Zebra", + "Shark", + "Whale", + "Otter", + "Crocodile", + ]; + + const dataset = []; + + for (let i = 0; i < n; i++) { + const item = items[i % items.length]; + + dataset.push({ + label: \`\${item}\${i}\`, + value: \`\${item.toLowerCase()}\${i}\`, + description: "Sample description", + }); + } + + return dataset; +}; + +export default function App() { + const items = generateItems(10000); + + return ( +
+ + {(item) => ( + + {item.label} + + )} + +
+ ); +}`; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/autocomplete/virtualization.ts b/apps/docs/content/components/autocomplete/virtualization.ts new file mode 100644 index 0000000000..254a3072f2 --- /dev/null +++ b/apps/docs/content/components/autocomplete/virtualization.ts @@ -0,0 +1,63 @@ +const App = `import {Autocomplete, AutocompleteItem} from "@nextui-org/react"; + +const generateItems = (n) => { + const items = [ + "Cat", + "Dog", + "Elephant", + "Lion", + "Tiger", + "Giraffe", + "Dolphin", + "Penguin", + "Zebra", + "Shark", + "Whale", + "Otter", + "Crocodile", + ]; + + const dataset = []; + + for (let i = 0; i < n; i++) { + const item = items[i % items.length]; + + dataset.push({ + label: \`\${item}\${i}\`, + value: \`\${item.toLowerCase()}\${i}\`, + description: "Sample description", + }); + } + + return dataset; +}; + +export default function App() { + const items = generateItems(1000); + + return ( +
+ + {(item) => ( + + {item.label} + + )} + +
+ ); +}`; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/autocomplete.mdx b/apps/docs/content/docs/components/autocomplete.mdx index b826b21556..6bef6b4a21 100644 --- a/apps/docs/content/docs/components/autocomplete.mdx +++ b/apps/docs/content/docs/components/autocomplete.mdx @@ -13,7 +13,7 @@ An autocomplete combines a text input with a listbox, allowing users to filter a --- - + ## Installation @@ -24,11 +24,10 @@ An autocomplete combines a text input with a listbox, allowing users to filter a npm: "npm install @nextui-org/autocomplete", yarn: "yarn add @nextui-org/autocomplete", pnpm: "pnpm add @nextui-org/autocomplete", - bun: "bun add @nextui-org/autocomplete" + bun: "bun add @nextui-org/autocomplete", }} /> - ## Import NextUI exports 3 autocomplete-related components: @@ -84,7 +83,7 @@ the end of the label and the autocomplete will be required. ### Read Only -If you pass the `isReadOnly` property to the Autocomplete, the Listbox will open to display +If you pass the `isReadOnly` property to the Autocomplete, the Listbox will open to display all available options, but users won't be able to select any of the listed options. @@ -243,7 +242,10 @@ You can customize the autocomplete items by modifying the `AutocompleteItem` chi By default, a message `No results found.` will be shown if there is no result matching a query with your filter. You can customize the empty content message by modifying the `emptyContent` in `listboxProps`. - + ### Custom Filtering @@ -316,6 +318,36 @@ import {useInfiniteScroll} from "@nextui-org/use-infinite-scroll"; files={autocompleteContent.asyncLoadingItems} /> +### Virtualization + +Autocomplete supports virtualization, in the example below we are using the `isVirtualized` prop to enable virtualization. + + + +> **Note**: The virtualization strategy is based on the [@tanstack/react-virtual](https://tanstack.com/virtual/latest) package, which provides efficient rendering of large lists by only rendering items that are visible in the viewport. + +#### Ten Thousand Items + +Virtualization with 10,000 items. + + + +#### Max Listbox Height + +The `maxListboxHeight` prop is used to set the maximum height of the listbox. This is required when using virtualization. By default, it's set to `256`. + + + +#### Custom Item Height + +The `itemHeight` prop is used to set the height of each item in the listbox. This is required when using virtualization. By default, it's set to `32`. + + + ### With Sections You can use the `AutocompleteSection` component to group autocomplete items. @@ -412,57 +444,60 @@ properties to customize the popover, listbox and input components. ### Autocomplete Props -| Attribute | Type | Description | Default | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | -| children\* | `ReactNode[]` | The children to render. Usually a list of `AutocompleteItem` and `AutocompleteSection` elements. | - | -| label | `ReactNode` | The content to display as the label. | - | -| name | `string` | The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). | - | -| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the Autocomplete. | `flat` | -| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the Autocomplete. | `default` | -| size | `sm` \| `md` \| `lg` | The size of the Autocomplete. | `md` | -| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the Autocomplete. | - | -| items | [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) | The list of Autocomplete items. (controlled) | - | -| defaultItems | [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) | The list of Autocomplete items (uncontrolled). | - | -| inputValue | `string` | The value of the Autocomplete input (controlled). | - | -| defaultInputValue | `string` | The value of the Autocomplete input (uncontrolled). | - | -| allowsCustomValue | `boolean` | Whether the Autocomplete allows a non-item matching input value to be set. | `false` | -| allowsEmptyCollection | `boolean` | Whether the autocomplete allows the menu to be open when the collection is empty. | `true` | -| shouldCloseOnBlur | `boolean` | Whether the Autocomplete should close when the input is blurred. | `true` | -| placeholder | `string` | Temporary text that occupies the text input when it is empty. | - | -| description | `ReactNode` | A description for the field. Provides a hint such as specific requirements for what to choose. | - | -| menuTrigger | `focus` \| `input` \| `manual` | The action that causes the menu to open. | `focus` | -| labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | -| selectedKey | `React.Key` | The currently selected key in the collection (controlled). | - | -| defaultSelectedKey | `React.Key` | The initial selected key in the collection (uncontrolled). | - | -| disabledKeys | `all` \| `React.Key[]` | The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. | - | -| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | An error message to display below the field. | - | -| validate | `(value: { inputValue: string, selectedKey: React.Key }) => ValidationError | true | null | undefined` | Validate input values when committing (e.g. on blur), and return error messages for invalid values. | - | -| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA.| `aria` | -| startContent | `ReactNode` | Element to be rendered in the left side of the Autocomplete. | - | -| endContent | `ReactNode` | Element to be rendered in the right side of the Autocomplete. | - | -| autoFocus | `boolean` | Whether the Autocomplete should be focused on render. | `false` | -| defaultFilter | `(textValue: string, inputValue: string) => boolean` | The filter function used to determine if a option should be included in the Autocomplete list. | - | -| filterOptions | [Intl.CollatorOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Collator/Collator) | The options used to create the collator used for filtering. | `{ sensitivity: 'base'}` | -| isReadOnly | `boolean` | Whether the Autocomplete is read only. | `false` | -| isRequired | `boolean` | Whether the Autocomplete is required. | `false` | -| isInvalid | `boolean` | Whether the Autocomplete is invalid. | `false` | -| isDisabled | `boolean` | Whether the Autocomplete is disabled. | `false` | -| fullWidth | `boolean` | Whether the input should take up the width of its parent. | `true` | -| selectorIcon | `ReactNode` | The icon that represents the autocomplete open state. Usually a chevron icon. | - | -| clearIcon | `ReactNode` | The icon to be used in the clear button. Usually a cross icon. | - | -| showScrollIndicators | `boolean` | Whether the scroll indicators should be shown when the listbox is scrollable. | `true` | -| scrollRef | `React.RefObject` | A ref to the scrollable element. | - | -| inputProps | [InputProps](/docs/components/input#api) | Props to be passed to the Input component. | - | -| popoverProps | [PopoverProps](/docs/components/popover#api) | Props to be passed to the Popover component. | - | -| listboxProps | [ListboxProps](/docs/components/listbox#api) | Props to be passed to the Listbox component. | - | -| scrollShadowProps | [ScrollShadowProps](/docs/components/scroll-shadow#api) | Props to be passed to the ScrollShadow component. | - | -| selectorButtonProps | [ButtonProps](/docs/components/button#api) | Props to be passed to the selector button. | - | -| clearButtonProps | [ButtonProps](/docs/components/button#api) | Props to be passed to the clear button. | - | -| isClearable | `boolean` | Whether the clear button should be shown. | `true` | -| disableClearable | `boolean` | Whether the clear button should be hidden. (**Deprecated**) Use `isClearable` instead. | `false` | -| disableAnimation | `boolean` | Whether the Autocomplete should be animated. | `true` | -| disableSelectorIconRotation | `boolean` | Whether the select should disable the rotation of the selector icon. | `false` | -| classNames | `Record<"base"| "listboxWrapper"| "listbox"| "popoverContent" | "endContentWrapper"| "clearButton" | "selectorButton", string>` | Allows to set custom class names for the Autocomplete slots. | - | +| Attribute | Type | Description | Default | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| children\* | `ReactNode[]` | The children to render. Usually a list of `AutocompleteItem` and `AutocompleteSection` elements. | - | +| label | `ReactNode` | The content to display as the label. | - | +| name | `string` | The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). | - | +| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the Autocomplete. | `flat` | +| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the Autocomplete. | `default` | +| size | `sm` \| `md` \| `lg` | The size of the Autocomplete. | `md` | +| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the Autocomplete. | - | +| items | [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) | The list of Autocomplete items. (controlled) | - | +| defaultItems | [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) | The list of Autocomplete items (uncontrolled). | - | +| inputValue | `string` | The value of the Autocomplete input (controlled). | - | +| defaultInputValue | `string` | The value of the Autocomplete input (uncontrolled). | - | +| allowsCustomValue | `boolean` | Whether the Autocomplete allows a non-item matching input value to be set. | `false` | +| allowsEmptyCollection | `boolean` | Whether the autocomplete allows the menu to be open when the collection is empty. | `true` | +| shouldCloseOnBlur | `boolean` | Whether the Autocomplete should close when the input is blurred. | `true` | +| placeholder | `string` | Temporary text that occupies the text input when it is empty. | - | +| description | `ReactNode` | A description for the field. Provides a hint such as specific requirements for what to choose. | - | +| menuTrigger | `focus` \| `input` \| `manual` | The action that causes the menu to open. | `focus` | +| labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | +| selectedKey | `React.Key` | The currently selected key in the collection (controlled). | - | +| defaultSelectedKey | `React.Key` | The initial selected key in the collection (uncontrolled). | - | +| disabledKeys | `all` \| `React.Key[]` | The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. | - | +| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | An error message to display below the field. | - | +| validate | `(value: { inputValue: string, selectedKey: React.Key }) => ValidationError | true | null | undefined` | Validate input values when committing (e.g. on blur), and return error messages for invalid values. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA. | `aria` | +| startContent | `ReactNode` | Element to be rendered in the left side of the Autocomplete. | - | +| endContent | `ReactNode` | Element to be rendered in the right side of the Autocomplete. | - | +| autoFocus | `boolean` | Whether the Autocomplete should be focused on render. | `false` | +| defaultFilter | `(textValue: string, inputValue: string) => boolean` | The filter function used to determine if a option should be included in the Autocomplete list. | - | +| filterOptions | [Intl.CollatorOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Collator/Collator) | The options used to create the collator used for filtering. | `{ sensitivity: 'base'}` | +| maxListboxHeight | `number` | The maximum height of the listbox in pixels. Required when using virtualization. | `256` | +| itemHeight | `number` | The fixed height of each item in pixels. Required when using virtualization. | `32` | +| isVirtualized | `boolean` | Whether to enable virtualization. By default, it's enabled when the number of items exceeds 50. | `undefined` | +| isReadOnly | `boolean` | Whether the Autocomplete is read only. | `false` | +| isRequired | `boolean` | Whether the Autocomplete is required. | `false` | +| isInvalid | `boolean` | Whether the Autocomplete is invalid. | `false` | +| isDisabled | `boolean` | Whether the Autocomplete is disabled. | `false` | +| fullWidth | `boolean` | Whether the input should take up the width of its parent. | `true` | +| selectorIcon | `ReactNode` | The icon that represents the autocomplete open state. Usually a chevron icon. | - | +| clearIcon | `ReactNode` | The icon to be used in the clear button. Usually a cross icon. | - | +| showScrollIndicators | `boolean` | Whether the scroll indicators should be shown when the listbox is scrollable. | `true` | +| scrollRef | `React.RefObject` | A ref to the scrollable element. | - | +| inputProps | [InputProps](/docs/components/input#api) | Props to be passed to the Input component. | - | +| popoverProps | [PopoverProps](/docs/components/popover#api) | Props to be passed to the Popover component. | - | +| listboxProps | [ListboxProps](/docs/components/listbox#api) | Props to be passed to the Listbox component. | - | +| scrollShadowProps | [ScrollShadowProps](/docs/components/scroll-shadow#api) | Props to be passed to the ScrollShadow component. | - | +| selectorButtonProps | [ButtonProps](/docs/components/button#api) | Props to be passed to the selector button. | - | +| clearButtonProps | [ButtonProps](/docs/components/button#api) | Props to be passed to the clear button. | - | +| isClearable | `boolean` | Whether the clear button should be shown. | `true` | +| disableClearable | `boolean` | Whether the clear button should be hidden. (**Deprecated**) Use `isClearable` instead. | `false` | +| disableAnimation | `boolean` | Whether the Autocomplete should be animated. | `true` | +| disableSelectorIconRotation | `boolean` | Whether the select should disable the rotation of the selector icon. | `false` | +| classNames | `Record<"base"| "listboxWrapper"| "listbox"| "popoverContent" | "endContentWrapper"| "clearButton" | "selectorButton", string>` | Allows to set custom class names for the Autocomplete slots. | - | ### Autocomplete Events diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 6f84d1c979..5badcf3d66 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -110,13 +110,30 @@ interface Props extends Omit, keyof ComboBoxProps * Callback fired when the select menu is closed. */ onClose?: () => void; + /** + * Whether to enable virtualization of the listbox items. + * By default, virtualization is automatically enabled when the number of items is greater than 50. + * @default undefined + */ + isVirtualized?: boolean; } export type UseAutocompleteProps = Props & Omit & ComboBoxProps & AsyncLoadable & - AutocompleteVariantProps; + AutocompleteVariantProps & { + /** + * The height of each item in the listbox. + * This is required for virtualized listboxes to calculate the height of each item. + */ + itemHeight?: number; + /** + * The max height of the listbox (which will be rendered in a popover). + * This is required for virtualized listboxes to set the maximum height of the listbox. + */ + maxListboxHeight?: number; + }; export function useAutocomplete(originalProps: UseAutocompleteProps) { const globalContext = useProviderContext(); @@ -158,6 +175,9 @@ export function useAutocomplete(originalProps: UseAutocomplete clearButtonProps = {}, showScrollIndicators = true, allowsCustomValue = false, + isVirtualized, + maxListboxHeight = 256, + itemHeight = 32, validationBehavior = globalContext?.validationBehavior ?? "aria", className, classNames, @@ -425,14 +445,25 @@ export function useAutocomplete(originalProps: UseAutocomplete onClick: chain(slotsProps.inputProps.onClick, otherProps.onClick), } as unknown as InputProps); - const getListBoxProps = () => - ({ + const getListBoxProps = () => { + // Use isVirtualized prop if defined, otherwise fallback to default behavior + const shouldVirtualize = isVirtualized ?? state.collection.size > 50; + + return { state, ref: listBoxRef, + isVirtualized: shouldVirtualize, + virtualization: shouldVirtualize + ? { + maxListboxHeight, + itemHeight, + } + : undefined, ...mergeProps(slotsProps.listboxProps, listBoxProps, { shouldHighlightOnFocus: true, }), - } as ListboxProps); + } as ListboxProps; + }; const getPopoverProps = (props: DOMAttributes = {}) => { const popoverProps = mergeProps(slotsProps.popoverProps, props); @@ -479,6 +510,9 @@ export function useAutocomplete(originalProps: UseAutocomplete props?.className, ), }), + style: { + maxHeight: originalProps.maxListboxHeight ?? 256, + }, }); const getEndContentWrapperProps: PropGetter = (props: any = {}) => ({ diff --git a/packages/components/autocomplete/stories/autocomplete.stories.tsx b/packages/components/autocomplete/stories/autocomplete.stories.tsx index ca4db45df2..ff8e63d16d 100644 --- a/packages/components/autocomplete/stories/autocomplete.stories.tsx +++ b/packages/components/autocomplete/stories/autocomplete.stories.tsx @@ -100,6 +100,58 @@ const items = animalsData.map((item) => ( )); +interface LargeDatasetSchema { + label: string; + value: string; + description: string; +} + +function generateLargeDataset(n: number): LargeDatasetSchema[] { + const dataset: LargeDatasetSchema[] = []; + + const items = [ + "Cat", + "Dog", + "Elephant", + "Lion", + "Tiger", + "Giraffe", + "Dolphin", + "Penguin", + "Zebra", + "Shark", + "Whale", + "Otter", + "Crocodile", + ]; + + for (let i = 0; i < n; i++) { + const item = items[i % items.length]; + + dataset.push({ + label: `${item}${i}`, + value: `${item.toLowerCase()}${i}`, + description: "Sample description", + }); + } + + return dataset; +} + +const LargeDatasetTemplate = (args: AutocompleteProps & {numItems: number}) => { + const largeDataset = generateLargeDataset(args.numItems); + + return ( + + {largeDataset.map((item, index) => ( + + {item.label} + + ))} + + ); +}; + const Template = (args: AutocompleteProps) => ( Red Panda @@ -1061,3 +1113,42 @@ export const FullyControlled = { ...defaultProps, }, }; + +export const OneThousandList = { + render: LargeDatasetTemplate, + args: { + ...defaultProps, + placeholder: "Search...", + numItems: 1000, + }, +}; + +export const TenThousandList = { + render: LargeDatasetTemplate, + args: { + ...defaultProps, + placeholder: "Search...", + numItems: 10000, + }, +}; + +export const CustomMaxListboxHeight = { + render: LargeDatasetTemplate, + args: { + ...defaultProps, + placeholder: "Search...", + numItems: 1000, + maxListboxHeight: 400, + }, +}; + +export const CustomItemHeight = { + render: LargeDatasetTemplate, + args: { + ...defaultProps, + placeholder: "Search...", + numItems: 1000, + maxListboxHeight: 400, + itemHeight: 40, + }, +}; diff --git a/packages/components/listbox/package.json b/packages/components/listbox/package.json index e8bded4e2c..90c4164bf4 100644 --- a/packages/components/listbox/package.json +++ b/packages/components/listbox/package.json @@ -40,11 +40,12 @@ "@nextui-org/system": ">=2.3.0-beta.0" }, "dependencies": { + "@nextui-org/aria-utils": "workspace:*", + "@nextui-org/divider": "workspace:*", "@nextui-org/react-utils": "workspace:*", "@nextui-org/shared-utils": "workspace:*", - "@nextui-org/divider": "workspace:*", - "@nextui-org/aria-utils": "workspace:*", "@nextui-org/use-is-mobile": "workspace:*", + "@tanstack/react-virtual": "^3.10.9", "@react-aria/utils": "3.25.2", "@react-aria/listbox": "3.13.3", "@react-stately/list": "3.10.8", @@ -54,14 +55,14 @@ "@react-types/shared": "3.24.1" }, "devDependencies": { - "@nextui-org/theme": "workspace:*", - "@nextui-org/system": "workspace:*", - "clean-package": "2.2.0", + "@nextui-org/avatar": "workspace:*", + "@nextui-org/chip": "workspace:*", + "@nextui-org/scroll-shadow": "workspace:*", "@nextui-org/shared-icons": "workspace:*", "@nextui-org/stories-utils": "workspace:*", - "@nextui-org/scroll-shadow": "workspace:*", - "@nextui-org/chip": "workspace:*", - "@nextui-org/avatar": "workspace:*", + "@nextui-org/system": "workspace:*", + "@nextui-org/theme": "workspace:*", + "clean-package": "2.2.0", "react": "^18.0.0", "react-dom": "^18.0.0" }, diff --git a/packages/components/listbox/src/listbox.tsx b/packages/components/listbox/src/listbox.tsx index ffc0fa6f82..ca6c4b227c 100644 --- a/packages/components/listbox/src/listbox.tsx +++ b/packages/components/listbox/src/listbox.tsx @@ -2,13 +2,26 @@ import {ForwardedRef, ReactElement, Ref} from "react"; import {forwardRef} from "@nextui-org/system"; import {mergeProps} from "@react-aria/utils"; -import {UseListboxProps, useListbox} from "./use-listbox"; +import {UseListboxProps, UseListboxReturn, useListbox} from "./use-listbox"; import ListboxSection from "./listbox-section"; import ListboxItem from "./listbox-item"; +import VirtualizedListbox from "./virtualized-listbox"; -interface Props extends UseListboxProps {} +export interface VirtualizationProps { + maxListboxHeight: number; + itemHeight: number; +} + +interface Props extends UseListboxProps { + isVirtualized?: boolean; + virtualization?: VirtualizationProps; +} function Listbox(props: Props, ref: ForwardedRef) { + const {isVirtualized, ...restProps} = props; + + const useListboxProps = useListbox({...restProps, ref}); + const { Component, state, @@ -24,7 +37,13 @@ function Listbox(props: Props, ref: ForwardedRef({...props, ref}); + } = useListboxProps; + + if (isVirtualized) { + return ( + )} {...(useListboxProps as UseListboxReturn)} /> + ); + } const content = ( diff --git a/packages/components/listbox/src/virtualized-listbox.tsx b/packages/components/listbox/src/virtualized-listbox.tsx new file mode 100644 index 0000000000..ee25f01dcd --- /dev/null +++ b/packages/components/listbox/src/virtualized-listbox.tsx @@ -0,0 +1,159 @@ +import {ReactElement, useRef} from "react"; +import {forwardRef} from "@nextui-org/system"; +import {mergeProps} from "@react-aria/utils"; +import {useVirtualizer} from "@tanstack/react-virtual"; +import {isEmpty} from "@nextui-org/shared-utils"; + +import ListboxItem from "./listbox-item"; +import ListboxSection from "./listbox-section"; +import {VirtualizationProps} from "./listbox"; +import {UseListboxReturn} from "./use-listbox"; + +interface Props extends UseListboxReturn { + isVirtualized?: boolean; + virtualization?: VirtualizationProps; +} + +function VirtualizedListbox(props: Props) { + const { + Component, + state, + color, + variant, + itemClasses, + getBaseProps, + topContent, + bottomContent, + hideEmptyContent, + hideSelectedIcon, + shouldHighlightOnFocus, + disableAnimation, + getEmptyContentProps, + getListProps, + } = props; + + const {virtualization} = props; + + if ( + !virtualization || + (!isEmpty(virtualization) && !virtualization.maxListboxHeight && !virtualization.itemHeight) + ) { + throw new Error( + "You are using a virtualized listbox. VirtualizedListbox requires 'virtualization' props with 'maxListboxHeight' and 'itemHeight' properties. This error might have originated from autocomplete components that use VirtualizedListbox. Please provide these props to use the virtualized listbox.", + ); + } + const {maxListboxHeight, itemHeight} = virtualization; + + const listHeight = Math.min(maxListboxHeight, itemHeight * state.collection.size); + + const parentRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: state.collection.size, + getScrollElement: () => parentRef.current, + estimateSize: () => itemHeight, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + + const renderRow = ({ + index, + style: virtualizerStyle, + }: { + index: number; + style: React.CSSProperties; + }) => { + const item = [...state.collection][index]; + + const itemProps = { + color, + item, + state, + variant, + disableAnimation, + hideSelectedIcon, + ...item.props, + }; + + if (item.type === "section") { + return ( + + ); + } + + let listboxItem = ( + + ); + + if (item.wrapper) { + listboxItem = item.wrapper(listboxItem); + } + + return listboxItem; + }; + + const content = ( + + {!state.collection.size && !hideEmptyContent && ( +
  • +
    +
  • + )} +
    + {listHeight > 0 && itemHeight > 0 && ( +
    + {virtualItems.map((virtualItem) => + renderRow({ + index: virtualItem.index, + style: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)`, + }, + }), + )} +
    + )} +
    +
    + ); + + return ( +
    + {topContent} + {content} + {bottomContent} +
    + ); +} + +VirtualizedListbox.displayName = "NextUI.VirtualizedListbox"; + +// forwardRef doesn't support generic parameters, so cast the result to the correct type +export default forwardRef(VirtualizedListbox) as (props: Props) => ReactElement; diff --git a/packages/core/theme/src/components/autocomplete.ts b/packages/core/theme/src/components/autocomplete.ts index a1c6af6a9b..9ca6b7f061 100644 --- a/packages/core/theme/src/components/autocomplete.ts +++ b/packages/core/theme/src/components/autocomplete.ts @@ -5,7 +5,7 @@ import {tv} from "../utils/tv"; const autocomplete = tv({ slots: { base: "group inline-flex flex-column w-full", - listboxWrapper: "scroll-py-6 max-h-64 w-full", + listboxWrapper: "scroll-py-6 w-full", listbox: "", popoverContent: "w-full p-1 overflow-hidden", endContentWrapper: "relative flex h-full items-center -mr-2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e681dea4b..e61a5a7797 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1783,6 +1783,9 @@ importers: '@react-types/shared': specifier: 3.24.1 version: 3.24.1(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.10.9 + version: 3.10.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@nextui-org/avatar': specifier: workspace:* @@ -7524,6 +7527,15 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' + '@tanstack/react-virtual@3.10.9': + resolution: {integrity: sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==} + peerDependencies: + react: ^18.2.0 + react-dom: ^18.2.0 + + '@tanstack/virtual-core@3.10.9': + resolution: {integrity: sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw==} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -21187,6 +21199,14 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.15(ts-node@10.9.2(@swc/core@1.9.2(@swc/helpers@0.5.15))(@types/node@20.2.5)(typescript@5.6.3)) + '@tanstack/react-virtual@3.10.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.10.9 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.10.9': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2