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
pull/98611/head
Tobias Skarhed 4 months ago committed by GitHub
parent 6952bf473f
commit e9be53b1d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      packages/grafana-ui/src/components/Combobox/Combobox.story.tsx
  2. 4
      packages/grafana-ui/src/components/Combobox/Combobox.tsx
  3. 48
      packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx
  4. 15
      packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx
  5. 66
      packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx

@ -76,7 +76,7 @@ type Story = StoryObj<typeof Combobox>;
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) => ({
label: 'Option ' + index,
value: index.toString(),

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

@ -1,7 +1,9 @@
import { action } from '@storybook/addon-actions';
import { useArgs } from '@storybook/preview-api';
import type { Meta, StoryObj } from '@storybook/react';
import { useArgs, useEffect, useState } from '@storybook/preview-api';
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { ComboboxOption } from './Combobox';
import { generateOptions } from './Combobox.story';
import { MultiCombobox } from './MultiCombobox';
const meta: Meta<typeof MultiCombobox> = {
@ -23,6 +25,9 @@ const commonArgs = {
export default meta;
type storyArgs = React.ComponentProps<typeof MultiCombobox>;
type ManyOptionsArgs = storyArgs & { numberOfOptions?: number };
type Story = StoryObj<typeof MultiCombobox>;
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,
};

@ -5,6 +5,21 @@ import React from 'react';
import { MultiCombobox, MultiComboboxProps } from './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;
beforeEach(() => {

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

Loading…
Cancel
Save