From ff5433388105acc02e49e5bfd10028d09b801b7c Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed, 31 Jul 2024 10:37:09 +0200 Subject: [PATCH] Combobox: Add clear and reset onBlur (#90943) * Add clear and reset onBlur * use selectItem * Use downshift hooks instead * Fix Clear bug and extract i18n * Remove useMemo from story * Add loading state to many options story * Set fallback to null * Fix unused import * Use onBlur and pass it to Downshift instead --- .../Combobox/Combobox.internal.story.tsx | 36 +++++---- .../src/components/Combobox/Combobox.tsx | 75 ++++++++++++++----- .../components/Combobox/getComboboxStyles.ts | 7 ++ public/locales/en-US/grafana.json | 5 ++ public/locales/pseudo-LOCALE/grafana.json | 5 ++ 5 files changed, 94 insertions(+), 34 deletions(-) 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 329362149ae..05a331b7606 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx @@ -1,7 +1,7 @@ import { action } from '@storybook/addon-actions'; import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { Chance } from 'chance'; -import { ComponentProps, useMemo, useState } from 'react'; +import { ComponentProps, useEffect, useState } from 'react'; import { Combobox, Option, Value } from './Combobox'; @@ -44,10 +44,7 @@ const BasicWithState: StoryFn = (args) => { {...args} value={value} onChange={(val) => { - if (!val) { - return; - } - setValue(val.value); + setValue(val?.value || null); action('onChange')(val); }} /> @@ -58,7 +55,7 @@ type Story = StoryObj; export const Basic: Story = {}; -function generateOptions(amount: number): Option[] { +async function generateOptions(amount: number): Promise { return Array.from({ length: amount }, () => ({ label: chance.name(), value: chance.guid(), @@ -66,21 +63,30 @@ function generateOptions(amount: number): Option[] { })); } -const manyOptions = generateOptions(1e5); -manyOptions.push({ label: 'Banana', value: 'banana', description: 'A yellow fruit' }); +const ManyOptionsStory: StoryFn = ({ numberOfOptions, ...args }) => { + const [value, setValue] = useState(null); + const [options, setOptions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setTimeout(() => { + generateOptions(numberOfOptions).then((options) => { + setIsLoading(false); + setOptions(options); + setValue(options[5].value); + console.log("I've set stuff"); + }); + }, 1000); + }, [numberOfOptions]); -const ManyOptionsStory: StoryFn = ({ numberOfOptions }) => { - const [value, setValue] = useState(manyOptions[5].value); - const options = useMemo(() => generateOptions(numberOfOptions), [numberOfOptions]); return ( { - if (!val) { - return; - } - setValue(val.value); + setValue(val?.value || null); action('onChange')(val); }} /> diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index a045d938ade..6951d0dc98c 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -2,9 +2,10 @@ import { cx } from '@emotion/css'; import { autoUpdate, flip, useFloating } from '@floating-ui/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useCombobox } from 'downshift'; -import { useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useStyles2 } from '../../themes'; +import { t } from '../../utils/i18n'; import { Icon } from '../Icon/Icon'; import { Input, Props as InputProps } from '../Input/Input'; @@ -20,12 +21,12 @@ export type Option = { interface ComboboxProps extends Omit { onChange: (val: Option | null) => void; - value: Value; + value: Value | null; options: Option[]; } function itemToString(item: Option | null) { - return item?.label || ''; + return item?.label ?? ''; } function itemFilter(inputValue: string) { @@ -48,6 +49,7 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro const MIN_WIDTH = 400; const [items, setItems] = useState(options); const selectedItem = useMemo(() => options.find((option) => option.value === value) || null, [options, value]); + const inputRef = useRef(null); const floatingRef = useRef(null); const styles = useStyles2(getComboboxStyles); @@ -59,21 +61,35 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro overscan: 2, }); - const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex } = 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); - } - }, - }); + const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex, setInputValue, selectItem } = + useCombobox({ + items, + itemToString, + selectedItem, + scrollIntoView: () => {}, + onInputValueChange: ({ inputValue }) => { + setItems(options.filter(itemFilter(inputValue))); + }, + onIsOpenChange: ({ isOpen }) => { + // Default to displaying all values when opening + if (isOpen) { + setItems(options); + return; + } + }, + onSelectedItemChange: ({ selectedItem }) => { + onChange(selectedItem); + }, + onHighlightedIndexChange: ({ highlightedIndex, type }) => { + if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) { + rowVirtualizer.scrollToIndex(highlightedIndex); + } + }, + }); + + const onBlur = useCallback(() => { + setInputValue(selectedItem?.label ?? ''); + }, [selectedItem, setInputValue]); // the order of middleware is important! const middleware = [ @@ -98,7 +114,27 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro return (
} + suffix={ + <> + {!!value && value === selectedItem?.value && ( + { + selectItem(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + selectItem(null); + } + }} + /> + )} + + + } {...restProps} {...getInputProps({ ref: inputRef, @@ -107,6 +143,7 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro * Downshift repo: https://github.com/downshift-js/downshift/tree/master */ onChange: () => {}, + onBlur, })} />
{ top: 0, }, }), + clear: css({ + label: 'grafana-select-clear', + cursor: 'pointer', + '&:hover': { + color: theme.colors.text.primary, + }, + }), }; }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 48ac04b212b..d38e110f275 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -272,6 +272,11 @@ "success": "Copied" } }, + "combobox": { + "clear": { + "title": "Clear value" + } + }, "command-palette": { "action": { "change-theme": "Change theme...", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index cc0649df82d..176c116f3ed 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -272,6 +272,11 @@ "success": "Cőpįęđ" } }, + "combobox": { + "clear": { + "title": "Cľęäř väľūę" + } + }, "command-palette": { "action": { "change-theme": "Cĥäʼnģę ŧĥęmę...",