Skip to content

Commit 88b068c

Browse files
authored
Improve resetting values when using the nullable prop on the Combobox component (#2660)
* move `nullable` handling to `onChange` of `Combobox.Input` itself We were specifically handling backspace/delete keys to verify if the `Combobox.Input` becomes empty then we can clear the value if we are in single value and in nullable mode. However, this doesn't capture other ways of clearing the `Combobox.Input`, for example when use `cmd+x` or `ctrl+y` in the input. Moving the logic, gives us some of these cases for free. * ensure pressing `escape` also clears the input in nullable, single value mode without an active value * adjust test to ensure we don't have a selected option instead of an active option We still will have an active option (because we default to the first option if nothing is active while the combobox is open). But since we cleared the value when using the `nullable` prop, then it means the `selected` option should be cleared. * ensure `input` event is fired when firing keydown events * ensure `defaultToFirstOption` is always set when going to an option We recently made a Vue improvement that delayed the going to an option, but this also included a bug where the `defaultToFirstOption` was not set at the right time anymore. * update changelog * fix `than` / `then` typo
1 parent 842890d commit 88b068c

File tree

9 files changed

+78
-44
lines changed

9 files changed

+78
-44
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Don't assume `<Tab />` components are available when setting the next index ([#2642](https://github.com/tailwindlabs/headlessui/pull/2642))
1616
- Fix incorrectly focused `Combobox.Input` component on page load ([#2654](https://github.com/tailwindlabs/headlessui/pull/2654))
1717
- Ensure `appear` works using the `Transition` component (even when used with SSR) ([#2646](https://github.com/tailwindlabs/headlessui/pull/2646))
18+
- Improve resetting values when using the `nullable` prop on the `Combobox` component ([#2660](https://github.com/tailwindlabs/headlessui/pull/2660))
1819

1920
## [1.7.16] - 2023-07-27
2021

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2735,9 +2735,9 @@ describe('Keyboard interactions', () => {
27352735
await press(Keys.Backspace)
27362736
expect(getComboboxInput()?.value).toBe('')
27372737

2738-
// Verify that we don't have an active option anymore since we are in `nullable` mode
2738+
// Verify that we don't have an selected option anymore since we are in `nullable` mode
27392739
assertNotActiveComboboxOption(options[1])
2740-
assertNoActiveComboboxOption()
2740+
assertNoSelectedComboboxOption()
27412741

27422742
// Verify that we saw the `null` change coming in
27432743
expect(handleChange).toHaveBeenCalledTimes(1)

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

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,14 @@ function InputFn<
745745

746746
let d = useDisposables()
747747

748+
let clear = useEvent(() => {
749+
actions.onChange(null)
750+
if (data.optionsRef.current) {
751+
data.optionsRef.current.scrollTop = 0
752+
}
753+
actions.goToOption(Focus.Nothing)
754+
})
755+
748756
// When a `displayValue` prop is given, we should use it to transform the current selected
749757
// option(s) so that the format can be chosen by developers implementing this. This is useful if
750758
// 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<
871879
switch (event.key) {
872880
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
873881

874-
case Keys.Backspace:
875-
case Keys.Delete:
876-
if (data.mode !== ValueMode.Single) return
877-
if (!data.nullable) return
878-
879-
let input = event.currentTarget
880-
d.requestAnimationFrame(() => {
881-
if (input.value === '') {
882-
actions.onChange(null)
883-
if (data.optionsRef.current) {
884-
data.optionsRef.current.scrollTop = 0
885-
}
886-
actions.goToOption(Focus.Nothing)
887-
}
888-
})
889-
break
890-
891882
case Keys.Enter:
892883
isTyping.current = false
893884
if (data.comboboxState !== ComboboxState.Open) return
@@ -981,6 +972,18 @@ function InputFn<
981972
if (data.optionsRef.current && !data.optionsPropsRef.current.static) {
982973
event.stopPropagation()
983974
}
975+
976+
if (data.nullable && data.mode === ValueMode.Single) {
977+
// We want to clear the value when the user presses escape if and only if the current
978+
// value is not set (aka, they didn't select anything yet, or they cleared the input which
979+
// caused the value to be set to `null`). If the current value is set, then we want to
980+
// fallback to that value when we press escape (this part is handled in the watcher that
981+
// syncs the value with the input field again).
982+
if (data.value === null) {
983+
clear()
984+
}
985+
}
986+
984987
return actions.closeCombobox()
985988

986989
case Keys.Tab:
@@ -1001,6 +1004,17 @@ function InputFn<
10011004
// options while typing won't work at all because we are still in "composing" mode.
10021005
onChange?.(event)
10031006

1007+
// When the value becomes empty in a single value mode while being nullable then we want to clear
1008+
// the option entirely.
1009+
//
1010+
// This is can happen when you press backspace, but also when you select all the text and press
1011+
// ctrl/cmd+x.
1012+
if (data.nullable && data.mode === ValueMode.Single) {
1013+
if (event.target.value === '') {
1014+
clear()
1015+
}
1016+
}
1017+
10041018
// Open the combobox to show the results based on what the user has typed
10051019
actions.openCombobox()
10061020
})

packages/@headlessui-react/src/test-utils/interactions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ let order: Record<
157157
value: element.value.slice(0, -1),
158158
}),
159159
})
160-
return fireEvent.keyDown(element, ev)
160+
161+
fireEvent.keyDown(element, ev)
162+
return fireEvent.input(element, ev)
161163
}
162164

163165
return fireEvent.keyDown(element, event)

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Don't assume `<Tab />` components are available when setting the next index ([#2642](https://github.com/tailwindlabs/headlessui/pull/2642))
1717
- Improve SSR of the `Disclosure` component ([#2645](https://github.com/tailwindlabs/headlessui/pull/2645))
1818
- Fix incorrectly focused `ComboboxInput` component on page load ([#2654](https://github.com/tailwindlabs/headlessui/pull/2654))
19+
- Improve resetting values when using the `nullable` prop on the `Combobox` component ([#2660](https://github.com/tailwindlabs/headlessui/pull/2660))
1920

2021
## [1.7.15] - 2023-07-27
2122

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4554,9 +4554,9 @@ describe('Keyboard interactions', () => {
45544554
await press(Keys.Backspace)
45554555
expect(getComboboxInput()?.value).toBe('')
45564556

4557-
// Verify that we don't have an active option anymore since we are in `nullable` mode
4557+
// Verify that we don't have an selected option anymore since we are in `nullable` mode
45584558
assertNotActiveComboboxOption(options[1])
4559-
assertNoActiveComboboxOption()
4559+
assertNoSelectedComboboxOption()
45604560

45614561
// Verify that we saw the `null` change coming in
45624562
expect(handleChange).toHaveBeenCalledTimes(1)

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

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -281,13 +281,13 @@ export let Combobox = defineComponent({
281281
comboboxState.value = ComboboxStates.Open
282282
},
283283
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger) {
284+
defaultToFirstOption.value = false
285+
284286
if (goToOptionRaf !== null) {
285287
cancelAnimationFrame(goToOptionRaf)
286288
}
287289

288290
goToOptionRaf = requestAnimationFrame(() => {
289-
defaultToFirstOption.value = false
290-
291291
if (props.disabled) return
292292
if (
293293
optionsRef.value &&
@@ -707,6 +707,15 @@ export let ComboboxInput = defineComponent({
707707

708708
expose({ el: api.inputRef, $el: api.inputRef })
709709

710+
function clear() {
711+
api.change(null)
712+
let options = dom(api.optionsRef)
713+
if (options) {
714+
options.scrollTop = 0
715+
}
716+
api.goToOption(Focus.Nothing)
717+
}
718+
710719
// When a `displayValue` prop is given, we should use it to transform the current selected
711720
// option(s) so that the format can be chosen by developers implementing this. This is useful if
712721
// 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({
837846
switch (event.key) {
838847
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
839848

840-
case Keys.Backspace:
841-
case Keys.Delete:
842-
if (api.mode.value !== ValueMode.Single) return
843-
if (!api.nullable.value) return
844-
845-
let input = event.currentTarget as HTMLInputElement
846-
requestAnimationFrame(() => {
847-
if (input.value === '') {
848-
api.change(null)
849-
let options = dom(api.optionsRef)
850-
if (options) {
851-
options.scrollTop = 0
852-
}
853-
api.goToOption(Focus.Nothing)
854-
}
855-
})
856-
break
857-
858849
case Keys.Enter:
859850
isTyping.value = false
860851
if (api.comboboxState.value !== ComboboxStates.Open) return
@@ -942,6 +933,18 @@ export let ComboboxInput = defineComponent({
942933
if (api.optionsRef.value && !api.optionsPropsRef.value.static) {
943934
event.stopPropagation()
944935
}
936+
937+
if (api.nullable.value && api.mode.value === ValueMode.Single) {
938+
// We want to clear the value when the user presses escape if and only if the current
939+
// value is not set (aka, they didn't select anything yet, or they cleared the input which
940+
// caused the value to be set to `null`). If the current value is set, then we want to
941+
// fallback to that value when we press escape (this part is handled in the watcher that
942+
// syncs the value with the input field again).
943+
if (api.value.value === null) {
944+
clear()
945+
}
946+
}
947+
945948
api.closeCombobox()
946949
break
947950

@@ -963,6 +966,17 @@ export let ComboboxInput = defineComponent({
963966
// options while typing won't work at all because we are still in "composing" mode.
964967
emit('change', event)
965968

969+
// When the value becomes empty in a single value mode while being nullable then we want to clear
970+
// the option entirely.
971+
//
972+
// This is can happen when you press backspace, but also when you select all the text and press
973+
// ctrl/cmd+x.
974+
if (api.nullable.value && api.mode.value === ValueMode.Single) {
975+
if (event.target.value === '') {
976+
clear()
977+
}
978+
}
979+
966980
// Open the combobox to show the results based on what the user has typed
967981
api.openCombobox()
968982
}

packages/@headlessui-vue/src/components/transitions/utils/transition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export function transition(
8686
// then we have some leftovers that should be cleaned.
8787
d.add(() => removeClasses(node, ...base, ...from, ...to, ...entered))
8888

89-
// When we get disposed early, than we should also call the done method but switch the reason.
89+
// When we get disposed early, then we should also call the done method but switch the reason.
9090
d.add(() => _done(Reason.Cancelled))
9191

9292
return d.dispose

packages/@headlessui-vue/src/test-utils/interactions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ let order: Record<
155155
value: element.value.slice(0, -1),
156156
}),
157157
})
158-
return fireEvent.keyDown(element, ev)
158+
159+
fireEvent.keyDown(element, ev)
160+
return fireEvent.input(element, ev)
159161
}
160162

161163
return fireEvent.keyDown(element, event)

0 commit comments

Comments
 (0)