From ab78fbd91eb6e00ad1c56642bb7eece489f2d3bb Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Oct 2022 15:17:52 -0400 Subject: [PATCH] =?UTF-8?q?Fire=20user=E2=80=99s=20onChange=20handler=20wh?= =?UTF-8?q?en=20we=20update=20the=20combobox=20input=20value=20internally?= =?UTF-8?q?=20(#1916)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fire user’s onChange handler when we update the input value internally * Update changelog * Fix CS --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/components/combobox/combobox.test.tsx | 52 +++++++++++++++++- .../src/components/combobox/combobox.tsx | 33 +++++++++++- packages/@headlessui-vue/CHANGELOG.md | 1 + .../src/components/combobox/combobox.test.ts | 54 +++++++++++++++++++ .../src/components/combobox/combobox.ts | 23 +++++++- 6 files changed, 159 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index d7701e00e3..196c2de487 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix `` crash ([#1889](https://github.com/tailwindlabs/headlessui/pull/1889)) - Expose `close` function for `Menu` and `Menu.Item` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897)) - Fix `useOutsideClick`, add improvements for ShadowDOM ([#1914](https://github.com/tailwindlabs/headlessui/pull/1914)) +- Fire ``'s `onChange` handler when changing the value internally ([#1916](https://github.com/tailwindlabs/headlessui/pull/1916)) ### Added diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 5f21d3553d..66c11c018e 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -2851,6 +2851,56 @@ describe('Keyboard interactions', () => { expect(getComboboxInput()?.value).toBe('option-b') }) ) + + it( + 'The onChange handler is fired when the input value is changed internally', + suppressConsoleLogs(async () => { + let currentSearchQuery: string = '' + + render( + + { + currentSearchQuery = event.target.value + }} + /> + Trigger + + Option A + Option B + Option C + + + ) + + // Open combobox + await click(getComboboxButton()) + + // Verify that the current search query is empty + expect(currentSearchQuery).toBe('') + + // Look for "Option C" + await type(word('Option C'), getComboboxInput()) + + // The input should be updated + expect(getComboboxInput()?.value).toBe('Option C') + + // The current search query should reflect the input value + expect(currentSearchQuery).toBe('Option C') + + // Close combobox + await press(Keys.Escape) + + // The input should be empty + expect(getComboboxInput()?.value).toBe('') + + // The current search query should be empty like the input + expect(currentSearchQuery).toBe('') + + // The combobox should be closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) }) describe('`ArrowDown` key', () => { @@ -5501,7 +5551,7 @@ describe('Form compatibility', () => { }} > - + Trigger Pizza Delivery diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index e1a5df879c..d4a37146a0 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -666,6 +666,33 @@ let Input = forwardRefWithAs(function Input< let id = `headlessui-combobox-input-${useId()}` let d = useDisposables() + let shouldIgnoreOpenOnChange = false + function updateInputAndNotify(newValue: string) { + let input = data.inputRef.current + if (!input) { + return + } + + // The value is already the same, so we can bail out early + if (input.value === newValue) { + return + } + + // Skip React's value setting which causes the input event to not be fired because it de-dupes input/change events + let descriptor = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value') + descriptor?.set?.call(input, newValue) + + // Fire an input event which causes the browser to trigger the user's `onChange` handler. + // We have to prevent the combobox from opening when this happens. Since these events + // fire synchronously `shouldIgnoreOpenOnChange` will be correct during `handleChange` + shouldIgnoreOpenOnChange = true + input.dispatchEvent(new Event('input', { bubbles: true })) + + // Now we can inform react that the input value has changed + input.value = newValue + shouldIgnoreOpenOnChange = false + } + let currentValue = useMemo(() => { if (typeof displayValue === 'function') { return displayValue(data.value as unknown as TType) ?? '' @@ -682,7 +709,7 @@ let Input = forwardRefWithAs(function Input< ([currentValue, state], [oldCurrentValue, oldState]) => { if (!data.inputRef.current) return if (oldState === ComboboxState.Open && state === ComboboxState.Closed) { - data.inputRef.current.value = currentValue + updateInputAndNotify(currentValue) } else if (currentValue !== oldCurrentValue) { data.inputRef.current.value = currentValue } @@ -787,7 +814,9 @@ let Input = forwardRefWithAs(function Input< }) let handleChange = useEvent((event: React.ChangeEvent) => { - actions.openCombobox() + if (!shouldIgnoreOpenOnChange) { + actions.openCombobox() + } onChange?.(event) }) diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 270efcc75e..210a7f4765 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Expose `close` function for `Menu` and `MenuItem` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897)) - Fix `useOutsideClick`, add improvements for ShadowDOM ([#1914](https://github.com/tailwindlabs/headlessui/pull/1914)) - Prevent default slot warning when using a component for `as` prop ([#1915](https://github.com/tailwindlabs/headlessui/pull/1915)) +- Fire ``'s `@change` handler when changing the value internally ([#1916](https://github.com/tailwindlabs/headlessui/pull/1916)) ## [1.7.3] - 2022-09-30 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 9c8968d9d4..a0fdf897f6 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -2931,6 +2931,60 @@ describe('Keyboard interactions', () => { expect(getComboboxInput()?.value).toBe('option-b') }) ) + + it( + 'The onChange handler is fired when the input value is changed internally', + suppressConsoleLogs(async () => { + let currentSearchQuery: string = '' + + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ + value: ref(null), + onChange: (evt: InputEvent & { target: HTMLInputElement }) => { + currentSearchQuery = evt.target.value + }, + }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify that the current search query is empty + expect(currentSearchQuery).toBe('') + + // Look for "Option C" + await type(word('Option C'), getComboboxInput()) + + // The input should be updated + expect(getComboboxInput()?.value).toBe('Option C') + + // The current search query should reflect the input value + expect(currentSearchQuery).toBe('Option C') + + // Close combobox + await press(Keys.Escape) + + // The input should be empty + expect(getComboboxInput()?.value).toBe('') + + // The current search query should be empty like the input + expect(currentSearchQuery).toBe('') + + // The combobox should be closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) }) describe('`ArrowDown` key', () => { diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index d287ac7cbd..25162e04dd 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -632,6 +632,23 @@ export let ComboboxInput = defineComponent({ // Workaround Vue bug where watching [ref(undefined)] is not fired immediately even when value is true const __fixVueImmediateWatchBug__ = ref('') + let shouldIgnoreOpenOnChange = false + function updateInputAndNotify(currentValue: string) { + let input = dom(api.inputRef) + if (!input) { + return + } + + input.value = currentValue + + // Fire an input event which causes the browser to trigger the user's `onChange` handler. + // We have to prevent the combobox from opening when this happens. Since these events + // fire synchronously `shouldIgnoreOpenOnChange` will be correct during `handleChange` + shouldIgnoreOpenOnChange = true + input.dispatchEvent(new Event('input', { bubbles: true })) + shouldIgnoreOpenOnChange = false + } + onMounted(() => { watch( [api.value, __fixVueImmediateWatchBug__], @@ -650,7 +667,7 @@ export let ComboboxInput = defineComponent({ let input = dom(api.inputRef) if (!input) return if (oldState === ComboboxStates.Open && state === ComboboxStates.Closed) { - input.value = currentValue + updateInputAndNotify(currentValue) } else if (currentValue !== oldCurrentValue) { input.value = currentValue } @@ -756,7 +773,9 @@ export let ComboboxInput = defineComponent({ } function handleInput(event: Event & { target: HTMLInputElement }) { - api.openCombobox() + if (!shouldIgnoreOpenOnChange) { + api.openCombobox() + } emit('change', event) }