mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
6952bf473f
commit
e9be53b1d6
@ -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} />
|
||||
<OptionListItem option={item} id={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…
Reference in New Issue
Block a user