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 { 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<typeof Combobox> = (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<typeof Combobox>;
export const Basic: Story = {};
function generateOptions(amount: number): Option[] {
async function generateOptions(amount: number): Promise<Option[]> {
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<PropsAndCustomArgs> = ({ numberOfOptions, ...args }) => {
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 (
<Combobox
{...args}
loading={isLoading}
options={options}
value={value}
onChange={(val) => {
if (!val) {
return;
}
setValue(val.value);
setValue(val?.value || null);
action('onChange')(val);
}}
/>

View File

@ -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<InputProps, 'width' | 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> {
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<HTMLInputElement>(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 (
<div>
<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}
{...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,
})}
/>
<div

View File

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

View File

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

View File

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