diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 54259325c..624f22081 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't assume `` components are available when setting the next index ([#2642](https://github.com/tailwindlabs/headlessui/pull/2642)) - Fix incorrectly focused `Combobox.Input` component on page load ([#2654](https://github.com/tailwindlabs/headlessui/pull/2654)) - Ensure `appear` works using the `Transition` component (even when used with SSR) ([#2646](https://github.com/tailwindlabs/headlessui/pull/2646)) +- Improve resetting values when using the `nullable` prop on the `Combobox` component ([#2660](https://github.com/tailwindlabs/headlessui/pull/2660)) ## [1.7.16] - 2023-07-27 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index eb57a61e5..e1f4b5580 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -2735,9 +2735,9 @@ describe('Keyboard interactions', () => { await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('') - // Verify that we don't have an active option anymore since we are in `nullable` mode + // Verify that we don't have an selected option anymore since we are in `nullable` mode assertNotActiveComboboxOption(options[1]) - assertNoActiveComboboxOption() + assertNoSelectedComboboxOption() // Verify that we saw the `null` change coming in expect(handleChange).toHaveBeenCalledTimes(1) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index d551fc920..5e7f38a1e 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -745,6 +745,14 @@ function InputFn< let d = useDisposables() + let clear = useEvent(() => { + actions.onChange(null) + if (data.optionsRef.current) { + data.optionsRef.current.scrollTop = 0 + } + actions.goToOption(Focus.Nothing) + }) + // When a `displayValue` prop is given, we should use it to transform the current selected // option(s) so that the format can be chosen by developers implementing this. This is useful if // your data is an object and you just want to pick a certain property or want to create a dynamic @@ -871,23 +879,6 @@ function InputFn< switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 - case Keys.Backspace: - case Keys.Delete: - if (data.mode !== ValueMode.Single) return - if (!data.nullable) return - - let input = event.currentTarget - d.requestAnimationFrame(() => { - if (input.value === '') { - actions.onChange(null) - if (data.optionsRef.current) { - data.optionsRef.current.scrollTop = 0 - } - actions.goToOption(Focus.Nothing) - } - }) - break - case Keys.Enter: isTyping.current = false if (data.comboboxState !== ComboboxState.Open) return @@ -981,6 +972,18 @@ function InputFn< if (data.optionsRef.current && !data.optionsPropsRef.current.static) { event.stopPropagation() } + + if (data.nullable && data.mode === ValueMode.Single) { + // We want to clear the value when the user presses escape if and only if the current + // value is not set (aka, they didn't select anything yet, or they cleared the input which + // caused the value to be set to `null`). If the current value is set, then we want to + // fallback to that value when we press escape (this part is handled in the watcher that + // syncs the value with the input field again). + if (data.value === null) { + clear() + } + } + return actions.closeCombobox() case Keys.Tab: @@ -1001,6 +1004,17 @@ function InputFn< // options while typing won't work at all because we are still in "composing" mode. onChange?.(event) + // When the value becomes empty in a single value mode while being nullable then we want to clear + // the option entirely. + // + // This is can happen when you press backspace, but also when you select all the text and press + // ctrl/cmd+x. + if (data.nullable && data.mode === ValueMode.Single) { + if (event.target.value === '') { + clear() + } + } + // Open the combobox to show the results based on what the user has typed actions.openCombobox() }) diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index 747ef523f..dd0298fd7 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -157,7 +157,9 @@ let order: Record< value: element.value.slice(0, -1), }), }) - return fireEvent.keyDown(element, ev) + + fireEvent.keyDown(element, ev) + return fireEvent.input(element, ev) } return fireEvent.keyDown(element, event) diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 0d08b0dad..2613dc8ae 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't assume `` components are available when setting the next index ([#2642](https://github.com/tailwindlabs/headlessui/pull/2642)) - Improve SSR of the `Disclosure` component ([#2645](https://github.com/tailwindlabs/headlessui/pull/2645)) - Fix incorrectly focused `ComboboxInput` component on page load ([#2654](https://github.com/tailwindlabs/headlessui/pull/2654)) +- Improve resetting values when using the `nullable` prop on the `Combobox` component ([#2660](https://github.com/tailwindlabs/headlessui/pull/2660)) ## [1.7.15] - 2023-07-27 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index a66d0930a..dedeeaca8 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -4554,9 +4554,9 @@ describe('Keyboard interactions', () => { await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('') - // Verify that we don't have an active option anymore since we are in `nullable` mode + // Verify that we don't have an selected option anymore since we are in `nullable` mode assertNotActiveComboboxOption(options[1]) - assertNoActiveComboboxOption() + assertNoSelectedComboboxOption() // Verify that we saw the `null` change coming in expect(handleChange).toHaveBeenCalledTimes(1) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 18baa4140..a5bec8643 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -281,13 +281,13 @@ export let Combobox = defineComponent({ comboboxState.value = ComboboxStates.Open }, goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger) { + defaultToFirstOption.value = false + if (goToOptionRaf !== null) { cancelAnimationFrame(goToOptionRaf) } goToOptionRaf = requestAnimationFrame(() => { - defaultToFirstOption.value = false - if (props.disabled) return if ( optionsRef.value && @@ -707,6 +707,15 @@ export let ComboboxInput = defineComponent({ expose({ el: api.inputRef, $el: api.inputRef }) + function clear() { + api.change(null) + let options = dom(api.optionsRef) + if (options) { + options.scrollTop = 0 + } + api.goToOption(Focus.Nothing) + } + // When a `displayValue` prop is given, we should use it to transform the current selected // option(s) so that the format can be chosen by developers implementing this. This is useful if // your data is an object and you just want to pick a certain property or want to create a dynamic @@ -837,24 +846,6 @@ export let ComboboxInput = defineComponent({ switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 - case Keys.Backspace: - case Keys.Delete: - if (api.mode.value !== ValueMode.Single) return - if (!api.nullable.value) return - - let input = event.currentTarget as HTMLInputElement - requestAnimationFrame(() => { - if (input.value === '') { - api.change(null) - let options = dom(api.optionsRef) - if (options) { - options.scrollTop = 0 - } - api.goToOption(Focus.Nothing) - } - }) - break - case Keys.Enter: isTyping.value = false if (api.comboboxState.value !== ComboboxStates.Open) return @@ -942,6 +933,18 @@ export let ComboboxInput = defineComponent({ if (api.optionsRef.value && !api.optionsPropsRef.value.static) { event.stopPropagation() } + + if (api.nullable.value && api.mode.value === ValueMode.Single) { + // We want to clear the value when the user presses escape if and only if the current + // value is not set (aka, they didn't select anything yet, or they cleared the input which + // caused the value to be set to `null`). If the current value is set, then we want to + // fallback to that value when we press escape (this part is handled in the watcher that + // syncs the value with the input field again). + if (api.value.value === null) { + clear() + } + } + api.closeCombobox() break @@ -963,6 +966,17 @@ export let ComboboxInput = defineComponent({ // options while typing won't work at all because we are still in "composing" mode. emit('change', event) + // When the value becomes empty in a single value mode while being nullable then we want to clear + // the option entirely. + // + // This is can happen when you press backspace, but also when you select all the text and press + // ctrl/cmd+x. + if (api.nullable.value && api.mode.value === ValueMode.Single) { + if (event.target.value === '') { + clear() + } + } + // Open the combobox to show the results based on what the user has typed api.openCombobox() } diff --git a/packages/@headlessui-vue/src/components/transitions/utils/transition.ts b/packages/@headlessui-vue/src/components/transitions/utils/transition.ts index 008a01375..a269ba5cf 100644 --- a/packages/@headlessui-vue/src/components/transitions/utils/transition.ts +++ b/packages/@headlessui-vue/src/components/transitions/utils/transition.ts @@ -86,7 +86,7 @@ export function transition( // then we have some leftovers that should be cleaned. d.add(() => removeClasses(node, ...base, ...from, ...to, ...entered)) - // When we get disposed early, than we should also call the done method but switch the reason. + // When we get disposed early, then we should also call the done method but switch the reason. d.add(() => _done(Reason.Cancelled)) return d.dispose diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts index 57c2ac7f3..612849023 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -155,7 +155,9 @@ let order: Record< value: element.value.slice(0, -1), }), }) - return fireEvent.keyDown(element, ev) + + fireEvent.keyDown(element, ev) + return fireEvent.input(element, ev) } return fireEvent.keyDown(element, event)