MultiCombobox: Async options (#99469)

* remove managed isOpen state, add hook to abstract away options/async functionality

* split useOptions into new file

* refactor stories

revert combobox stories to what's in main. I screwed up that rebase

* change onChange type, clean up what calls onChange, add debounce and useLatestAsyncCall

* tests (mid trying to figure out the act stuff)

* tests

* debounce-promise doesn't work with rollup?

* just some minor code clean up

* fix type import
This commit is contained in:
Josh Hunt 2025-01-28 13:36:59 +00:00 committed by GitHub
parent af2c7a19d1
commit 9d635edd0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 428 additions and 128 deletions

View File

@ -162,15 +162,15 @@ export const AsyncOptionsWithLabels: Story = {
return (
<Field
label='Asynbc options fn returns objects like { label: "Option 69", value: "69" }'
label='Async options fn returns objects like { label: "Option 69", value: "69" }'
description="Search for 'break' to see an error"
>
<Combobox
{...args}
{...dynamicArgs}
onChange={(val: ComboboxOption | null) => {
onChangeAction(val);
setArgs({ value: val });
onChange={(value: ComboboxOption | null) => {
onChangeAction(value);
setArgs({ value });
}}
/>
</Field>
@ -205,7 +205,7 @@ export const AsyncOptionsWithOnlyValues: Story = {
{...dynamicArgs}
onChange={(value: ComboboxOption | null) => {
onChangeAction(value);
setArgs({ value: value });
setArgs({ value });
}}
/>
</Field>

View File

@ -2,8 +2,10 @@ import { action } from '@storybook/addon-actions';
import { useArgs, useEffect, useState } from '@storybook/preview-api';
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { Field } from '../Forms/Field';
import { MultiCombobox } from './MultiCombobox';
import { generateOptions } from './storyUtils';
import { generateOptions, fakeSearchAPI } from './storyUtils';
import { ComboboxOption } from './types';
const meta: Meta<typeof MultiCombobox> = {
@ -11,6 +13,9 @@ const meta: Meta<typeof MultiCombobox> = {
component: MultiCombobox,
};
const loadOptionsAction = action('options called');
const onChangeAction = action('onChange called');
const commonArgs = {
options: [
{ label: 'wasd - 1', value: 'option1' },
@ -40,7 +45,7 @@ export const Basic: Story = {
{...args}
value={value}
onChange={(val) => {
action('onChange')(val);
onChangeAction(val);
setArgs({ value: val });
}}
/>
@ -67,17 +72,14 @@ export const AutoSize: Story = {
};
const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...args }) => {
const [value, setValue] = useState<string[]>([]);
const [dynamicArgs, setArgs] = useArgs();
const [options, setOptions] = useState<ComboboxOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setTimeout(() => {
generateOptions(numberOfOptions).then((options) => {
setIsLoading(false);
setOptions(options);
setValue([options[5].value]);
});
setTimeout(async () => {
const options = await generateOptions(numberOfOptions);
setOptions(options);
}, 1000);
}, [numberOfOptions]);
@ -85,12 +87,11 @@ const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...
return (
<MultiCombobox
{...rest}
loading={isLoading}
{...dynamicArgs}
options={options}
value={value}
onChange={(opts) => {
setValue(opts || []);
action('onChange')(opts);
setArgs({ value: opts });
onChangeAction(opts);
}}
/>
);
@ -104,3 +105,71 @@ export const ManyOptions: StoryObj<ManyOptionsArgs> = {
},
render: ManyOptionsStory,
};
function loadOptionsWithLabels(inputValue: string) {
loadOptionsAction(inputValue);
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`);
}
export const AsyncOptionsWithLabels: Story = {
name: 'Async - options returns labels',
args: {
options: loadOptionsWithLabels,
value: [{ label: 'Option 69', value: '69' }],
placeholder: 'Select an option',
},
render: (args) => {
const [dynamicArgs, setArgs] = useArgs();
return (
<Field
label='Asynbc options fn returns objects like { label: "Option 69", value: "69" }'
description="Search for 'break' to see an error"
>
<MultiCombobox
{...args}
{...dynamicArgs}
onChange={(val) => {
onChangeAction(val);
setArgs({ value: val });
}}
/>
</Field>
);
},
};
function loadOptionsOnlyValues(inputValue: string) {
loadOptionsAction(inputValue);
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`).then((options) =>
options.map((opt) => ({ value: opt.label! }))
);
}
export const AsyncOptionsWithOnlyValues: Story = {
name: 'Async - options returns only values',
args: {
options: loadOptionsOnlyValues,
value: [{ value: 'Option 69' }],
placeholder: 'Select an option',
},
render: (args) => {
const [dynamicArgs, setArgs] = useArgs();
return (
<Field
label='Async options fn returns objects like { value: "69" }'
description="Search for 'break' to see an error"
>
<MultiCombobox
{...args}
{...dynamicArgs}
onChange={(val) => {
onChangeAction(val);
setArgs({ value: val });
}}
/>
</Field>
);
},
};

View File

@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent, { UserEvent } from '@testing-library/user-event';
import React from 'react';
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
import { ComboboxOption } from './types';
describe('MultiCombobox', () => {
beforeAll(() => {
@ -91,15 +92,18 @@ describe('MultiCombobox', () => {
await user.click(input);
await user.click(await screen.findByRole('option', { name: 'A' }));
//Second option
// Second option
await user.click(screen.getByRole('option', { name: 'C' }));
//Deselect
// Deselect
await user.click(screen.getByRole('option', { name: 'A' }));
expect(onChange).toHaveBeenNthCalledWith(1, [first]);
expect(onChange).toHaveBeenNthCalledWith(2, [first, third]);
expect(onChange).toHaveBeenNthCalledWith(3, [third]);
expect(onChange).toHaveBeenNthCalledWith(1, [{ label: 'A', value: first }]);
expect(onChange).toHaveBeenNthCalledWith(2, [
{ label: 'A', value: first },
{ label: 'C', value: third },
]);
expect(onChange).toHaveBeenNthCalledWith(3, [{ label: 'C', value: third }]);
});
it('should be able to render a value that is not in the options', async () => {
@ -138,7 +142,11 @@ describe('MultiCombobox', () => {
await user.click(input);
await user.click(await screen.findByText('All'));
expect(onChange).toHaveBeenCalledWith(['a', 'b', 'c']);
expect(onChange).toHaveBeenCalledWith([
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
{ label: 'C', value: 'c' },
]);
});
it('should deselect all option', async () => {
@ -157,4 +165,138 @@ describe('MultiCombobox', () => {
expect(onChange).toHaveBeenCalledWith([]);
});
});
describe('async', () => {
const onChangeHandler = jest.fn();
let user: ReturnType<typeof userEvent.setup>;
beforeAll(() => {
user = userEvent.setup({ delay: null });
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
afterEach(() => {
onChangeHandler.mockReset();
});
// Assume that most apis only return with the value
const simpleAsyncOptions = [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }];
it('should allow async options', async () => {
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
render(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
await user.click(input);
// Debounce
await act(async () => jest.advanceTimersByTime(200));
expect(asyncOptions).toHaveBeenCalled();
});
it('should allow async options and select value', async () => {
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
render(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
await user.click(input);
const item = await screen.findByRole('option', { name: 'Option 3' });
await user.click(item);
expect(onChangeHandler).toHaveBeenCalledWith([simpleAsyncOptions[2]]);
});
it('should retain values not returned by the async function', async () => {
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
render(<MultiCombobox options={asyncOptions} value={[{ value: 'Option 69' }]} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
await user.click(input);
const item = await screen.findByRole('option', { name: 'Option 3' });
await user.click(item);
expect(onChangeHandler).toHaveBeenCalledWith([{ value: 'Option 69' }, { value: 'Option 3' }]);
});
it('should ignore late responses', async () => {
const asyncOptions = jest.fn(async (searchTerm: string) => {
if (searchTerm === 'a') {
return promiseResolvesWith([{ value: 'first' }], 1500);
} else if (searchTerm === 'ab') {
return promiseResolvesWith([{ value: 'second' }], 500);
} else if (searchTerm === 'abc') {
return promiseResolvesWith([{ value: 'third' }], 800);
}
return Promise.resolve([]);
});
render(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('a');
act(() => jest.advanceTimersByTime(200)); // Skip debounce
await user.keyboard('b');
act(() => jest.advanceTimersByTime(200)); // Skip debounce
await user.keyboard('c');
act(() => jest.advanceTimersByTime(500)); // Resolve the second request, should be ignored
expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'third' })).not.toBeInTheDocument();
jest.advanceTimersByTime(800); // Resolve the third request, should be shown
expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument();
expect(await screen.findByRole('option', { name: 'third' })).toBeInTheDocument();
jest.advanceTimersByTime(1500); // Resolve the first request, should be ignored
expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'third' })).toBeInTheDocument();
jest.clearAllTimers();
});
it('should debounce requests', async () => {
const asyncOptions = jest.fn(async () => {
return promiseResolvesWith([{ value: 'Option 3' }], 1);
});
render(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
await user.click(input);
await user.keyboard('a');
act(() => jest.advanceTimersByTime(10));
await user.keyboard('b');
act(() => jest.advanceTimersByTime(10));
await user.keyboard('c');
act(() => jest.advanceTimersByTime(200));
const item = await screen.findByRole('option', { name: 'Option 3' });
expect(item).toBeInTheDocument();
expect(asyncOptions).toHaveBeenCalledTimes(1);
expect(asyncOptions).toHaveBeenCalledWith('abc');
});
});
});
function promiseResolvesWith(value: ComboboxOption[], timeout = 0) {
return new Promise<ComboboxOption[]>((resolve) => setTimeout(() => resolve(value), timeout));
}

View File

@ -18,46 +18,25 @@ import { NotFoundError } from './MessageRows';
import { OptionListItem } from './OptionListItem';
import { SuffixIcon } from './SuffixIcon';
import { ValuePill } from './ValuePill';
import { itemFilter, itemToString } from './filter';
import { itemToString } from './filter';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
import { useComboboxFloat } from './useComboboxFloat';
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
import { useMultiInputAutoSize } from './useMultiInputAutoSize';
import { useOptions } from './useOptions';
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
value?: T[] | Array<ComboboxOption<T>>;
onChange: (items?: T[]) => void;
onChange: (option: Array<ComboboxOption<T>>) => void;
enableAllOption?: boolean;
}
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
const {
options,
placeholder,
onChange,
value,
width,
enableAllOption,
invalid,
loading,
disabled,
minWidth,
maxWidth,
} = props;
const isAsync = typeof options === 'function';
const selectedItems = useMemo(() => {
if (!value || isAsync) {
//TODO handle async
return [];
}
return getSelectedItemsFromValue<T>(value, options);
}, [value, options, isAsync]);
const { placeholder, onChange, value, width, enableAllOption, invalid, disabled, minWidth, maxWidth } = props;
const styles = useStyles2(getComboboxStyles);
const [inputValue, setInputValue] = useState('');
@ -73,19 +52,22 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
} as ComboboxOption<T>;
}, [inputValue]);
const baseItems = useMemo(() => {
return isAsync ? [] : enableAllOption ? [allOptionItem, ...options] : options;
}, [options, enableAllOption, allOptionItem, isAsync]);
// Handle async options and the 'All' option
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options);
const options = useMemo(() => {
// Only add the 'All' option if there's more than 1 option
const addAllOption = enableAllOption && baseOptions.length > 1;
return addAllOption ? [allOptionItem, ...baseOptions] : baseOptions;
}, [baseOptions, enableAllOption, allOptionItem]);
const loading = props.loading || asyncLoading;
const items = useMemo(() => {
const newItems = baseItems.filter(itemFilter(inputValue));
if (enableAllOption && newItems.length === 1 && newItems[0] === allOptionItem) {
const selectedItems = useMemo(() => {
if (!value) {
return [];
}
return newItems;
}, [baseItems, inputValue, enableAllOption, allOptionItem]);
return getSelectedItemsFromValue<T>(value, baseOptions);
}, [value, baseOptions]);
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
selectedItems,
@ -98,48 +80,50 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
[selectedItems]
);
const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({
selectedItems, //initally selected items,
onStateChange: ({ type, selectedItems: newSelectedItems }) => {
switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
if (newSelectedItems) {
onChange(getComboboxOptionsValues(newSelectedItems));
}
break;
const { getSelectedItemProps, getDropdownProps, setSelectedItems, addSelectedItem, removeSelectedItem } =
useMultipleSelection({
selectedItems, // initally selected items,
onStateChange: ({ type, selectedItems: newSelectedItems }) => {
switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
case useMultipleSelection.stateChangeTypes.FunctionAddSelectedItem:
case useMultipleSelection.stateChangeTypes.FunctionSetSelectedItems:
// Unclear why newSelectedItems would be undefined, but this seems logical
onChange(newSelectedItems ?? []);
break;
default:
break;
}
},
stateReducer: (state, actionAndChanges) => {
const { changes } = actionAndChanges;
return {
...changes,
default:
break;
}
},
stateReducer: (state, actionAndChanges) => {
const { changes } = actionAndChanges;
return {
...changes,
/**
* TODO: Fix Hack!
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
* of breaking keyboard navigation.
*
* Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item
* in a multiselect, the stateReducer tries to move focus onto another item which causes the menu to be closed.
* This only seems to happen when you deselect the last item in the selectedItems list.
*
* Check out:
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
* - The activeIndex useEffect in useMultipleSelection https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72
*
* Forcing the activeIndex to -999 both prevents the useEffect that changes the focus from triggering (value never changes)
* and prevents the if statement in useMultipleSelection from focusing anything.
*/
activeIndex: -999,
};
},
});
/**
* TODO: Fix Hack!
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
* of breaking keyboard navigation.
*
* Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item
* in a multiselect, the stateReducer tries to move focus onto another item which causes the menu to be closed.
* This only seems to happen when you deselect the last item in the selectedItems list.
*
* Check out:
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
* - The activeIndex useEffect in useMultipleSelection https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72
*
* Forcing the activeIndex to -999 both prevents the useEffect that changes the focus from triggering (value never changes)
* and prevents the if statement in useMultipleSelection from focusing anything.
*/
activeIndex: -999,
};
},
});
const {
getToggleButtonProps,
@ -150,12 +134,25 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
getInputProps,
getItemProps,
} = useCombobox({
items,
items: options,
itemToString,
inputValue,
selectedItem: null,
stateReducer: (state, actionAndChanges) => {
const { changes, type } = actionAndChanges;
const { type } = actionAndChanges;
let { changes } = actionAndChanges;
const menuBeingOpened = state.isOpen === false && changes.isOpen === true;
// 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: '',
};
}
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
@ -171,39 +168,50 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
}
},
onIsOpenChange: ({ isOpen, inputValue }) => {
if (isOpen && inputValue === '') {
updateOptions(inputValue);
}
},
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
// Handle All functionality
if (newSelectedItem?.value === ALL_OPTION_VALUE) {
const allFilteredSelected = selectedItems.length === items.length - 1;
let newSelectedItems = allFilteredSelected && inputValue === '' ? [] : baseItems.slice(1);
// TODO: fix bug where if the search filtered items list is the
// same length, but different, than the selected items (ask tobias)
const isAllFilteredSelected = selectedItems.length === options.length - 1;
if (!allFilteredSelected && inputValue !== '') {
// if every option is already selected, clear the selection.
// otherwise, select all the options (excluding the first ALL_OTION)
const realOptions = options.slice(1);
let newSelectedItems = isAllFilteredSelected && inputValue === '' ? [] : realOptions;
if (!isAllFilteredSelected && inputValue !== '') {
// Select all currently filtered items and deduplicate
newSelectedItems = [...new Set([...selectedItems, ...items.slice(1)])];
newSelectedItems = [...new Set([...selectedItems, ...realOptions])];
}
if (allFilteredSelected && inputValue !== '') {
if (isAllFilteredSelected && inputValue !== '') {
// Deselect all currently filtered items
const filteredSet = new Set(items.slice(1).map((item) => item.value));
const filteredSet = new Set(realOptions.map((item) => item.value));
newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value));
}
onChange(getComboboxOptionsValues(newSelectedItems));
break;
}
if (newSelectedItem) {
if (!isOptionSelected(newSelectedItem)) {
onChange(getComboboxOptionsValues([...selectedItems, newSelectedItem]));
break;
}
removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here
setSelectedItems(newSelectedItems);
} else if (newSelectedItem && isOptionSelected(newSelectedItem)) {
removeSelectedItem(newSelectedItem);
} else if (newSelectedItem) {
addSelectedItem(newSelectedItem);
}
break;
case useCombobox.stateChangeTypes.InputChange:
setInputValue(newInputValue ?? '');
updateOptions(newInputValue ?? '');
break;
default:
break;
@ -211,14 +219,14 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
},
});
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(options, isOpen);
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled, width, minWidth, maxWidth);
const virtualizerOptions = {
count: items.length,
count: options.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index: number) =>
'description' in items[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT,
'description' in options[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT,
overscan: VIRTUAL_OVERSCAN_ITEMS,
};
@ -291,13 +299,16 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const index = virtualRow.index;
const item = items[index];
const item = options[index];
const itemProps = getItemProps({ item, index });
const isSelected = isOptionSelected(item);
const id = 'multicombobox-option-' + item.value.toString();
const isAll = item.value === ALL_OPTION_VALUE;
// TODO: fix bug where if the search filtered items list is the
// same length, but different, than the selected items (ask tobias)
const allItemsSelected =
items[0]?.value === ALL_OPTION_VALUE && selectedItems.length === items.length - 1;
options[0]?.value === ALL_OPTION_VALUE && selectedItems.length === options.length - 1;
return (
<li
@ -321,7 +332,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
label={
isAll
? (item.label ?? item.value.toString()) +
(isAll && inputValue !== '' ? ` (${items.length - 1})` : '')
(isAll && inputValue !== '' ? ` (${options.length - 1})` : '')
: (item.label ?? item.value.toString())
}
description={item?.description}
@ -332,7 +343,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
);
})}
</ul>
<div aria-live="polite">{items.length === 0 && <NotFoundError />}</div>
<div aria-live="polite">{options.length === 0 && <NotFoundError />}</div>
</ScrollContainer>
)}
</div>
@ -375,7 +386,3 @@ function isComboboxOptions<T extends string | number>(
): value is Array<ComboboxOption<T>> {
return typeof value[0] === 'object';
}
function getComboboxOptionsValues<T extends string | number>(optionArray: Array<ComboboxOption<T>>) {
return optionArray.map((option) => option.value);
}

