mirror of
https://github.com/grafana/grafana.git
synced 2025-09-25 14:54:13 +08:00
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
This commit is contained in:
@ -61,6 +61,7 @@
|
|||||||
"@react-aria/focus": "3.17.1",
|
"@react-aria/focus": "3.17.1",
|
||||||
"@react-aria/overlays": "3.22.1",
|
"@react-aria/overlays": "3.22.1",
|
||||||
"@react-aria/utils": "3.24.1",
|
"@react-aria/utils": "3.24.1",
|
||||||
|
"@tanstack/react-virtual": "^3.5.1",
|
||||||
"ansicolor": "1.1.100",
|
"ansicolor": "1.1.100",
|
||||||
"calculate-size": "1.1.1",
|
"calculate-size": "1.1.1",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import { Meta, StoryFn } from '@storybook/react';
|
import { Meta, StoryFn, StoryObj } from '@storybook/react';
|
||||||
import React, { useState } from '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',
|
title: 'Forms/Combobox',
|
||||||
component: Combobox,
|
component: Combobox,
|
||||||
args: {
|
args: {
|
||||||
@ -28,9 +33,11 @@ const meta: Meta<typeof Combobox> = {
|
|||||||
],
|
],
|
||||||
value: 'banana',
|
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);
|
const [value, setValue] = useState(args.value);
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<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;
|
export default meta;
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useCombobox } from 'downshift';
|
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 { Icon } from '../Icon/Icon';
|
||||||
import { Input, Props as InputProps } from '../Input/Input';
|
import { Input, Props as InputProps } from '../Input/Input';
|
||||||
|
|
||||||
type Value = string | number;
|
export type Value = string | number;
|
||||||
type Option = {
|
export type Option = {
|
||||||
label: string;
|
label: string;
|
||||||
value: Value;
|
value: Value;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ComboboxProps
|
interface ComboboxProps
|
||||||
@ -33,32 +37,86 @@ function itemFilter(inputValue: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function estimateSize() {
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
|
||||||
export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => {
|
export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => {
|
||||||
const [items, setItems] = useState(options);
|
const [items, setItems] = useState(options);
|
||||||
const selectedItem = useMemo(() => options.find((option) => option.value === value) || null, [options, value]);
|
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({
|
const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({
|
||||||
items,
|
items,
|
||||||
itemToString,
|
itemToString,
|
||||||
selectedItem,
|
selectedItem,
|
||||||
|
scrollIntoView: () => {},
|
||||||
onInputValueChange: ({ inputValue }) => {
|
onInputValueChange: ({ inputValue }) => {
|
||||||
setItems(options.filter(itemFilter(inputValue)));
|
setItems(options.filter(itemFilter(inputValue)));
|
||||||
},
|
},
|
||||||
onSelectedItemChange: ({ selectedItem }) => onChange(selectedItem),
|
onSelectedItemChange: ({ selectedItem }) => onChange(selectedItem),
|
||||||
|
onHighlightedIndexChange: ({ highlightedIndex, type }) => {
|
||||||
|
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
|
||||||
|
rowVirtualizer.scrollToIndex(highlightedIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Input suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} {...restProps} {...getInputProps()} />
|
<Input suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} {...restProps} {...getInputProps()} />
|
||||||
<ul {...getMenuProps()}>
|
<div className={styles.dropdown} {...getMenuProps({ ref: listRef })}>
|
||||||
{isOpen &&
|
{isOpen && (
|
||||||
items.map((item, index) => {
|
<ul style={{ height: rowVirtualizer.getTotalSize() }}>
|
||||||
return (
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
<li key={item.value} {...getItemProps({ item, index })}>
|
return (
|
||||||
{item.label}
|
<li
|
||||||
</li>
|
key={items[virtualRow.index].value}
|
||||||
);
|
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
|
||||||
})}
|
data-index={virtualRow.index}
|
||||||
</ul>
|
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>
|
</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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
20
yarn.lock
20
yarn.lock
@ -3684,6 +3684,7 @@ __metadata:
|
|||||||
"@storybook/react": "npm:^8.1.6"
|
"@storybook/react": "npm:^8.1.6"
|
||||||
"@storybook/react-webpack5": "npm:^8.1.6"
|
"@storybook/react-webpack5": "npm:^8.1.6"
|
||||||
"@storybook/theming": "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/dom": "npm:10.0.0"
|
||||||
"@testing-library/jest-dom": "npm:6.4.2"
|
"@testing-library/jest-dom": "npm:6.4.2"
|
||||||
"@testing-library/react": "npm:15.0.2"
|
"@testing-library/react": "npm:15.0.2"
|
||||||
@ -7803,6 +7804,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@testing-library/dom@npm:10.0.0, @testing-library/dom@npm:>=7, @testing-library/dom@npm:^10.0.0":
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
resolution: "@testing-library/dom@npm:10.0.0"
|
resolution: "@testing-library/dom@npm:10.0.0"
|
||||||
|
Reference in New Issue
Block a user