From 924a94cf80ff13de1ac09c6d4d75f91173f37d94 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Thu, 20 Jun 2024 15:51:17 +0200 Subject: [PATCH] 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 --- packages/grafana-ui/package.json | 1 + .../Combobox/Combobox.internal.story.tsx | 56 +++++++++++-- .../src/components/Combobox/Combobox.tsx | 84 ++++++++++++++++--- yarn.lock | 20 +++++ 4 files changed, 143 insertions(+), 18 deletions(-) diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 8a05ff5148e..da1bf32b7c3 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx index e56a989a526..cf373415761 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx @@ -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 = { +const chance = new Chance(); + +type PropsAndCustomArgs = ComponentProps & { numberOfOptions: number }; + +const meta: Meta = { title: 'Forms/Combobox', component: Combobox, args: { @@ -28,9 +33,11 @@ const meta: Meta = { ], value: 'banana', }, + + render: (args) => , }; -export const Basic: StoryFn = (args) => { +const BasicWithState: StoryFn = (args) => { const [value, setValue] = useState(args.value); return ( = (args) => { ); }; +type Story = StoryObj; + +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 = ({ numberOfOptions }) => { + const [value, setValue] = useState(manyOptions[5].value); + const options = useMemo(() => generateOptions(numberOfOptions), [numberOfOptions]); + return ( + { + setValue(val.value); + action('onChange')(val); + }} + /> + ); +}; + +export const ManyOptions: StoryObj = { + args: { + numberOfOptions: 1e5, + options: undefined, + value: undefined, + }, + render: ManyOptionsStory, +}; + export default meta; diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index ff83f3edc27..17e782a4b36 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -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 (
} {...restProps} {...getInputProps()} /> -
    - {isOpen && - items.map((item, index) => { - return ( -
  • - {item.label} -
  • - ); - })} -
+
+ {isOpen && ( +
    + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + return ( +
  • + {items[virtualRow.index].label} + {items[virtualRow.index].description && {items[virtualRow.index].description}} +
  • + ); + })} +
+ )} +
); }; + +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', + }, + }), +}); diff --git a/yarn.lock b/yarn.lock index 7b3e11f200b..705fe14a62a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"