New Select: Use virtual list (#89290)

* use react-virtual

* Render story with 100k items

* Dyanmic height and TanStack

* Remove weird item

* Add numberOfOptions to story

* Update class name

* Update class name
pull/89450/head
Tobias Skarhed 11 months ago committed by GitHub
parent 6834038e91
commit 924a94cf80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      packages/grafana-ui/package.json
  2. 56
      packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx
  3. 84
      packages/grafana-ui/src/components/Combobox/Combobox.tsx
  4. 20
      yarn.lock

@ -61,6 +61,7 @@
"@react-aria/focus": "3.17.1",
"@react-aria/overlays": "3.22.1",
"@react-aria/utils": "3.24.1",
"@tanstack/react-virtual": "^3.5.1",
"ansicolor": "1.1.100",
"calculate-size": "1.1.1",
"classnames": "2.5.1",

@ -1,10 +1,15 @@
import { action } from '@storybook/addon-actions';
import { Meta, StoryFn } from '@storybook/react';
import React, { useState } from 'react';
import { Meta, StoryFn, StoryObj } from '@storybook/react';
import { Chance } from 'chance';
import React, { ComponentProps, useMemo, useState } from 'react';
import { Combobox } from './Combobox';
import { Combobox, Option, Value } from './Combobox';
const meta: Meta<typeof Combobox> = {
const chance = new Chance();
type PropsAndCustomArgs = ComponentProps<typeof Combobox> & { numberOfOptions: number };
const meta: Meta<PropsAndCustomArgs> = {
title: 'Forms/Combobox',
component: Combobox,
args: {
@ -28,9 +33,11 @@ const meta: Meta<typeof Combobox> = {
],
value: 'banana',
},
render: (args) => <BasicWithState {...args} />,
};
export const Basic: StoryFn<typeof Combobox> = (args) => {
const BasicWithState: StoryFn<typeof Combobox> = (args) => {
const [value, setValue] = useState(args.value);
return (
<Combobox
@ -44,4 +51,43 @@ export const Basic: StoryFn<typeof Combobox> = (args) => {
);
};
type Story = StoryObj<typeof Combobox>;
export const Basic: Story = {};
function generateOptions(amount: number): Option[] {
return Array.from({ length: amount }, () => ({
label: chance.name(),
value: chance.guid(),
description: chance.sentence(),
}));
}
const manyOptions = generateOptions(1e5);
manyOptions.push({ label: 'Banana', value: 'banana', description: 'A yellow fruit' });
const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions }) => {
const [value, setValue] = useState<Value>(manyOptions[5].value);
const options = useMemo(() => generateOptions(numberOfOptions), [numberOfOptions]);
return (
<Combobox
options={options}
value={value}
onChange={(val) => {
setValue(val.value);
action('onChange')(val);
}}
/>
);
};
export const ManyOptions: StoryObj<PropsAndCustomArgs> = {
args: {
numberOfOptions: 1e5,
options: undefined,
value: undefined,
},
render: ManyOptionsStory,
};
export default meta;

@ -1,13 +1,17 @@
import { css } from '@emotion/css';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCombobox } from 'downshift';
import React, { useMemo, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { useStyles2 } from '../../themes';
import { Icon } from '../Icon/Icon';
import { Input, Props as InputProps } from '../Input/Input';
type Value = string | number;
type Option = {
export type Value = string | number;
export type Option = {
label: string;
value: Value;
description?: string;
};
interface ComboboxProps
@ -33,32 +37,86 @@ function itemFilter(inputValue: string) {
};
}
function estimateSize() {
return 60;
}
export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => {
const [items, setItems] = useState(options);
const selectedItem = useMemo(() => options.find((option) => option.value === value) || null, [options, value]);
const listRef = useRef(null);
const styles = useStyles2(getStyles);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => listRef.current,
estimateSize,
overscan: 2,
});
const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({
items,
itemToString,
selectedItem,
scrollIntoView: () => {},
onInputValueChange: ({ inputValue }) => {
setItems(options.filter(itemFilter(inputValue)));
},
onSelectedItemChange: ({ selectedItem }) => onChange(selectedItem),
onHighlightedIndexChange: ({ highlightedIndex, type }) => {
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
rowVirtualizer.scrollToIndex(highlightedIndex);
}
},
});
return (
<div>
<Input suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} {...restProps} {...getInputProps()} />
<ul {...getMenuProps()}>
{isOpen &&
items.map((item, index) => {
return (
<li key={item.value} {...getItemProps({ item, index })}>
{item.label}
</li>
);
})}
</ul>
<div className={styles.dropdown} {...getMenuProps({ ref: listRef })}>
{isOpen && (
<ul style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
return (
<li
key={items[virtualRow.index].value}
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={styles.menuItem}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
>
<span>{items[virtualRow.index].label}</span>
{items[virtualRow.index].description && <span>{items[virtualRow.index].description}</span>}
</li>
);
})}
</ul>
)}
</div>
</div>
);
};
const getStyles = () => ({
dropdown: css({
position: 'absolute',
height: 400,
width: 600,
overflowY: 'scroll',
contain: 'strict',
}),
menuItem: css({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
display: 'flex',
flexDirection: 'column',
'&:first-child': {
fontWeight: 'bold',
},
}),
});

@ -3684,6 +3684,7 @@ __metadata:
"@storybook/react": "npm:^8.1.6"
"@storybook/react-webpack5": "npm:^8.1.6"
"@storybook/theming": "npm:^8.1.6"
"@tanstack/react-virtual": "npm:^3.5.1"
"@testing-library/dom": "npm:10.0.0"
"@testing-library/jest-dom": "npm:6.4.2"
"@testing-library/react": "npm:15.0.2"
@ -7803,6 +7804,25 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/react-virtual@npm:^3.5.1":
version: 3.5.1
resolution: "@tanstack/react-virtual@npm:3.5.1"
dependencies:
"@tanstack/virtual-core": "npm:3.5.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10/11c8e9e2391fa0c947848a720b7dccccb1e35a78ac3169d1c34629bbec4ec713eed78d4c17a3e540e01386ee25b600a53254357597ae91a5fe35c7436651e975
languageName: node
linkType: hard
"@tanstack/virtual-core@npm:3.5.1":
version: 3.5.1
resolution: "@tanstack/virtual-core@npm:3.5.1"
checksum: 10/611ea09d37cf9183a51d2dfce401c3802b0d91f014e9bbaf32a6220ec7301b873b308130b795d935c0f5b73a43fd8358274915885da692d3e991eeeab6f8711b
languageName: node
linkType: hard
"@testing-library/dom@npm:10.0.0, @testing-library/dom@npm:>=7, @testing-library/dom@npm:^10.0.0":
version: 10.0.0
resolution: "@testing-library/dom@npm:10.0.0"

Loading…
Cancel
Save