diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx index 2b0e4dcf178..53e1a2c0276 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx @@ -76,7 +76,7 @@ type Story = StoryObj; export const Basic: Story = {}; -async function generateOptions(amount: number): Promise { +export async function generateOptions(amount: number): Promise { return Array.from({ length: amount }, (_, index) => ({ label: 'Option ' + index, value: index.toString(), diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index de23afd82e4..4022e98a48e 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -111,6 +111,8 @@ function itemFilter(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 = (props: ComboboxProps) => count: items.length, getScrollElement: () => scrollRef.current, estimateSize: () => MENU_OPTION_HEIGHT, - overscan: 4, + overscan: VIRTUAL_OVERSCAN_ITEMS, }; const rowVirtualizer = useVirtualizer(virtualizerOptions); diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx index 948fd1632cc..f40e9f62bdd 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -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 = { @@ -23,6 +25,9 @@ const commonArgs = { export default meta; +type storyArgs = React.ComponentProps; +type ManyOptionsArgs = storyArgs & { numberOfOptions?: number }; + type Story = StoryObj; export const Basic: Story = { @@ -42,3 +47,42 @@ export const Basic: Story = { ); }, }; + +const ManyOptionsStory: StoryFn = ({ numberOfOptions = 1e4, ...args }) => { + const [value, setValue] = useState([]); + const [options, setOptions] = useState([]); + 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 ( + { + setValue(opts || []); + action('onChange')(opts); + }} + /> + ); +}; + +export const ManyOptions: StoryObj = { + args: { + numberOfOptions: 1e4, + options: undefined, + value: undefined, + }, + render: ManyOptionsStory, +}; diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx index 8b4129b3937..2f44fd1dd11 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -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(() => { diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index d21c0599055..2dfc72663b6 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -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 = (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 = (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 = (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 = (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 = (props: MultiComboboxPro > {isOpen && ( -
    - {items.map((item, index) => { +
      + {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 (
    • - {' '} - {/* Add styling with virtualization */} - - + + { + e.stopPropagation(); + }} + /> + +
    • ); })}