Skip to content

Commit ab78fbd

Browse files
Fire user’s onChange handler when we update the combobox input value internally (#1916)
* Fire user’s onChange handler when we update the input value internally * Update changelog * Fix CS
1 parent 17de0a2 commit ab78fbd

File tree

6 files changed

+159
-5
lines changed

6 files changed

+159
-5
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Fix `<Popover.Button as={Fragment} />` crash ([#1889](https://github.com/tailwindlabs/headlessui/pull/1889))
1313
- Expose `close` function for `Menu` and `Menu.Item` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897))
1414
- Fix `useOutsideClick`, add improvements for ShadowDOM ([#1914](https://github.com/tailwindlabs/headlessui/pull/1914))
15+
- Fire `<Combobox.Input>`'s `onChange` handler when changing the value internally ([#1916](https://github.com/tailwindlabs/headlessui/pull/1916))
1516

1617
### Added
1718

packages/@headlessui-react/src/components/combobox/combobox.test.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2851,6 +2851,56 @@ describe('Keyboard interactions', () => {
28512851
expect(getComboboxInput()?.value).toBe('option-b')
28522852
})
28532853
)
2854+
2855+
it(
2856+
'The onChange handler is fired when the input value is changed internally',
2857+
suppressConsoleLogs(async () => {
2858+
let currentSearchQuery: string = ''
2859+
2860+
render(
2861+
<Combobox value={null} onChange={console.log}>
2862+
<Combobox.Input
2863+
onChange={(event) => {
2864+
currentSearchQuery = event.target.value
2865+
}}
2866+
/>
2867+
<Combobox.Button>Trigger</Combobox.Button>
2868+
<Combobox.Options>
2869+
<Combobox.Option value="option-a">Option A</Combobox.Option>
2870+
<Combobox.Option value="option-b">Option B</Combobox.Option>
2871+
<Combobox.Option value="option-c">Option C</Combobox.Option>
2872+
</Combobox.Options>
2873+
</Combobox>
2874+
)
2875+
2876+
// Open combobox
2877+
await click(getComboboxButton())
2878+
2879+
// Verify that the current search query is empty
2880+
expect(currentSearchQuery).toBe('')
2881+
2882+
// Look for "Option C"
2883+
await type(word('Option C'), getComboboxInput())
2884+
2885+
// The input should be updated
2886+
expect(getComboboxInput()?.value).toBe('Option C')
2887+
2888+
// The current search query should reflect the input value
2889+
expect(currentSearchQuery).toBe('Option C')
2890+
2891+
// Close combobox
2892+
await press(Keys.Escape)
2893+
2894+
// The input should be empty
2895+
expect(getComboboxInput()?.value).toBe('')
2896+
2897+
// The current search query should be empty like the input
2898+
expect(currentSearchQuery).toBe('')
2899+
2900+
// The combobox should be closed
2901+
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
2902+
})
2903+
)
28542904
})
28552905