View File

@ -0,0 +1,82 @@
import { debounce } from 'lodash';
import { useState, useCallback, useMemo } from 'react';
import { itemFilter } from './filter';
import { ComboboxOption } from './types';
import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
type AsyncOptions<T extends string | number> =
| Array<ComboboxOption<T>>
| ((inputValue: string) => Promise<Array<ComboboxOption<T>>>);
const asyncNoop = () => Promise.resolve([]);
/**
* Abstracts away sync/async options for MultiCombobox (and later Combobox).
* It also filters options based on the user's input.
*
* Returns:
* - options either filtered by user's input, or from async options fn
* - function to call when user types (to filter, or call async fn)
* - loading and error states
*/
export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T>) {
const isAsync = typeof rawOptions === 'function';
const loadOptions = useLatestAsyncCall(isAsync ? rawOptions : asyncNoop);
const debouncedLoadOptions = useMemo(
() =>
debounce((searchTerm: string) => {
return loadOptions(searchTerm)
.then((options) => {
setAsyncOptions(options);
setAsyncLoading(false);
setAsyncError(false);
})
.catch((error) => {
if (!(error instanceof StaleResultError)) {
setAsyncError(true);
setAsyncLoading(false);
if (error) {
console.error('Error loading async options for Combobox', error);
}
}
});
}, 200),
[loadOptions]
);
const [asyncOptions, setAsyncOptions] = useState<Array<ComboboxOption<T>>>([]);
const [asyncLoading, setAsyncLoading] = useState(false);
const [asyncError, setAsyncError] = useState(false);
// This hook keeps its own inputValue state (rather than accepting it as an arg) because it needs to be
// told it for async options loading anyway.
const [userTypedSearch, setUserTypedSearch] = useState('');
const updateOptions = useCallback(
(inputValue: string) => {
if (!isAsync) {
setUserTypedSearch(inputValue);
return;
}
setAsyncLoading(true);
debouncedLoadOptions(inputValue);
},
[debouncedLoadOptions, isAsync]
);
const finalOptions = useMemo(() => {
if (isAsync) {
return asyncOptions;
} else {
return rawOptions.filter(itemFilter(userTypedSearch));
}
}, [rawOptions, asyncOptions, isAsync, userTypedSearch]);
return { options: finalOptions, updateOptions, asyncLoading, asyncError };
}