Skip to content

Commit ad99a86

Browse files
authored
fix(ComboBox): preserve input on blur and toggle (#20405)
* fix(ComboBox): preserve input on blur and toggle * fix: prevent custom values when allowCustomValue is false * fix: clear disallowed custom values on blur
1 parent 5f73957 commit ad99a86

File tree

2 files changed

+101
-27
lines changed

2 files changed

+101
-27
lines changed

packages/react/src/components/ComboBox/ComboBox-test.js

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,33 @@ describe('ComboBox', () => {
415415
expect(screen.getByTestId('selected-item').textContent).toBe('Item 0');
416416
});
417417

418+
it('should restore selected item label on blur when input does not match any item and a selection exists', async () => {
419+
render(
420+
<ComboBox
421+
{...mockProps}
422+
initialSelectedItem={mockProps.items[1]}
423+
allowCustomValue={false}
424+
/>
425+
);
426+
427+
expect(findInputNode()).toHaveDisplayValue('Item 1');
428+
429+
await userEvent.clear(findInputNode());
430+
await userEvent.type(findInputNode(), 'no-match');
431+
await userEvent.keyboard('[Tab]');
432+
433+
expect(findInputNode()).toHaveDisplayValue('Item 1');
434+
});
435+
436+
it('should keep exact match input on blur when it matches an item label', async () => {
437+
render(<ComboBox {...mockProps} allowCustomValue={false} />);
438+
439+
await userEvent.type(findInputNode(), 'Item 2');
440+
await userEvent.keyboard('[Tab]');
441+
442+
expect(findInputNode()).toHaveDisplayValue('Item 2');
443+
});
444+
418445
describe('should display initially selected item found in `initialSelectedItem`', () => {
419446
it('using an object type for the `initialSelectedItem` prop', async () => {
420447
render(
@@ -847,6 +874,31 @@ describe('ComboBox', () => {
847874
expect(findInputNode()).toHaveDisplayValue('');
848875
});
849876

877+
it('should not clear input when opening then closing the menu without changes', async () => {
878+
render(
879+
<ComboBox {...mockProps} initialSelectedItem={mockProps.items[1]} />
880+
);
881+
882+
expect(findInputNode()).toHaveDisplayValue('Item 1');
883+
884+
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
885+
assertMenuOpen(mockProps);
886+
887+
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
888+
assertMenuClosed(mockProps);
889+
890+
expect(findInputNode()).toHaveDisplayValue('Item 1');
891+
});
892+
893+
it('should clear input on blur when no item is selected and value does not match any item (`allowCustomValue` is `false`)', async () => {
894+
render(<ComboBox {...mockProps} allowCustomValue={false} />);
895+
896+
await userEvent.type(findInputNode(), 'no-match-here');
897+
await userEvent.keyboard('[Tab]');
898+
899+
expect(findInputNode()).toHaveDisplayValue('');
900+
});
901+
850902
it('should pass defined selectedItem to onChange when item is selected', async () => {
851903
render(<ComboBox {...mockProps} />);
852904

@@ -1453,7 +1505,7 @@ describe('ComboBox', () => {
14531505
expect(attributes).toEqual({
14541506
'aria-activedescendant': '',
14551507
'aria-autocomplete': 'list',
1456-
'aria-controls': 'downshift-«r7r»-menu',
1508+
'aria-controls': attributes['aria-controls'],
14571509
'aria-expanded': 'false',
14581510
'aria-haspopup': 'listbox',
14591511
'aria-label': 'Choose an item',

packages/react/src/components/ComboBox/ComboBox.tsx

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -588,32 +588,51 @@ const ComboBox = forwardRef(
588588

589589
switch (type) {
590590
case InputBlur: {
591-
if (allowCustomValue && highlightedIndex == '-1') {
592-
const customValue = inputValue as ItemType;
593-
changes.selectedItem = customValue;
591+
// If custom values are allowed, treat whatever the user typed as
592+
// the value.
593+
if (allowCustomValue && highlightedIndex === -1) {
594+
const { inputValue } = state;
595+
596+
changes.selectedItem = inputValue;
597+
594598
if (onChange) {
595-
onChange({ selectedItem: inputValue as ItemType, inputValue });
599+
onChange({ selectedItem: inputValue, inputValue });
596600
}
601+
597602
return changes;
598603
}
604+
605+
// If a new item was selected, keep its label in the input.
599606
if (
600607
state.inputValue &&
601-
highlightedIndex == '-1' &&
608+
highlightedIndex === -1 &&
602609
changes.selectedItem
603610
) {
604611
return {
605612
...changes,
606613
inputValue: itemToString(changes.selectedItem),
607614
};
608615
}
609-
if (
610-
state.inputValue &&
611-
highlightedIndex == '-1' &&
612-
!allowCustomValue &&
613-
!changes.selectedItem
614-
) {
615-
return { ...changes, inputValue: '' };
616+
617+
// If custom values are not allowed, normalize any non-matching
618+
// text. If the input isn’t an exact item label, restore the
619+
// selected label if there is one, or clear it.
620+
if (!allowCustomValue) {
621+
const currentInput = state.inputValue ?? '';
622+
const hasExactMatch =
623+
!!currentInput &&
624+
items.some((item) => itemToString(item) === currentInput);
625+
626+
if (!hasExactMatch) {
627+
const restoredInput =
628+
state.selectedItem !== null
629+
? itemToString(state.selectedItem)
630+
: '';
631+
632+
return { ...changes, inputValue: restoredInput };
633+
}
616634
}
635+
617636
return changes;
618637
}
619638

@@ -667,20 +686,23 @@ const ComboBox = forwardRef(
667686
return { ...changes, isOpen: true };
668687
case FunctionToggleMenu:
669688
case ToggleButtonClick:
670-
if (
671-
!changes.isOpen &&
672-
state.inputValue &&
673-
highlightedIndex === -1 &&
674-
!allowCustomValue
675-
) {
676-
return {
677-
...changes,
678-
inputValue: '', // Clear the input
679-
};
680-
}
681-
if (changes.isOpen && !changes.selectedItem) {
682-
return { ...changes };
689+
// When closing the menu, apply the same normalization as blur.
690+
if (state.isOpen && !changes.isOpen && !allowCustomValue) {
691+
const currentInput = state.inputValue ?? '';
692+
const hasExactMatch =
693+
!!currentInput &&
694+
items.some((item) => itemToString(item) === currentInput);
695+
696+
if (!hasExactMatch) {
697+
const restoredInput =
698+
state.selectedItem !== null
699+
? itemToString(state.selectedItem)
700+
: '';
701+
702+
return { ...changes, inputValue: restoredInput };
703+
}
683704
}
705+
684706
return changes;
685707

686708
case MenuMouseLeave:
@@ -704,7 +726,7 @@ const ComboBox = forwardRef(
704726
}
705727
},
706728
// eslint-disable-next-line react-hooks/exhaustive-deps
707-
[allowCustomValue, inputValue, onChange]
729+
[allowCustomValue, inputValue, itemToString, items, onChange]
708730
);
709731

710732
const handleToggleClick =

0 commit comments

Comments
 (0)