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 = {};
|
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(),
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
Loading…
Reference in New Issue
Block a user