Skip to content

Commit ab6310c

Browse files
authored
Implement nullable mode on Combobox in single value mode (#1295)
* implement `backspace` behaviour in tests * add `Delete` Key * implement `nullable` mode on Combobox in single value mode If you pass a `nullable` prop to the Combobox, then it's possible to unset the Combobox value by setting it to `null`. This is triggered by removing all text from the input which will reset the value itself as well. * update changelog
1 parent c475cab commit ab6310c

File tree

9 files changed

+230
-4
lines changed

9 files changed

+230
-4
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4040

4141
- Add `<form>` compatibility ([#1214](https://github.com/tailwindlabs/headlessui/pull/1214))
4242
- Add `multi` value support for Listbox & Combobox ([#1243](https://github.com/tailwindlabs/headlessui/pull/1243))
43+
- Implement `nullable` mode on `Combobox` in single value mode ([#1295](https://github.com/tailwindlabs/headlessui/pull/1295))
4344

4445
## [Unreleased - @headlessui/vue]
4546

@@ -77,6 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7778

7879
- Add `<form>` compatibility ([#1214](https://github.com/tailwindlabs/headlessui/pull/1214))
7980
- Add `multi` value support for Listbox & Combobox ([#1243](https://github.com/tailwindlabs/headlessui/pull/1243))
81+
- Implement `nullable` mode on `Combobox` in single value mode ([#1295](https://github.com/tailwindlabs/headlessui/pull/1295))
8082

8183
## [@headlessui/react@v1.5.0] - 2022-02-17
8284

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,74 @@ describe('Keyboard interactions', () => {
17241724
})
17251725
})
17261726

1727+
describe('`Backspace` key', () => {
1728+
it(
1729+
'should reset the value when the last character is removed, when in `nullable` mode',
1730+
suppressConsoleLogs(async () => {
1731+
let handleChange = jest.fn()
1732+
function Example() {
1733+
let [value, setValue] = useState<string>('bob')
1734+
let [query, setQuery] = useState<string>('')
1735+
1736+
return (
1737+
<Combobox
1738+
value={value}
1739+
onChange={(value) => {
1740+
setValue(value)
1741+
handleChange(value)
1742+
}}
1743+
nullable
1744+
>
1745+
<Combobox.Input onChange={(event) => setQuery(event.target.value)} />
1746+
<Combobox.Button>Trigger</Combobox.Button>
1747+
<Combobox.Options>
1748+
<Combobox.Option value="alice">Alice</Combobox.Option>
1749+
<Combobox.Option value="bob">Bob</Combobox.Option>
1750+
<Combobox.Option value="charlie">Charlie</Combobox.Option>
1751+
</Combobox.Options>
1752+
</Combobox>
1753+
)
1754+
}
1755+
1756+
render(<Example />)
1757+
1758+
// Open combobox
1759+
await click(getComboboxButton())
1760+
1761+
let options: ReturnType<typeof getComboboxOptions>
1762+
1763+
// Bob should be active
1764+
options = getComboboxOptions()
1765+
expect(getComboboxInput()).toHaveValue('bob')
1766+
assertActiveComboboxOption(options[1])
1767+
1768+
assertActiveElement(getComboboxInput())
1769+
1770+
// Delete a character
1771+
await press(Keys.Backspace)
1772+
expect(getComboboxInput()?.value).toBe('bo')
1773+
assertActiveComboboxOption(options[1])
1774+
1775+
// Delete a character
1776+
await press(Keys.Backspace)
1777+
expect(getComboboxInput()?.value).toBe('b')
1778+
assertActiveComboboxOption(options[1])
1779+
1780+
// Delete a character
1781+
await press(Keys.Backspace)
1782+
expect(getComboboxInput()?.value).toBe('')
1783+
1784+
// Verify that we don't have an active option anymore since we are in `nullable` mode
1785+
assertNotActiveComboboxOption(options[1])
1786+
assertNoActiveComboboxOption()
1787+
1788+
// Verify that we saw the `null` change coming in
1789+
expect(handleChange).toHaveBeenCalledTimes(1)
1790+
expect(handleChange).toHaveBeenCalledWith(null)
1791+
})
1792+
)
1793+
})
1794+
17271795
describe('Input', () => {
17281796
describe('`Enter` key', () => {
17291797
it(

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ interface StateDefinition {
6868
value: unknown
6969
mode: ValueMode
7070
onChange(value: unknown): void
71+
nullable: boolean
7172
__demoMode: boolean
7273
}>
7374
inputPropsRef: MutableRefObject<{
@@ -336,27 +337,42 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
336337
TType = string,
337338
TActualType = TType extends (infer U)[] ? U : TType
338339
>(
339-
props: Props<TTag, ComboboxRenderPropArg<TType>, 'value' | 'onChange' | 'disabled' | 'name'> & {
340+
props: Props<
341+
TTag,
342+
ComboboxRenderPropArg<TType>,
343+
'value' | 'onChange' | 'disabled' | 'name' | 'nullable'
344+
> & {
340345
value: TType
341346
onChange(value: TType): void
342347
disabled?: boolean
343348
__demoMode?: boolean
344349
name?: string
350+
nullable?: boolean
345351
},
346352
ref: Ref<TTag>
347353
) {
348-
let { name, value, onChange, disabled = false, __demoMode = false, ...theirProps } = props
354+
let {
355+
name,
356+
value,
357+
onChange,
358+
disabled = false,
359+
__demoMode = false,
360+
nullable = false,
361+
...theirProps
362+
} = props
349363
let defaultToFirstOption = useRef(false)
350364

351365
let comboboxPropsRef = useRef<StateDefinition['comboboxPropsRef']['current']>({
352366
value,
353367
mode: Array.isArray(value) ? ValueMode.Multi : ValueMode.Single,
354368
onChange,
369+
nullable,
355370
__demoMode,
356371
})
357372

358373
comboboxPropsRef.current.value = value
359374
comboboxPropsRef.current.mode = Array.isArray(value) ? ValueMode.Multi : ValueMode.Single
375+
comboboxPropsRef.current.nullable = nullable
360376

361377
let optionsPropsRef = useRef<StateDefinition['optionsPropsRef']['current']>({
362378
static: false,
@@ -621,10 +637,27 @@ let Input = forwardRefWithAs(function Input<
621637
}, [displayValue, inputPropsRef])
622638

623639
let handleKeyDown = useCallback(
624-
(event: ReactKeyboardEvent<HTMLUListElement>) => {
640+
(event: ReactKeyboardEvent<HTMLInputElement>) => {
625641
switch (event.key) {
626642
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
627643

644+
case Keys.Backspace:
645+
case Keys.Delete:
646+
if (data.mode !== ValueMode.Single) return
647+
if (!state.comboboxPropsRef.current.nullable) return
648+
649+
let input = event.currentTarget
650+
d.requestAnimationFrame(() => {
651+
if (input.value === '') {
652+
state.comboboxPropsRef.current.onChange(null)
653+
if (state.optionsRef.current) {
654+
state.optionsRef.current.scrollTop = 0
655+
}
656+
actions.goToOption(Focus.Nothing)
657+
}
658+
})
659+
break
660+
628661
case Keys.Enter:
629662
if (state.comboboxState !== ComboboxStates.Open) return
630663

packages/@headlessui-react/src/components/keyboard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export enum Keys {
55
Enter = 'Enter',
66
Escape = 'Escape',
77
Backspace = 'Backspace',
8+
Delete = 'Delete',
89

910
ArrowLeft = 'ArrowLeft',
1011
ArrowUp = 'ArrowUp',

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,23 @@ let order: Record<
151151
return fireEvent.keyUp(element, event)
152152
},
153153
],
154+
[Keys.Backspace.key!]: [
155+
function keydown(element, event) {
156+
if (element instanceof HTMLInputElement) {
157+
let ev = Object.assign({}, event, {
158+
target: Object.assign({}, event.target, {
159+
value: element.value.slice(0, -1),
160+
}),
161+
})
162+
return fireEvent.keyDown(element, ev)
163+
}
164+
165+
return fireEvent.keyDown(element, event)
166+
},
167+
function keyup(element, event) {
168+
return fireEvent.keyUp(element, event)
169+
},
170+
],
154171
}
155172

156173
export async function type(events: Partial<KeyboardEvent>[], element = document.activeElement) {

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3469,6 +3469,67 @@ describe('Keyboard interactions', () => {
34693469
)
34703470
})
34713471

3472+
describe('`Backspace` key', () => {
3473+
it(
3474+
'should reset the value when the last character is removed, when in `nullable` mode',
3475+
suppressConsoleLogs(async () => {
3476+
let handleChange = jest.fn()
3477+
renderTemplate({
3478+
template: html`
3479+
<Combobox v-model="value">
3480+
<ComboboxInput />
3481+
<ComboboxButton>Trigger</ComboboxButton>
3482+
<ComboboxOptions>
3483+
<ComboboxOption value="alice">Alice</ComboboxOption>
3484+
<ComboboxOption value="bob">Bob</ComboboxOption>
3485+
<ComboboxOption value="charlie">Charlie</ComboboxOption>
3486+
</ComboboxOptions>
3487+
</Combobox>
3488+
`,
3489+
setup: () => {
3490+
let value = ref('bob')
3491+
watch([value], () => handleChange(value.value))
3492+
return { value }
3493+
},
3494+
})
3495+
3496+
// Open combobox
3497+
await click(getComboboxButton())
3498+
3499+
let options: ReturnType<typeof getComboboxOptions>
3500+
3501+
// Bob should be active
3502+
options = getComboboxOptions()
3503+
expect(getComboboxInput()).toHaveValue('bob')
3504+
assertActiveComboboxOption(options[1])
3505+
3506+
assertActiveElement(getComboboxInput())
3507+
3508+
// Delete a character
3509+
await press(Keys.Backspace)
3510+
expect(getComboboxInput()?.value).toBe('bo')
3511+
assertActiveComboboxOption(options[1])
3512+
3513+
// Delete a character
3514+
await press(Keys.Backspace)
3515+
expect(getComboboxInput()?.value).toBe('b')
3516+
assertActiveComboboxOption(options[1])
3517+
3518+
// Delete a character
3519+
await press(Keys.Backspace)
3520+
expect(getComboboxInput()?.value).toBe('')
3521+
3522+
// Verify that we don't have an active option anymore since we are in `nullable` mode
3523+
assertNotActiveComboboxOption(options[1])
3524+
assertNoActiveComboboxOption()
3525+
3526+
// Verify that we saw the `null` change coming in
3527+
expect(handleChange).toHaveBeenCalledTimes(1)
3528+
expect(handleChange).toHaveBeenCalledWith(null)
3529+
})
3530+
)
3531+
})
3532+
34723533
describe('`Any` key aka search', () => {
34733534
let Example = defineComponent({
34743535
components: getDefaultComponents(),

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type StateDefinition = {
6161
value: ComputedRef<unknown>
6262

6363
mode: ComputedRef<ValueMode>
64+
nullable: ComputedRef<boolean>
6465

6566
inputPropsRef: Ref<{ displayValue?: (item: unknown) => string }>
6667
optionsPropsRef: Ref<{ static: boolean; hold: boolean }>
@@ -79,6 +80,7 @@ type StateDefinition = {
7980
closeCombobox(): void
8081
openCombobox(): void
8182
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void
83+
change(value: unknown): void
8284
selectOption(id: string): void
8385
selectActiveOption(): void
8486
registerOption(id: string, dataRef: ComputedRef<ComboboxOptionData>): void
@@ -110,6 +112,7 @@ export let Combobox = defineComponent({
110112
disabled: { type: [Boolean], default: false },
111113
modelValue: { type: [Object, String, Number, Boolean] },
112114
name: { type: String },
115+
nullable: { type: Boolean, default: false },
113116
},
114117
setup(props, { slots, attrs, emit }) {
115118
let comboboxState = ref<StateDefinition['comboboxState']['value']>(ComboboxStates.Closed)
@@ -161,17 +164,22 @@ export let Combobox = defineComponent({
161164

162165
let value = computed(() => props.modelValue)
163166
let mode = computed(() => (Array.isArray(value.value) ? ValueMode.Multi : ValueMode.Single))
167+
let nullable = computed(() => props.nullable)
164168

165169
let api = {
166170
comboboxState,
167171
value,
168172
mode,
173+
nullable,
169174
inputRef,
170175
labelRef,
171176
buttonRef,
172177
optionsRef,
173178
disabled: computed(() => props.disabled),
174179
options,
180+
change(value: unknown) {
181+
emit('update:modelValue', value)
182+
},
175183
activeOptionIndex: computed(() => {
176184
if (
177185
defaultToFirstOption.value &&
@@ -436,7 +444,7 @@ export let Combobox = defineComponent({
436444
)
437445
: []),
438446
render({
439-
props: omit(incomingProps, ['onUpdate:modelValue']),
447+
props: omit(incomingProps, ['nullable', 'onUpdate:modelValue']),
440448
slot,
441449
slots,
442450
attrs,
@@ -615,6 +623,24 @@ export let ComboboxInput = defineComponent({
615623
switch (event.key) {
616624
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
617625

626+
case Keys.Backspace:
627+
case Keys.Delete:
628+
if (api.mode.value !== ValueMode.Single) return
629+
if (!api.nullable) return
630+
631+
let input = event.currentTarget as HTMLInputElement
632+
requestAnimationFrame(() => {
633+
if (input.value === '') {
634+
api.change(null)
635+
let options = dom(api.optionsRef)
636+
if (options) {
637+
options.scrollTop = 0
638+
}
639+
api.goToOption(Focus.Nothing)
640+
}
641+
})
642+
break
643+
618644
case Keys.Enter:
619645
if (api.comboboxState.value !== ComboboxStates.Open) return
620646

packages/@headlessui-vue/src/keyboard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export enum Keys {
55
Enter = 'Enter',
66
Escape = 'Escape',
77
Backspace = 'Backspace',
8+
Delete = 'Delete',
89

910
ArrowLeft = 'ArrowLeft',
1011
ArrowUp = 'ArrowUp',

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,23 @@ let order: Record<
151151
return fireEvent.keyUp(element, event)
152152
},
153153
],
154+
[Keys.Backspace.key!]: [
155+
function keydown(element, event) {
156+
if (element instanceof HTMLInputElement) {
157+
let ev = Object.assign({}, event, {
158+
target: Object.assign({}, event.target, {
159+
value: element.value.slice(0, -1),
160+
}),
161+
})
162+
return fireEvent.keyDown(element, ev)
163+
}
164+
165+
return fireEvent.keyDown(element, event)
166+
},
167+
function keyup(element, event) {
168+
return fireEvent.keyUp(element, event)
169+
},
170+
],
154171
}
155172

156173
export async function type(events: Partial<KeyboardEvent>[], element = document.activeElement) {

0 commit comments

Comments
 (0)