Skip to content

Commit 2444bfe

Browse files
authored
fix(Combobox): ⚡ make controlled input adhere to inputValue and… (#2267)
… send all change events resolves #2264
1 parent 1d79992 commit 2444bfe

File tree

4 files changed

+60
-5
lines changed

4 files changed

+60
-5
lines changed

.changeset/long-boxes-sniff.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@digdir/designsystemet-react": patch
3+
---
4+
5+
fix(Combobox): :zap: make controlled input adhere to `inputValue` and send all change events

packages/react/src/components/form/Combobox/Combobox.test.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,26 @@ describe('Combobox', () => {
149149
});
150150
});
151151

152+
it('should send `onChange` event when value changes', async () => {
153+
const onChange = vi.fn();
154+
const { user } = await render({ onChange });
155+
const combobox = screen.getByRole('combobox');
156+
157+
await act(async () => await user.click(combobox));
158+
await act(async () => await user.click(screen.getByText('Oslo')));
159+
160+
await vi.waitFor(() => {
161+
/* we expect a change event with (e) => e.target.value === 'Oslo' */
162+
expect(onChange).toHaveBeenCalledWith(
163+
expect.objectContaining({
164+
target: expect.objectContaining({
165+
value: 'Oslo',
166+
}),
167+
}),
168+
);
169+
});
170+
});
171+
152172
it('should show a chip of a selected option in multiple mode', async () => {
153173
await render({ multiple: true, value: ['leikanger'] });
154174
expect(screen.getByText('Leikanger')).toBeInTheDocument();

packages/react/src/components/form/Combobox/Combobox.tsx

+13-5
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { Option } from './useCombobox';
2222
import { useCombobox } from './useCombobox';
2323
import { useComboboxKeyboard } from './useComboboxKeyboard';
2424
import { useFloatingCombobox } from './useFloatingCombobox';
25-
import { prefix, removePrefix } from './utilities';
25+
import { prefix, removePrefix, setReactInputValue } from './utilities';
2626

2727
export type ComboboxProps = {
2828
/**
@@ -161,6 +161,12 @@ export const ComboboxComponent = forwardRef<HTMLInputElement, ComboboxProps>(
161161

162162
const [inputValue, setInputValue] = useState<string>(rest.inputValue || '');
163163

164+
useEffect(() => {
165+
if (typeof rest.inputValue === 'string') {
166+
setInputValue(rest.inputValue);
167+
}
168+
}, [rest.inputValue]);
169+
164170
const {
165171
selectedOptions,
166172
options,
@@ -208,7 +214,8 @@ export const ComboboxComponent = forwardRef<HTMLInputElement, ComboboxProps>(
208214
useEffect(() => {
209215
if (value && value.length > 0 && !multiple) {
210216
const option = options[prefix(value[0])];
211-
setInputValue(option?.label || '');
217+
inputRef.current &&
218+
setReactInputValue(inputRef.current, option?.label || '');
212219
}
213220
}, [multiple, value, options]);
214221

@@ -239,7 +246,7 @@ export const ComboboxComponent = forwardRef<HTMLInputElement, ComboboxProps>(
239246
const { option, clear, remove } = args;
240247
if (clear) {
241248
setSelectedOptions({});
242-
setInputValue('');
249+
inputRef.current && setReactInputValue(inputRef.current, '');
243250
onValueChange?.([]);
244251
return;
245252
}
@@ -264,15 +271,16 @@ export const ComboboxComponent = forwardRef<HTMLInputElement, ComboboxProps>(
264271
} else {
265272
newSelectedOptions[prefix(option.value)] = option;
266273
}
267-
setInputValue('');
274+
inputRef.current && setReactInputValue(inputRef.current, '');
268275
inputRef.current?.focus();
269276
} else {
270277
/* clear newSelectedOptions */
271278
for (const key of Object.keys(newSelectedOptions)) {
272279
delete newSelectedOptions[key];
273280
}
274281
newSelectedOptions[prefix(option.value)] = option;
275-
setInputValue(option?.label || '');
282+
inputRef.current &&
283+
setReactInputValue(inputRef.current, option?.label || '');
276284
// move cursor to the end of the input
277285
setTimeout(() => {
278286
inputRef.current?.setSelectionRange(

packages/react/src/components/form/Combobox/utilities.ts

+22
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,25 @@ export const prefix = (value?: string): string => {
3838
export const removePrefix = (value: string): string => {
3939
return value.slice(INTERNAL_OPTION_PREFIX.length);
4040
};
41+
42+
// Workaround from https://github.com/equinor/design-system/blob/develop/packages/eds-utils/src/utils/setReactInputValue.ts
43+
// React ignores 'dispathEvent' on input/textarea, see https://github.com/facebook/react/issues/10135
44+
type ReactInternalHack = { _valueTracker?: { setValue: (a: string) => void } };
45+
46+
export const setReactInputValue = (
47+
input: HTMLInputElement & ReactInternalHack,
48+
value: string,
49+
): void => {
50+
const previousValue = input.value;
51+
52+
input.value = value;
53+
54+
const tracker = input._valueTracker;
55+
56+
if (typeof tracker !== 'undefined') {
57+
tracker.setValue(previousValue);
58+
}
59+
60+
//'change' instead of 'input', see https://github.com/facebook/react/issues/11488#issuecomment-381590324
61+
input.dispatchEvent(new Event('change', { bubbles: true }));
62+
};

0 commit comments

Comments
 (0)