mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Combobox: Improve async UX (#96054)
* Refactor basic usage with stateReducer This is a combination of 3 commits. This is the 1st commit message: more wip This is the commit message #2: even more wip This is the commit message #3: got basic usage working well with stateReducer remove unrelated change todo tests * fix behaviour for async * clean up dev stuff * story * Fix options being cleared for non-async combobox * Fill out tests! * put story back * clean up metriccombobox test * show selected value as placeholder while menu is open * properly fallback placeholder to the prop
This commit is contained in:
parent
3a242d8274
commit
e8241636b8
@ -104,14 +104,12 @@ describe('MetricCombobox', () => {
|
|||||||
|
|
||||||
const combobox = screen.getByPlaceholderText('Select metric');
|
const combobox = screen.getByPlaceholderText('Select metric');
|
||||||
await userEvent.click(combobox);
|
await userEvent.click(combobox);
|
||||||
|
|
||||||
await userEvent.type(combobox, 'unique');
|
await userEvent.type(combobox, 'unique');
|
||||||
expect(jest.mocked(mockDatasource.metricFindQuery)).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const item = await screen.findByRole('option', { name: 'unique_metric' });
|
const item = await screen.findByRole('option', { name: 'unique_metric' });
|
||||||
expect(item).toBeInTheDocument();
|
expect(item).toBeInTheDocument();
|
||||||
|
|
||||||
const negativeItem = await screen.queryByRole('option', { name: 'random_metric' });
|
const negativeItem = screen.queryByRole('option', { name: 'random_metric' });
|
||||||
expect(negativeItem).not.toBeInTheDocument();
|
expect(negativeItem).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ import { useTheme2 } from '../../themes/ThemeContext';
|
|||||||
import { Alert } from '../Alert/Alert';
|
import { Alert } from '../Alert/Alert';
|
||||||
import { Divider } from '../Divider/Divider';
|
import { Divider } from '../Divider/Divider';
|
||||||
import { Field } from '../Forms/Field';
|
import { Field } from '../Forms/Field';
|
||||||
import { Select, AsyncSelect } from '../Select/Select';
|
import { AsyncSelect, Select } from '../Select/Select';
|
||||||
|
|
||||||
import { Combobox, ComboboxOption } from './Combobox';
|
import { Combobox, ComboboxOption } from './Combobox';
|
||||||
|
|
||||||
@ -55,6 +55,7 @@ const meta: Meta<PropsAndCustomArgs> = {
|
|||||||
|
|
||||||
const BasicWithState: StoryFn<typeof Combobox> = (args) => {
|
const BasicWithState: StoryFn<typeof Combobox> = (args) => {
|
||||||
const [value, setValue] = useState(args.value);
|
const [value, setValue] = useState(args.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field label="Test input" description="Input with a few options">
|
<Field label="Test input" description="Input with a few options">
|
||||||
<Combobox
|
<Combobox
|
||||||
@ -259,6 +260,7 @@ export const CustomValue: StoryObj<PropsAndCustomArgs> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadOptionsAction = action('loadOptions called');
|
||||||
const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
||||||
// Combobox
|
// Combobox
|
||||||
const [selectedOption, setSelectedOption] = useState<ComboboxOption<string> | null>(null);
|
const [selectedOption, setSelectedOption] = useState<ComboboxOption<string> | null>(null);
|
||||||
@ -268,7 +270,7 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
|||||||
|
|
||||||
// This simulates a kind of search API call
|
// This simulates a kind of search API call
|
||||||
const loadOptionsWithLabels = useCallback((inputValue: string) => {
|
const loadOptionsWithLabels = useCallback((inputValue: string) => {
|
||||||
console.info(`Load options called with value '${inputValue}' `);
|
loadOptionsAction(inputValue);
|
||||||
return fakeSearchAPI(`http://example.com/search?query=${inputValue}`);
|
return fakeSearchAPI(`http://example.com/search?query=${inputValue}`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -326,7 +328,6 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
|||||||
|
|
||||||
<Field label="Async with error" description="An odd number of characters throws an error">
|
<Field label="Async with error" description="An odd number of characters throws an error">
|
||||||
<Combobox
|
<Combobox
|
||||||
{...args}
|
|
||||||
id="test-combobox-error"
|
id="test-combobox-error"
|
||||||
placeholder="Select an option"
|
placeholder="Select an option"
|
||||||
options={loadOptionsWithErrors}
|
options={loadOptionsWithErrors}
|
||||||
@ -337,6 +338,7 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Compared to AsyncSelect">
|
<Field label="Compared to AsyncSelect">
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
id="test-async-select"
|
id="test-async-select"
|
||||||
@ -350,6 +352,20 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Async with error" description="An odd number of characters throws an error">
|
||||||
|
<Combobox
|
||||||
|
{...args}
|
||||||
|
id="test-combobox-error"
|
||||||
|
placeholder="Select an option"
|
||||||
|
options={loadOptionsWithErrors}
|
||||||
|
value={selectedOption}
|
||||||
|
onChange={(val) => {
|
||||||
|
action('onChange')(val);
|
||||||
|
setSelectedOption(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -28,6 +28,10 @@ describe('Combobox', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
onChangeHandler.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders without error', () => {
|
it('renders without error', () => {
|
||||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
|
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
|
||||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
@ -45,6 +49,15 @@ describe('Combobox', () => {
|
|||||||
expect(onChangeHandler).toHaveBeenCalledWith(options[0]);
|
expect(onChangeHandler).toHaveBeenCalledWith(options[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the placeholder with the menu open when there's no value", async () => {
|
||||||
|
render(<Combobox options={options} value={null} onChange={onChangeHandler} placeholder="Select an option" />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'Select an option');
|
||||||
|
});
|
||||||
|
|
||||||
it('selects value by clicking that needs scrolling', async () => {
|
it('selects value by clicking that needs scrolling', async () => {
|
||||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
|
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
|
||||||
|
|
||||||
@ -93,6 +106,54 @@ describe('Combobox', () => {
|
|||||||
expect(screen.queryByDisplayValue('Option 2')).not.toBeInTheDocument();
|
expect(screen.queryByDisplayValue('Option 2')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with a value already selected', () => {
|
||||||
|
it('shows an empty text input when opening the menu', async () => {
|
||||||
|
const selectedValue = options[0].value;
|
||||||
|
render(<Combobox options={options} value={selectedValue} onChange={onChangeHandler} />);
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all options unfiltered when opening the menu', async () => {
|
||||||
|
const selectedValue = options[0].value;
|
||||||
|
render(<Combobox options={options} value={selectedValue} onChange={onChangeHandler} />);
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
const optionsEls = await screen.findAllByRole('option');
|
||||||
|
expect(optionsEls).toHaveLength(options.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the current selected value as the placeholder of the input', async () => {
|
||||||
|
const selectedValue = options[0].value;
|
||||||
|
render(<Combobox options={options} value={selectedValue} onChange={onChangeHandler} />);
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
expect(input).toHaveAttribute('placeholder', options[0].label);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exiting the menu without selecting an item restores the value to the text input', async () => {
|
||||||
|
const selectedValue = options[0].value;
|
||||||
|
render(<Combobox options={options} value={selectedValue} onChange={onChangeHandler} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await userEvent.type(input, 'Option 3');
|
||||||
|
await userEvent.keyboard('{Esc}');
|
||||||
|
|
||||||
|
expect(onChangeHandler).not.toHaveBeenCalled();
|
||||||
|
expect(input).toHaveValue('Option 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('create custom value', () => {
|
describe('create custom value', () => {
|
||||||
it('should allow creating a custom value', async () => {
|
it('should allow creating a custom value', async () => {
|
||||||
const onChangeHandler = jest.fn();
|
const onChangeHandler = jest.fn();
|
||||||
@ -156,6 +217,8 @@ describe('Combobox', () => {
|
|||||||
const input = screen.getByRole('combobox');
|
const input = screen.getByRole('combobox');
|
||||||
await user.click(input);
|
await user.click(input);
|
||||||
|
|
||||||
|
await act(async () => jest.advanceTimersByTime(200));
|
||||||
|
|
||||||
expect(asyncOptions).toHaveBeenCalled();
|
expect(asyncOptions).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -294,5 +357,57 @@ describe('Combobox', () => {
|
|||||||
|
|
||||||
expect(emptyMessage).toBeInTheDocument();
|
expect(emptyMessage).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with a value already selected', () => {
|
||||||
|
const selectedValue = { value: '1', label: 'Option 1' };
|
||||||
|
|
||||||
|
it('shows an empty text input when opening the menu', async () => {
|
||||||
|
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
|
||||||
|
render(<Combobox options={asyncOptions} value={selectedValue} onChange={onChangeHandler} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await user.click(input);
|
||||||
|
|
||||||
|
// Flush out async on open changes
|
||||||
|
await act(async () => Promise.resolve());
|
||||||
|
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all options unfiltered when opening the menu', async () => {
|
||||||
|
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
|
||||||
|
render(<Combobox options={asyncOptions} value={selectedValue} onChange={onChangeHandler} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await user.click(input);
|
||||||
|
|
||||||
|
// Flush out async on open changes
|
||||||
|
await act(async () => Promise.resolve());
|
||||||
|
|
||||||
|
const optionsEls = await screen.findAllByRole('option');
|
||||||
|
expect(optionsEls).toHaveLength(simpleAsyncOptions.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exiting the menu without selecting an item restores the value to the text input', async () => {
|
||||||
|
const asyncOptions = jest.fn(async () => {
|
||||||
|
return new Promise<ComboboxOption[]>((resolve) => setTimeout(() => resolve([{ value: 'first' }]), 2000));
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Combobox options={asyncOptions} value={selectedValue} onChange={onChangeHandler} createCustomValue />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
await user.click(input);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await user.type(input, 'Opt');
|
||||||
|
jest.advanceTimersByTime(500); // Custom value while typing
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.keyboard('{Esc}');
|
||||||
|
|
||||||
|
expect(onChangeHandler).not.toHaveBeenCalled();
|
||||||
|
expect(input).toHaveValue('Option 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -72,6 +72,7 @@ function itemFilter<T extends string | number>(inputValue: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
const asyncNoop = () => Promise.resolve([]);
|
const asyncNoop = () => Promise.resolve([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,17 +80,20 @@ const asyncNoop = () => Promise.resolve([]);
|
|||||||
*
|
*
|
||||||
* @alpha
|
* @alpha
|
||||||
*/
|
*/
|
||||||
export const Combobox = <T extends string | number>({
|
export const Combobox = <T extends string | number>(props: ComboboxProps<T>) => {
|
||||||
|
const {
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
value: valueProp,
|
value: valueProp,
|
||||||
|
placeholder: placeholderProp,
|
||||||
isClearable = false,
|
isClearable = false,
|
||||||
createCustomValue = false,
|
createCustomValue = false,
|
||||||
id,
|
id,
|
||||||
width,
|
width,
|
||||||
'aria-labelledby': ariaLabelledBy,
|
'aria-labelledby': ariaLabelledBy,
|
||||||
...restProps
|
...restProps
|
||||||
}: ComboboxProps<T>) => {
|
} = props;
|
||||||
|
|
||||||
// Value can be an actual scalar Value (string or number), or an Option (value + label), so
|
// Value can be an actual scalar Value (string or number), or an Option (value + label), so
|
||||||
// get a consistent Value from it
|
// get a consistent Value from it
|
||||||
const value = typeof valueProp === 'object' ? valueProp?.value : valueProp;
|
const value = typeof valueProp === 'object' ? valueProp?.value : valueProp;
|
||||||
@ -99,7 +103,31 @@ export const Combobox = <T extends string | number>({
|
|||||||
const [asyncLoading, setAsyncLoading] = useState(false);
|
const [asyncLoading, setAsyncLoading] = useState(false);
|
||||||
const [asyncError, setAsyncError] = useState(false);
|
const [asyncError, setAsyncError] = useState(false);
|
||||||
|
|
||||||
const [items, setItems] = useState(isAsync ? [] : options);
|
// A custom setter to always prepend the custom value at the beginning, if needed
|
||||||
|
const [items, baseSetItems] = useState(isAsync ? [] : options);
|
||||||
|
const setItems = useCallback(
|
||||||
|
(items: Array<ComboboxOption<T>>, inputValue: string | undefined) => {
|
||||||
|
let itemsToSet = items;
|
||||||
|
|
||||||
|
if (inputValue && createCustomValue) {
|
||||||
|
const optionMatchingInput = items.find((opt) => opt.label === inputValue || opt.value === inputValue);
|
||||||
|
|
||||||
|
if (!optionMatchingInput) {
|
||||||
|
const customValueOption = {
|
||||||
|
// Type casting needed to make this work when T is a number
|
||||||
|
value: inputValue as unknown as T,
|
||||||
|
description: t('combobox.custom-value.create', 'Create custom value'),
|
||||||
|
};
|
||||||
|
|
||||||
|
itemsToSet = items.slice(0);
|
||||||
|
itemsToSet.unshift(customValueOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSetItems(itemsToSet);
|
||||||
|
},
|
||||||
|
[createCustomValue]
|
||||||
|
);
|
||||||
|
|
||||||
const selectedItemIndex = useMemo(() => {
|
const selectedItemIndex = useMemo(() => {
|
||||||
if (isAsync) {
|
if (isAsync) {
|
||||||
@ -142,10 +170,10 @@ export const Combobox = <T extends string | number>({
|
|||||||
|
|
||||||
const debounceAsync = useMemo(
|
const debounceAsync = useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce((inputValue: string, customValueOption: ComboboxOption<T> | null) => {
|
debounce((inputValue: string) => {
|
||||||
loadOptions(inputValue)
|
loadOptions(inputValue)
|
||||||
.then((opts) => {
|
.then((opts) => {
|
||||||
setItems(customValueOption ? [customValueOption, ...opts] : opts);
|
setItems(opts, inputValue);
|
||||||
setAsyncLoading(false);
|
setAsyncLoading(false);
|
||||||
setAsyncError(false);
|
setAsyncError(false);
|
||||||
})
|
})
|
||||||
@ -156,16 +184,17 @@ export const Combobox = <T extends string | number>({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 200),
|
}, 200),
|
||||||
[loadOptions]
|
[loadOptions, setItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
isOpen,
|
||||||
|
highlightedIndex,
|
||||||
|
|
||||||
getInputProps,
|
getInputProps,
|
||||||
getMenuProps,
|
getMenuProps,
|
||||||
getItemProps,
|
getItemProps,
|
||||||
isOpen,
|
|
||||||
highlightedIndex,
|
|
||||||
setInputValue,
|
|
||||||
openMenu,
|
openMenu,
|
||||||
closeMenu,
|
closeMenu,
|
||||||
selectItem,
|
selectItem,
|
||||||
@ -176,51 +205,53 @@ export const Combobox = <T extends string | number>({
|
|||||||
items,
|
items,
|
||||||
itemToString,
|
itemToString,
|
||||||
selectedItem,
|
selectedItem,
|
||||||
|
|
||||||
|
// Don't change downshift state in the onBlahChange handlers. Instead, use the stateReducer to make changes.
|
||||||
|
// Downshift calls change handlers on the render after so you can get sync/flickering issues if you change its state
|
||||||
|
// in them.
|
||||||
|
// Instead, stateReducer is called in the same tick as state changes, before that state is committed and rendered.
|
||||||
|
|
||||||
onSelectedItemChange: ({ selectedItem }) => {
|
onSelectedItemChange: ({ selectedItem }) => {
|
||||||
onChange(selectedItem);
|
onChange(selectedItem);
|
||||||
},
|
},
|
||||||
|
|
||||||
defaultHighlightedIndex: selectedItemIndex ?? 0,
|
defaultHighlightedIndex: selectedItemIndex ?? 0,
|
||||||
|
|
||||||
scrollIntoView: () => {},
|
scrollIntoView: () => {},
|
||||||
onInputValueChange: ({ inputValue }) => {
|
|
||||||
const customValueOption =
|
|
||||||
createCustomValue &&
|
|
||||||
inputValue &&
|
|
||||||
items.findIndex((opt) => opt.label === inputValue || opt.value === inputValue) === -1
|
|
||||||
? {
|
|
||||||
// Type casting needed to make this work when T is a number
|
|
||||||
value: inputValue as unknown as T,
|
|
||||||
description: t('combobox.custom-value.create', 'Create custom value'),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
|
onInputValueChange: ({ inputValue, isOpen }) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
// Prevent stale options from showing on reopen
|
||||||
if (isAsync) {
|
if (isAsync) {
|
||||||
if (customValueOption) {
|
setItems([], '');
|
||||||
setItems([customValueOption]);
|
|
||||||
}
|
}
|
||||||
setAsyncLoading(true);
|
|
||||||
debounceAsync(inputValue, customValueOption);
|
|
||||||
|
|
||||||
|
// Otherwise there's nothing else to do when the menu isnt open
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAsync) {
|
||||||
const filteredItems = options.filter(itemFilter(inputValue));
|
const filteredItems = options.filter(itemFilter(inputValue));
|
||||||
|
setItems(filteredItems, inputValue);
|
||||||
|
} else {
|
||||||
|
if (inputValue && createCustomValue) {
|
||||||
|
setItems([], inputValue);
|
||||||
|
}
|
||||||
|
|
||||||
setItems(customValueOption ? [customValueOption, ...filteredItems] : filteredItems);
|
setAsyncLoading(true);
|
||||||
|
debounceAsync(inputValue);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onIsOpenChange: ({ isOpen, inputValue }) => {
|
onIsOpenChange: ({ isOpen, inputValue }) => {
|
||||||
// Default to displaying all values when opening
|
// Loading async options mostly happens in onInputValueChange, but if the menu is opened with an empty input
|
||||||
if (isOpen && !isAsync) {
|
// then onInputValueChange isn't called (because the input value hasn't changed)
|
||||||
setItems(options);
|
if (isAsync && isOpen && inputValue === '') {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOpen && isAsync) {
|
|
||||||
setAsyncLoading(true);
|
setAsyncLoading(true);
|
||||||
loadOptions(inputValue ?? '')
|
// TODO: dedupe this loading logic with debounceAsync
|
||||||
.then((options) => {
|
loadOptions(inputValue)
|
||||||
setItems(options);
|
.then((opts) => {
|
||||||
|
setItems(opts, inputValue);
|
||||||
setAsyncLoading(false);
|
setAsyncLoading(false);
|
||||||
setAsyncError(false);
|
setAsyncError(false);
|
||||||
})
|
})
|
||||||
@ -230,22 +261,52 @@ export const Combobox = <T extends string | number>({
|
|||||||
setAsyncLoading(false);
|
setAsyncLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onHighlightedIndexChange: ({ highlightedIndex, type }) => {
|
onHighlightedIndexChange: ({ highlightedIndex, type }) => {
|
||||||
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
|
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
|
||||||
rowVirtualizer.scrollToIndex(highlightedIndex);
|
rowVirtualizer.scrollToIndex(highlightedIndex);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
stateReducer(state, actionAndChanges) {
|
||||||
|
let { changes } = actionAndChanges;
|
||||||
|
const menuBeingOpened = state.isOpen === false && changes.isOpen === true;
|
||||||
|
const menuBeingClosed = state.isOpen === true && changes.isOpen === false;
|
||||||
|
|
||||||
|
// Reset the input value when the menu is opened. If the menu is opened due to an input change
|
||||||
|
// then make sure we keep that.
|
||||||
|
// This will trigger onInputValueChange to load async options
|
||||||
|
if (menuBeingOpened && changes.inputValue === state.inputValue) {
|
||||||
|
changes = {
|
||||||
|
...changes,
|
||||||
|
inputValue: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuBeingClosed) {
|
||||||
|
// Flush the selected item to the input when the menu is closed
|
||||||
|
if (changes.selectedItem) {
|
||||||
|
changes = {
|
||||||
|
...changes,
|
||||||
|
inputValue: itemToString(changes.selectedItem),
|
||||||
|
};
|
||||||
|
} else if (changes.inputValue !== '') {
|
||||||
|
// Otherwise if no selected value, clear any search from the input
|
||||||
|
changes = {
|
||||||
|
...changes,
|
||||||
|
inputValue: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { inputRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, rowVirtualizer.range, isOpen);
|
const { inputRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, rowVirtualizer.range, isOpen);
|
||||||
|
|
||||||
const onBlur = useCallback(() => {
|
|
||||||
setInputValue(selectedItem?.label ?? value?.toString() ?? '');
|
|
||||||
}, [selectedItem, setInputValue, value]);
|
|
||||||
|
|
||||||
const handleSuffixClick = useCallback(() => {
|
const handleSuffixClick = useCallback(() => {
|
||||||
isOpen ? closeMenu() : openMenu();
|
isOpen ? closeMenu() : openMenu();
|
||||||
}, [isOpen, openMenu, closeMenu]);
|
}, [isOpen, openMenu, closeMenu]);
|
||||||
@ -259,6 +320,8 @@ export const Combobox = <T extends string | number>({
|
|||||||
? 'search'
|
? 'search'
|
||||||
: 'angle-down';
|
: 'angle-down';
|
||||||
|
|
||||||
|
const placeholder = (isOpen ? itemToString(selectedItem) : null) || placeholderProp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InputComponent
|
<InputComponent
|
||||||
@ -297,9 +360,9 @@ export const Combobox = <T extends string | number>({
|
|||||||
* See issue here: https://github.com/downshift-js/downshift/issues/718
|
* See issue here: https://github.com/downshift-js/downshift/issues/718
|
||||||
* Downshift repo: https://github.com/downshift-js/downshift/tree/master
|
* Downshift repo: https://github.com/downshift-js/downshift/tree/master
|
||||||
*/
|
*/
|
||||||
onChange: () => {},
|
onChange: noop,
|
||||||
onBlur,
|
|
||||||
'aria-labelledby': ariaLabelledBy, // Label should be handled with the Field component
|
'aria-labelledby': ariaLabelledBy, // Label should be handled with the Field component
|
||||||
|
placeholder,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
Loading…
Reference in New Issue
Block a user