MultiCombobox: Use virtualized list (#98318)

* List virtualization

* Remove lgos

* Fix onBlur and highlighted item

* Remove unnecessary blur values

* Mock getBoundingClientRect

* Add story for many options

* Fix PR feedback
This commit is contained in:
Tobias Skarhed 2025-01-07 11:38:13 +01:00 committed by GitHub
parent 6952bf473f
commit e9be53b1d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 116 additions and 21 deletions

View File

@ -76,7 +76,7 @@ type Story = StoryObj<typeof Combobox>;
export const Basic: Story = {}; export const Basic: Story = {};
async function generateOptions(amount: number): Promise<ComboboxOption[]> { export async function generateOptions(amount: number): Promise<ComboboxOption[]> {
return Array.from({ length: amount }, (_, index) => ({ return Array.from({ length: amount }, (_, index) => ({
label: 'Option ' + index, label: 'Option ' + index,
value: index.toString(), value: index.toString(),

View File

@ -111,6 +111,8 @@ function itemFilter<T extends string | number>(inputValue: string) {
const noop = () => {}; const noop = () => {};
const asyncNoop = () => Promise.resolve([]); const asyncNoop = () => Promise.resolve([]);
export const VIRTUAL_OVERSCAN_ITEMS = 4;
/** /**
* A performant Select replacement. * A performant Select replacement.
* *
@ -214,7 +216,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
count: items.length, count: items.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => MENU_OPTION_HEIGHT, estimateSize: () => MENU_OPTION_HEIGHT,
overscan: 4, overscan: VIRTUAL_OVERSCAN_ITEMS,
}; };
const rowVirtualizer = useVirtualizer(virtualizerOptions); const rowVirtualizer = useVirtualizer(virtualizerOptions);

View File

@ -1,7 +1,9 @@
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { useArgs } from '@storybook/preview-api'; import { useArgs, useEffect, useState } from '@storybook/preview-api';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { ComboboxOption } from './Combobox';
import { generateOptions } from './Combobox.story';
import { MultiCombobox } from './MultiCombobox'; import { MultiCombobox } from './MultiCombobox';
const meta: Meta<typeof MultiCombobox> = { const meta: Meta<typeof MultiCombobox> = {
@ -23,6 +25,9 @@ const commonArgs = {
export default meta; export default meta;
type storyArgs = React.ComponentProps<typeof MultiCombobox>;
type ManyOptionsArgs = storyArgs & { numberOfOptions?: number };
type Story = StoryObj<typeof MultiCombobox>; type Story = StoryObj<typeof MultiCombobox>;
export const Basic: Story = { export const Basic: Story = {
@ -42,3 +47,42 @@ export const Basic: Story = {
); );
}, },
}; };
const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...args }) => {
const [value, setValue] = useState<string[]>([]);
const [options, setOptions] = useState<ComboboxOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setTimeout(() => {
generateOptions(numberOfOptions).then((options) => {
setIsLoading(false);
setOptions(options);
setValue([options[5].value]);
});
}, 1000);
}, [numberOfOptions]);
const { onChange, ...rest } = args;
return (
<MultiCombobox
{...rest}
loading={isLoading}
options={options}
value={value}
onChange={(opts) => {
setValue(opts || []);
action('onChange')(opts);
}}
/>
);
};
export const ManyOptions: StoryObj<ManyOptionsArgs> = {
args: {
numberOfOptions: 1e4,
options: undefined,
value: undefined,
},
render: ManyOptionsStory,
};

View File

@ -5,6 +5,21 @@ import React from 'react';
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox'; import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
describe('MultiCombobox', () => { describe('MultiCombobox', () => {
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
width: 120,
height: 120,
top: 0,
left: 0,
bottom: 0,
right: 0,
}));
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
value: mockGetBoundingClientRect,
});
});
let user: UserEvent; let user: UserEvent;
beforeEach(() => { beforeEach(() => {

View File

@ -1,19 +1,27 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCombobox, useMultipleSelection } from 'downshift'; import { useCombobox, useMultipleSelection } from 'downshift';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
import { Checkbox } from '../Forms/Checkbox'; import { Checkbox } from '../Forms/Checkbox';
import { Box } from '../Layout/Box/Box'; import { Box } from '../Layout/Box/Box';
import { Stack } from '../Layout/Stack/Stack';
import { Portal } from '../Portal/Portal'; import { Portal } from '../Portal/Portal';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { Text } from '../Text/Text'; import { Text } from '../Text/Text';
import { Tooltip } from '../Tooltip'; import { Tooltip } from '../Tooltip';
import { ComboboxOption, ComboboxBaseProps, AutoSizeConditionals, itemToString } from './Combobox'; import {
ComboboxOption,
ComboboxBaseProps,
AutoSizeConditionals,
itemToString,
VIRTUAL_OVERSCAN_ITEMS,
} from './Combobox';
import { OptionListItem } from './OptionListItem'; import { OptionListItem } from './OptionListItem';
import { ValuePill } from './ValuePill'; import { ValuePill } from './ValuePill';
import { getComboboxStyles } from './getComboboxStyles'; import { getComboboxStyles, MENU_OPTION_HEIGHT } from './getComboboxStyles';
import { getMultiComboboxStyles } from './getMultiComboboxStyles'; import { getMultiComboboxStyles } from './getMultiComboboxStyles';
import { useComboboxFloat } from './useComboboxFloat'; import { useComboboxFloat } from './useComboboxFloat';
import { useMeasureMulti } from './useMeasureMulti'; import { useMeasureMulti } from './useMeasureMulti';
@ -40,7 +48,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
const styles = useStyles2(getComboboxStyles); const styles = useStyles2(getComboboxStyles);
const [items, _baseSetItems] = useState(isAsync ? [] : options); const [items, baseSetItems] = useState(isAsync ? [] : options);
// TODO: Improve this with async
useEffect(() => {
baseSetItems(isAsync ? [] : options);
}, [options, isAsync]);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen); const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
@ -96,8 +110,12 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
return { return {
...changes, ...changes,
isOpen: true, isOpen: true,
defaultHighlightedIndex: 0, highlightedIndex: state.highlightedIndex,
}; };
case useCombobox.stateChangeTypes.InputBlur:
setInputValue('');
setIsOpen(false);
return changes;
default: default:
return changes; return changes;
} }
@ -115,10 +133,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here
} }
break; break;
case useCombobox.stateChangeTypes.InputBlur:
setIsOpen(false);
setInputValue('');
break;
case useCombobox.stateChangeTypes.InputChange: case useCombobox.stateChangeTypes.InputChange:
setInputValue(newInputValue ?? ''); setInputValue(newInputValue ?? '');
break; break;
@ -128,6 +142,15 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
}, },
}); });
const virtualizerOptions = {
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => MENU_OPTION_HEIGHT,
overscan: VIRTUAL_OVERSCAN_ITEMS,
};
const rowVirtualizer = useVirtualizer(virtualizerOptions);
const visibleItems = isOpen ? selectedItems : selectedItems.slice(0, shownItems); const visibleItems = isOpen ? selectedItems : selectedItems.slice(0, shownItems);
return ( return (
@ -190,21 +213,32 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
> >
{isOpen && ( {isOpen && (
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef}> <ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef}>
<ul> <ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
{items.map((item, index) => { {rowVirtualizer.getVirtualItems().map((virtualRow) => {
const index = virtualRow.index;
const item = items[index];
const itemProps = getItemProps({ item, index }); const itemProps = getItemProps({ item, index });
const isSelected = isOptionSelected(item); const isSelected = isOptionSelected(item);
const id = 'multicombobox-option-' + item.value.toString(); const id = 'multicombobox-option-' + item.value.toString();
return ( return (
<li <li
key={item.value} key={`${item.value}-${index}`}
data-index={index}
{...itemProps} {...itemProps}
style={highlightedIndex === index ? { backgroundColor: 'blue' } : {}} className={cx(styles.option, { [styles.optionFocused]: highlightedIndex === index })}
style={{ height: virtualRow.size, transform: `translateY(${virtualRow.start}px)` }}
> >
{' '} <Stack direction="row" alignItems="center">
{/* Add styling with virtualization */} <Checkbox
<Checkbox key={id} value={isSelected} aria-labelledby={id} /> key={id}
<OptionListItem option={item} id={id} /> value={isSelected}
aria-labelledby={id}
onClick={(e) => {
e.stopPropagation();
}}
/>
<OptionListItem option={item} id={id} />
</Stack>
</li> </li>
); );
})} })}