Skip to content

Commit d3b4a62

Browse files
fix(combobox): onInputChange calls (#18713)
* fix(combobox): onInputChange calls * Remove useEffect calling onInputChange on initial render * Leverage useCombobox's onStateChange to call onInputChange * Unit tests * fix(combobox): onInputChange calls * Rewrite onInputChange logic to call when inputValue has changed in useEffect * remove explicit calls to onInputChange * Comprehensive unit tests for this prop * fix(combobox): onInputChange calls --------- Co-authored-by: Preeti Bansal <146315451+preetibansalui@users.noreply.github.com>
1 parent d894cb7 commit d3b4a62

File tree

2 files changed

+106
-12
lines changed

2 files changed

+106
-12
lines changed

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

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@ const ControlledComboBox = ({ controlledItem }) => {
3333
const items = generateItems(5, generateGenericItem);
3434
const [value, setValue] = useState(controlledItem || items[0]);
3535
const [onChangeCallCount, setOnChangeCallCount] = useState(0);
36+
const [onInputChangeCallCount, setOnInputChangeCallCount] = useState(0);
3637
const controlledOnChange = ({ selectedItem }) => {
3738
setValue(selectedItem);
3839
setOnChangeCallCount((prevCount) => prevCount + 1);
3940
};
41+
const controlledOnInputChange = () => {
42+
setOnInputChangeCallCount((prevCount) => prevCount + 1);
43+
};
4044

4145
return (
4246
<div>
@@ -45,11 +49,13 @@ const ControlledComboBox = ({ controlledItem }) => {
4549
items={items}
4650
selectedItem={value}
4751
onChange={controlledOnChange}
52+
onInputChange={controlledOnInputChange}
4853
placeholder="Filter..."
4954
type="default"
5055
/>
5156
<div>value: {value?.label || 'none'}</div>
5257
<div>onChangeCallCount: {onChangeCallCount}</div>
58+
<div>onInputChangeCallCount: {onInputChangeCallCount}</div>
5359
<button onClick={() => setValue(items[3])}>Choose item 3</button>
5460
<button onClick={() => setValue(null)}>reset</button>
5561
</div>
@@ -184,13 +190,74 @@ describe('ComboBox', () => {
184190
});
185191
});
186192

187-
it('capture filter text event onInputChange', async () => {
188-
const onInputChange = jest.fn();
189-
render(<ComboBox {...mockProps} onInputChange={onInputChange} />);
193+
describe('onInputChange', () => {
194+
let onInputChange;
195+
beforeEach(() => {
196+
onInputChange = jest.fn();
197+
});
198+
it('should not call onChange or onInputChange on initial render', () => {
199+
render(<ComboBox {...mockProps} onInputChange={onInputChange} />);
200+
expect(onInputChange).not.toHaveBeenCalled();
201+
expect(mockProps.onChange).not.toHaveBeenCalled();
202+
});
190203

191-
await userEvent.type(findInputNode(), 'something');
204+
it('capture filter text event onInputChange', async () => {
205+
render(<ComboBox {...mockProps} onInputChange={onInputChange} />);
206+
await userEvent.type(findInputNode(), 'something');
207+
expect(onInputChange).toHaveBeenCalledWith('something');
208+
});
209+
210+
it('should call onInputChange when option is selected from dropdown', async () => {
211+
render(<ComboBox {...mockProps} onInputChange={onInputChange} />);
212+
await openMenu();
213+
expect(onInputChange).not.toHaveBeenCalled();
214+
await userEvent.click(screen.getByRole('option', { name: 'Item 2' }));
215+
expect(onInputChange).toHaveBeenCalledWith('Item 2');
216+
});
217+
218+
it('should call onInputChange when option is cleared with button', async () => {
219+
render(
220+
<ComboBox
221+
{...mockProps}
222+
initialSelectedItem={mockProps.items[0]}
223+
onInputChange={onInputChange}
224+
/>
225+
);
226+
expect(onInputChange).not.toHaveBeenCalled();
227+
await userEvent.click(
228+
screen.getByRole('button', { name: 'Clear selected item' })
229+
);
230+
expect(onInputChange).toHaveBeenCalledWith('');
231+
});
232+
233+
it('should not call onInputChange when combobox is interacted with but input value does not change', async () => {
234+
render(
235+
<ComboBox
236+
{...mockProps}
237+
initialSelectedItem={mockProps.items[0]}
238+
onInputChange={onInputChange}
239+
/>
240+
);
241+
expect(onInputChange).not.toHaveBeenCalled();
242+
await openMenu();
243+
await userEvent.click(screen.getByRole('option', { name: 'Item 0' }));
244+
expect(onInputChange).not.toHaveBeenCalled();
245+
});
192246

193-
expect(onInputChange).toHaveBeenCalledWith('something');
247+
it('should call onInputChange when custom value is entered into combobox', async () => {
248+
render(
249+
<ComboBox
250+
{...mockProps}
251+
initialSelectedItem={mockProps.items[0]}
252+
onInputChange={onInputChange}
253+
/>
254+
);
255+
await userEvent.clear(findInputNode());
256+
expect(onInputChange).toHaveBeenCalledWith('');
257+
await userEvent.type(findInputNode(), 'custom value');
258+
await userEvent.keyboard('[Enter]');
259+
expect(onInputChange).toHaveBeenCalledWith('custom value');
260+
});
194261
});
195262

196263
it('should render custom item components', async () => {
@@ -372,6 +439,35 @@ describe('ComboBox', () => {
372439
await waitForPosition();
373440
expect(findInputNode()).toHaveDisplayValue(mockProps.items[0].label);
374441
});
442+
it('should not call onChange or onInputChange on initial render', () => {
443+
render(<ControlledComboBox />);
444+
expect(screen.getByText('onChangeCallCount: 0')).toBeInTheDocument();
445+
expect(screen.getByText('onInputChangeCallCount: 0')).toBeInTheDocument();
446+
});
447+
it('should call onInputChange when input changes', async () => {
448+
render(<ControlledComboBox />);
449+
await userEvent.type(findInputNode(), 'Item 2');
450+
expect(screen.getByText('onInputChangeCallCount: 6')).toBeInTheDocument();
451+
});
452+
it('should call onInputChange when external state managing selectedItem is updated', async () => {
453+
render(<ControlledComboBox />);
454+
await userEvent.click(
455+
screen.getByRole('button', { name: 'Choose item 3' })
456+
);
457+
expect(screen.getByText('onInputChangeCallCount: 1')).toBeInTheDocument();
458+
});
459+
it('should not call onChange or onInputChange when external state managing selectedItem is updated to same value', async () => {
460+
render(
461+
<ControlledComboBox
462+
controlledItem={{ id: 'id-3', label: 'Item 3', value: 3 }}
463+
/>
464+
);
465+
await userEvent.click(
466+
screen.getByRole('button', { name: 'Choose item 3' })
467+
);
468+
expect(screen.getByText('onChangeCallCount: 0')).toBeInTheDocument();
469+
expect(screen.getByText('onInputChangeCallCount: 0')).toBeInTheDocument();
470+
});
375471

376472
it('should display selected item using a string type for the `selectedItem` prop', async () => {
377473
// Replace the 'items' property in mockProps with a list of strings

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

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ const ComboBox = forwardRef(
504504
const textInput = useRef<HTMLInputElement>(null);
505505
const comboBoxInstanceId = useId();
506506
const [isFocused, setIsFocused] = useState(false);
507-
const savedOnInputChange = useRef(onInputChange);
507+
const prevInputValue = useRef(inputValue);
508508
const prevSelectedItemProp = useRef<ItemType | null | undefined>(
509509
selectedItemProp
510510
);
@@ -547,13 +547,11 @@ const ComboBox = forwardRef(
547547
: defaultShouldFilterItem()
548548
);
549549

550+
// call onInputChange whenever inputValue is updated
550551
useEffect(() => {
551-
savedOnInputChange.current = onInputChange;
552-
}, [onInputChange]);
553-
554-
useEffect(() => {
555-
if (savedOnInputChange.current) {
556-
savedOnInputChange.current(inputValue);
552+
if (prevInputValue.current !== inputValue) {
553+
prevInputValue.current = inputValue;
554+
onInputChange && onInputChange(inputValue);
557555
}
558556
}, [inputValue]);
559557

0 commit comments

Comments
 (0)