mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
a5795ad66e
commit
ff54333881
@ -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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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...",
|
||||||
|
@ -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ę...",
|
||||||
|
Loading…
Reference in New Issue
Block a user