28562906
describe('`ArrowDown` key', () => {
@@ -5501,7 +5551,7 @@ describe('Form compatibility', () => {
55015551
}}
55025552
>
55035553
<Combobox value={value} onChange={setValue} name="delivery">
5504-
<Combobox.Input onChange={console.log} />
5554+
<Combobox.Input onChange={NOOP} />
55055555
<Combobox.Button>Trigger</Combobox.Button>
55065556
<Combobox.Label>Pizza Delivery</Combobox.Label>
55075557
<Combobox.Options>

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,33 @@ let Input = forwardRefWithAs(function Input<
666666
let id = `headlessui-combobox-input-${useId()}`
667667
let d = useDisposables()
668668

669+
let shouldIgnoreOpenOnChange = false
670+
function updateInputAndNotify(newValue: string) {
671+
let input = data.inputRef.current
672+
if (!input) {
673+
return
674+
}
675+
676+
// The value is already the same, so we can bail out early
677+
if (input.value === newValue) {
678+
return
679+
}
680+
681+
// Skip React's value setting which causes the input event to not be fired because it de-dupes input/change events
682+
let descriptor = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value')
683+
descriptor?.set?.call(input, newValue)
684+
685+
// Fire an input event which causes the browser to trigger the user's `onChange` handler.
686+
// We have to prevent the combobox from opening when this happens. Since these events
687+
// fire synchronously `shouldIgnoreOpenOnChange` will be correct during `handleChange`
688+
shouldIgnoreOpenOnChange = true
689+
input.dispatchEvent(new Event('input', { bubbles: true }))
690+
691+
// Now we can inform react that the input value has changed
692+
input.value = newValue
693+
shouldIgnoreOpenOnChange = false
694+
}
695+
669696
let currentValue = useMemo(() => {
670697
if (typeof displayValue === 'function') {
671698
return displayValue(data.value as unknown as TType) ?? ''
@@ -682,7 +709,7 @@ let Input = forwardRefWithAs(function Input<
682709
([currentValue, state], [oldCurrentValue, oldState]) => {
683710
if (!data.inputRef.current) return
684711
if (oldState === ComboboxState.Open && state === ComboboxState.Closed) {
685-
data.inputRef.current.value = currentValue
712+
updateInputAndNotify(currentValue)
686713
} else if (currentValue !== oldCurrentValue) {
687714
data.inputRef.current.value = currentValue
688715
}
@@ -787,7 +814,9 @@ let Input = forwardRefWithAs(function Input<
787814
})
788815

789816
let handleChange = useEvent((event: React.ChangeEvent<HTMLInputElement>) => {
790-
actions.openCombobox()
817+
if (!shouldIgnoreOpenOnChange) {
818+
actions.openCombobox()
819+
}
791820
onChange?.(event)
792821
})
793822

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Expose `close` function for `Menu` and `MenuItem` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897))
1313
- Fix `useOutsideClick`, add improvements for ShadowDOM ([#1914](https://github.com/tailwindlabs/headlessui/pull/1914))
1414
- Prevent default slot warning when using a component for `as` prop ([#1915](https://github.com/tailwindlabs/headlessui/pull/1915))
15+
- Fire `<ComboboxInput>`'s `@change` handler when changing the value internally ([#1916](https://github.com/tailwindlabs/headlessui/pull/1916))
1516

1617
## [1.7.3] - 2022-09-30
1718

packages/@headlessui-vue/src/components/combobox/combobox.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2931,6 +2931,60 @@ describe('Keyboard interactions', () => {
29312931
expect(getComboboxInput()?.value).toBe('option-b')
29322932
})
29332933
)
2934+
2935+
it(
2936+
'The onChange handler is fired when the input value is changed internally',
2937+
suppressConsoleLogs(async () => {
2938+
let currentSearchQuery: string = ''
2939+
2940+
renderTemplate({
2941+
template: html`
2942+
<Combobox v-model="value">
2943+
<ComboboxInput @change="onChange" />
2944+
<ComboboxButton>Trigger</ComboboxButton>
2945+
<ComboboxOptions>
2946+
<ComboboxOption value="option-a">Option A</ComboboxOption>
2947+
<ComboboxOption value="option-b">Option B</ComboboxOption>
2948+
<ComboboxOption value="option-c">Option C</ComboboxOption>
2949+
</ComboboxOptions>
2950+
</Combobox>
2951+
`,
2952+
setup: () => ({
2953+
value: ref(null),
2954+
onChange: (evt: InputEvent & { target: HTMLInputElement }) => {
2955+
currentSearchQuery = evt.target.value
2956+
},
2957+
}),
2958+
})
2959+
2960+
// Open combobox
2961+
await click(getComboboxButton())
2962+
2963+
// Verify that the current search query is empty
2964+
expect(currentSearchQuery).toBe('')
2965+
2966+
// Look for "Option C"
2967+
await type(word('Option C'), getComboboxInput())
2968+
2969+
// The input should be updated
2970+
expect(getComboboxInput()?.value).toBe('Option C')
2971+
2972+
// The current search query should reflect the input value
2973+
expect(currentSearchQuery).toBe('Option C')
2974+
2975+
// Close combobox
2976+
await press(Keys.Escape)
2977+
2978+
// The input should be empty
2979+
expect(getComboboxInput()?.value).toBe('')
2980+
2981+
// The current search query should be empty like the input
2982+
expect(currentSearchQuery).toBe('')
2983+
2984+
// The combobox should be closed
2985+
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
2986+
})
2987+
)
29342988
})
29352989

29362990
describe('`ArrowDown` key', () => {

packages/@headlessui-vue/src/components/combobox/combobox.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,23 @@ export let ComboboxInput = defineComponent({
632632
// Workaround Vue bug where watching [ref(undefined)] is not fired immediately even when value is true
633633
const __fixVueImmediateWatchBug__ = ref('')
634634

635+
let shouldIgnoreOpenOnChange = false
636+
function updateInputAndNotify(currentValue: string) {
637+
let input = dom(api.inputRef)
638+
if (!input) {
639+
return
640+
}
641+
642+
input.value = currentValue
643+
644+
// Fire an input event which causes the browser to trigger the user's `onChange` handler.
645+
// We have to prevent the combobox from opening when this happens. Since these events
646+
// fire synchronously `shouldIgnoreOpenOnChange` will be correct during `handleChange`
647+
shouldIgnoreOpenOnChange = true
648+
input.dispatchEvent(new Event('input', { bubbles: true }))
649+
shouldIgnoreOpenOnChange = false
650+
}
651+
635652
onMounted(() => {
636653
watch(
637654
[api.value, __fixVueImmediateWatchBug__],
@@ -650,7 +667,7 @@ export let ComboboxInput = defineComponent({
650667
let input = dom(api.inputRef)
651668
if (!input) return
652669
if (oldState === ComboboxStates.Open && state === ComboboxStates.Closed) {
653-
input.value = currentValue
670+
updateInputAndNotify(currentValue)
654671
} else if (currentValue !== oldCurrentValue) {
655672
input.value = currentValue
656673
}
@@ -756,7 +773,9 @@ export let ComboboxInput = defineComponent({
756773
}
757774

758775
function handleInput(event: Event & { target: HTMLInputElement }) {
759-
api.openCombobox()
776+
if (!shouldIgnoreOpenOnChange) {
777+
api.openCombobox()
778+
}
760779
emit('change', event)
761780
}
762781

0 commit comments

Comments
 (0)