New Select: Create custom value (#92652)

* Display custom value in option list

* Ignore create new value when there is an exact match

* Support displaying custom value

* Add i18n string

* Update failing test
This commit is contained in:
Tobias Skarhed
2024-09-02 14:42:42 +02:00
committed by GitHub
parent 5871362ccf
commit 220215c9ca
5 changed files with 72 additions and 20 deletions
@@ -97,9 +97,9 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...arg
loading={isLoading}
options={options}
value={value}
onChange={(val) => {
setValue(val?.value || null);
action('onChange')(val);
onChange={(opt) => {
setValue(opt?.value || null);
action('onChange')(opt);
}}
/>
);
@@ -79,7 +79,7 @@ describe('Combobox', () => {
});
it('clears selected value', async () => {
render(<Combobox options={options} value={options[1].value} onChange={onChangeHandler} />);
render(<Combobox options={options} value={options[1].value} onChange={onChangeHandler} isClearable />);
expect(screen.queryByDisplayValue('Option 2')).toBeInTheDocument();
const input = screen.getByRole('combobox');
@@ -23,10 +23,12 @@ interface ComboboxProps
onChange: (val: Option | null) => void;
value: Value | null;
options: Option[];
isClearable?: boolean;
createCustomValue?: boolean;
}
function itemToString(item: Option | null) {
return item?.label ?? '';
return item?.label ?? item?.value?.toString() ?? '';
}
function itemFilter(inputValue: string) {
@@ -51,13 +53,44 @@ const INDEX_WIDTH_CALCULATION = 100;
// A multiplier guesstimate times the amount of characters. If any padding or image support etc. is added this will need to be updated.
const WIDTH_MULTIPLIER = 7.3;
export const Combobox = ({ options, onChange, value, id, ...restProps }: ComboboxProps) => {
export const Combobox = ({
options,
onChange,
value,
isClearable = false,
createCustomValue = false,
id,
...restProps
}: ComboboxProps) => {
const [items, setItems] = useState(options);
const selectedItemIndex = useMemo(
() => options.findIndex((option) => option.value === value) || null,
[options, value]
);
const selectedItem = selectedItemIndex ? options[selectedItemIndex] : null;
const selectedItemIndex = useMemo(() => {
if (value === null) {
return null;
}
const index = options.findIndex((option) => option.value === value);
if (index === -1) {
return null;
}
return index;
}, [options, value]);
const selectedItem = useMemo(() => {
if (selectedItemIndex) {
return options[selectedItemIndex];
}
// Custom value
if (value !== null) {
return {
label: value.toString(),
value,
};
}
return null;
}, [selectedItemIndex, options, value]);
const inputRef = useRef<HTMLInputElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
@@ -82,18 +115,34 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo
isOpen,
highlightedIndex,
setInputValue,
selectItem,
openMenu,
closeMenu,
selectItem,
} = useCombobox({
inputId: id,
items,
itemToString,
selectedItem,
onSelectedItemChange: ({ selectedItem, inputValue }) => {
onChange(selectedItem);
},
defaultHighlightedIndex: selectedItemIndex ?? undefined,
scrollIntoView: () => {},
onInputValueChange: ({ inputValue }) => {
setItems(options.filter(itemFilter(inputValue)));
const filteredItems = options.filter(itemFilter(inputValue));
if (createCustomValue && inputValue && filteredItems.findIndex((opt) => opt.label === inputValue) === -1) {
setItems([
...filteredItems,
{
label: inputValue,
value: inputValue,
description: t('combobox.custom-value.create', 'Create custom value'),
},
]);
return;
} else {
setItems(filteredItems);
}
},
onIsOpenChange: ({ isOpen }) => {
// Default to displaying all values when opening
@@ -102,9 +151,6 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo
return;
}
},
onSelectedItemChange: ({ selectedItem }) => {
onChange(selectedItem);
},
onHighlightedIndexChange: ({ highlightedIndex, type }) => {
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
rowVirtualizer.scrollToIndex(highlightedIndex);
@@ -113,8 +159,8 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo
});
const onBlur = useCallback(() => {
setInputValue(selectedItem?.label ?? '');
}, [selectedItem, setInputValue]);
setInputValue(selectedItem?.label ?? value?.toString() ?? '');
}, [selectedItem, setInputValue, value]);
// the order of middleware is important!
const middleware = [
@@ -147,7 +193,7 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo
<Input
suffix={
<>
{!!value && value === selectedItem?.value && (
{!!value && value === selectedItem?.value && isClearable && (
<Icon
name="times"
className={styles.clear}
@@ -202,7 +248,7 @@ export const Combobox = ({ options, onChange, value, id, ...restProps }: Combobo
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
return (
<li
key={items[virtualRow.index].value}
key={items[virtualRow.index].value + items[virtualRow.index].label}
data-index={virtualRow.index}
className={cx(
styles.option,
+3
View File
@@ -333,6 +333,9 @@
"combobox": {
"clear": {
"title": "Clear value"
},
"custom-value": {
"create": "Create custom value"
}
},
"command-palette": {
@@ -333,6 +333,9 @@
"combobox": {
"clear": {
"title": "Cľęäř väľūę"
},
"custom-value": {
"create": "Cřęäŧę čūşŧőm väľūę"
}
},
"command-palette": {