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
This commit is contained in:
Tobias Skarhed 2024-07-31 10:37:09 +02:00 committed by GitHub
parent a5795ad66e
commit ff54333881
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 94 additions and 34 deletions

View File

@ -1,7 +1,7 @@
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { Meta, StoryFn, StoryObj } from '@storybook/react';
import { Chance } from 'chance'; import { Chance } from 'chance';
import { ComponentProps, useMemo, useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { Combobox, Option, Value } from './Combobox'; import { Combobox, Option, Value } from './Combobox';
@ -44,10 +44,7 @@ const BasicWithState: StoryFn<typeof Combobox> = (args) => {
{...args} {...args}
value={value} value={value}
onChange={(val) => { onChange={(val) => {
if (!val) { setValue(val?.value || null);
return;
}
setValue(val.value);
action('onChange')(val); action('onChange')(val);
}} }}
/> />
@ -58,7 +55,7 @@ type Story = StoryObj<typeof Combobox>;
export const Basic: Story = {}; export const Basic: Story = {};
function generateOptions(amount: number): Option[] { async function generateOptions(amount: number): Promise<Option[]> {
return Array.from({ length: amount }, () => ({ return Array.from({ length: amount }, () => ({
label: chance.name(), label: chance.name(),
value: chance.guid(), value: chance.guid(),
@ -66,21 +63,30 @@ function generateOptions(amount: number): Option[] {
})); }));
} }
const manyOptions = generateOptions(1e5); const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...args }) => {
manyOptions.push({ label: 'Banana', value: 'banana', description: 'A yellow fruit' }); const [value, setValue] = useState<Value | null>(null);
const [options, setOptions] = useState<Option[]>([]);
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<PropsAndCustomArgs> = ({ numberOfOptions }) => {
const [value, setValue] = useState<Value>(manyOptions[5].value);
const options = useMemo(() => generateOptions(numberOfOptions), [numberOfOptions]);
return ( return (
<Combobox <Combobox
{...args}
loading={isLoading}
options={options} options={options}
value={value} value={value}
onChange={(val) => { onChange={(val) => {
if (!val) { setValue(val?.value || null);
return;
}
setValue(val.value);
action('onChange')(val); action('onChange')(val);
}} }}
/> />

View File

@ -2,9 +2,10 @@ import { cx } from '@emotion/css';
import { autoUpdate, flip, useFloating } from '@floating-ui/react'; import { autoUpdate, flip, useFloating } from '@floating-ui/react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useCombobox } from 'downshift'; import { useCombobox } from 'downshift';
import { useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
import { t } from '../../utils/i18n';
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';
@ -20,12 +21,12 @@ export type Option = {
interface ComboboxProps interface ComboboxProps
extends Omit<InputProps, 'width' | 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> { extends Omit<InputProps, 'width' | 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> {
onChange: (val: Option | null) => void; onChange: (val: Option | null) => void;
value: Value; value: Value | null;
options: Option[]; options: Option[];
} }
function itemToString(item: Option | null) { function itemToString(item: Option | null) {
return item?.label || ''; return item?.label ?? '';
} }
function itemFilter(inputValue: string) { function itemFilter(inputValue: string) {
@ -48,6 +49,7 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
const MIN_WIDTH = 400; const MIN_WIDTH = 400;
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 inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const floatingRef = useRef(null); const floatingRef = useRef(null);
const styles = useStyles2(getComboboxStyles); const styles = useStyles2(getComboboxStyles);
@ -59,21 +61,35 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
overscan: 2, overscan: 2,
}); });
const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex } = useCombobox({ const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex, setInputValue, selectItem } =
items, useCombobox({
itemToString, items,
selectedItem, itemToString,
scrollIntoView: () => {}, selectedItem,
onInputValueChange: ({ inputValue }) => { scrollIntoView: () => {},
setItems(options.filter(itemFilter(inputValue))); onInputValueChange: ({ inputValue }) => {
}, setItems(options.filter(itemFilter(inputValue)));
onSelectedItemChange: ({ selectedItem }) => onChange(selectedItem), },
onHighlightedIndexChange: ({ highlightedIndex, type }) => { onIsOpenChange: ({ isOpen }) => {
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) { // Default to displaying all values when opening
rowVirtualizer.scrollToIndex(highlightedIndex); 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! // the order of middleware is important!
const middleware = [ const middleware = [
@ -98,7 +114,27 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
return ( return (
<div> <div>
<Input <Input
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} suffix={
<>
{!!value && value === selectedItem?.value && (
<Icon
name="times"
className={styles.clear}
title={t('combobox.clear.title', 'Clear value')}
tabIndex={0}
onClick={() => {
selectItem(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
selectItem(null);
}
}}
/>
)}
<Icon name={isOpen ? 'search' : 'angle-down'} />
</>
}
{...restProps} {...restProps}
{...getInputProps({ {...getInputProps({
ref: inputRef, ref: inputRef,
@ -107,6 +143,7 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
* Downshift repo: https://github.com/downshift-js/downshift/tree/master * Downshift repo: https://github.com/downshift-js/downshift/tree/master
*/ */
onChange: () => {}, onChange: () => {},
onBlur,
})} })}
/> />
<div <div

View File

@ -77,5 +77,12 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
top: 0, top: 0,
}, },
}), }),
clear: css({
label: 'grafana-select-clear',
cursor: 'pointer',
'&:hover': {
color: theme.colors.text.primary,
},
}),
}; };
}; };

View File

@ -272,6 +272,11 @@
"success": "Copied" "success": "Copied"
} }
}, },
"combobox": {
"clear": {
"title": "Clear value"
}
},
"command-palette": { "command-palette": {
"action": { "action": {
"change-theme": "Change theme...", "change-theme": "Change theme...",

View File

@ -272,6 +272,11 @@
"success": "Cőpįęđ" "success": "Cőpįęđ"
} }
}, },
"combobox": {
"clear": {
"title": "Cľęäř väľūę"
}
},
"command-palette": { "command-palette": {
"action": { "action": {
"change-theme": "Cĥäʼnģę ŧĥęmę...", "change-theme": "Cĥäʼnģę ŧĥęmę...",