Skip to content

Commit

Permalink
Implement nullable mode on Combobox in single value mode (#1295)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
RobinMalfait authored Mar 31, 2022
1 parent c475cab commit ab6310c
Show file tree
Hide file tree
Showing 9 changed files with 230 additions and 4 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

## [Unreleased - @headlessui/vue]

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

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,74 @@ describe('Keyboard interactions', () => {
})
})

describe('`Backspace` key', () => {
it(
'should reset the value when the last character is removed, when in `nullable` mode',
suppressConsoleLogs(async () => {
let handleChange = jest.fn()
function Example() {
let [value, setValue] = useState<string>('bob')
let [query, setQuery] = useState<string>('')

return (
<Combobox
value={value}
onChange={(value) => {
setValue(value)
handleChange(value)
}}
nullable
>
<Combobox.Input onChange={(event) => setQuery(event.target.value)} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="alice">Alice</Combobox.Option>
<Combobox.Option value="bob">Bob</Combobox.Option>
<Combobox.Option value="charlie">Charlie</Combobox.Option>
</Combobox.Options>
</Combobox>
)
}

render(<Example />)

// Open combobox
await click(getComboboxButton())

let options: ReturnType<typeof getComboboxOptions>

// Bob should be active
options = getComboboxOptions()
expect(getComboboxInput()).toHaveValue('bob')
assertActiveComboboxOption(options[1])

assertActiveElement(getComboboxInput())

// Delete a character
await press(Keys.Backspace)
expect(getComboboxInput()?.value).toBe('bo')
assertActiveComboboxOption(options[1])

// Delete a character
await press(Keys.Backspace)
expect(getComboboxInput()?.value).toBe('b')
assertActiveComboboxOption(options[1])

// Delete a character
await press(Keys.Backspace)
expect(getComboboxInput()?.value).toBe('')

// Verify that we don't have an active option anymore since we are in `nullable` mode
assertNotActiveComboboxOption(options[1])
assertNoActiveComboboxOption()

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

describe('Input', () => {
describe('`Enter` key', () => {
it(
Expand Down
39 changes: 36 additions & 3 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ interface StateDefinition {
value: unknown
mode: ValueMode
onChange(value: unknown): void
nullable: boolean
__demoMode: boolean
}>
inputPropsRef: MutableRefObject<{
Expand Down Expand Up @@ -336,27 +337,42 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
TType = string,
TActualType = TType extends (infer U)[] ? U : TType
>(
props: Props<TTag, ComboboxRenderPropArg<TType>, 'value' | 'onChange' | 'disabled' | 'name'> & {
props: Props<
TTag,
ComboboxRenderPropArg<TType>,
'value' | 'onChange' | 'disabled' | 'name' | 'nullable'
> & {
value: TType
onChange(value: TType): void
disabled?: boolean
__demoMode?: boolean
name?: string
nullable?: boolean
},
ref: Ref<TTag>
) {
let { name, value, onChange, disabled = false, __demoMode = false, ...theirProps } = props
let {
name,
value,
onChange,
disabled = false,
__demoMode = false,
nullable = false,
...theirProps
} = props
let defaultToFirstOption = useRef(false)

let comboboxPropsRef = useRef<StateDefinition['comboboxPropsRef']['current']>({
value,
mode: Array.isArray(value) ? ValueMode.Multi : ValueMode.Single,
onChange,
nullable,
__demoMode,
})

comboboxPropsRef.current.value = value
comboboxPropsRef.current.mode = Array.isArray(value) ? ValueMode.Multi : ValueMode.Single
comboboxPropsRef.current.nullable = nullable

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

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

case Keys.Backspace:
case Keys.Delete:
if (data.mode !== ValueMode.Single) return
if (!state.comboboxPropsRef.current.nullable) return

let input = event.currentTarget
d.requestAnimationFrame(() => {
if (input.value === '') {
state.comboboxPropsRef.current.onChange(null)
if (state.optionsRef.current) {
state.optionsRef.current.scrollTop = 0
}
actions.goToOption(Focus.Nothing)
}
})
break

case Keys.Enter:
if (state.comboboxState !== ComboboxStates.Open) return

Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-react/src/components/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum Keys {
Enter = 'Enter',
Escape = 'Escape',
Backspace = 'Backspace',
Delete = 'Delete',

ArrowLeft = 'ArrowLeft',
ArrowUp = 'ArrowUp',
Expand Down
17 changes: 17 additions & 0 deletions packages/@headlessui-react/src/test-utils/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,23 @@ let order: Record<
return fireEvent.keyUp(element, event)
},
],
[Keys.Backspace.key!]: [
function keydown(element, event) {
if (element instanceof HTMLInputElement) {
let ev = Object.assign({}, event, {
target: Object.assign({}, event.target, {
value: element.value.slice(0, -1),
}),
})
return fireEvent.keyDown(element, ev)
}

return fireEvent.keyDown(element, event)
},
function keyup(element, event) {
return fireEvent.keyUp(element, event)
},
],
}

export async function type(events: Partial<KeyboardEvent>[], element = document.activeElement) {
Expand Down
61 changes: 61 additions & 0 deletions packages/@headlessui-vue/src/components/combobox/combobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3469,6 +3469,67 @@ describe('Keyboard interactions', () => {
)
})

describe('`Backspace` key', () => {
it(
'should reset the value when the last character is removed, when in `nullable` mode',
suppressConsoleLogs(async () => {
let handleChange = jest.fn()
renderTemplate({
template: html`
<Combobox v-model="value">
<ComboboxInput />
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxOptions>
<ComboboxOption value="alice">Alice</ComboboxOption>
<ComboboxOption value="bob">Bob</ComboboxOption>
<ComboboxOption value="charlie">Charlie</ComboboxOption>
</ComboboxOptions>
</Combobox>
`,
setup: () => {
let value = ref('bob')
watch([value], () => handleChange(value.value))
return { value }
},
})

// Open combobox
await click(getComboboxButton())

let options: ReturnType<typeof getComboboxOptions>

// Bob should be active
options = getComboboxOptions()
expect(getComboboxInput()).toHaveValue('bob')
assertActiveComboboxOption(options[1])

assertActiveElement(getComboboxInput())

// Delete a character
await press(Keys.Backspace)
expect(getComboboxInput()?.value).toBe('bo')
assertActiveComboboxOption(options[1])

// Delete a character
await press(Keys.Backspace)
expect(getComboboxInput()?.value).toBe('b')
assertActiveComboboxOption(options[1])

// Delete a character
await press(Keys.Backspace)
expect(getComboboxInput()?.value).toBe('')

// Verify that we don't have an active option anymore since we are in `nullable` mode
assertNotActiveComboboxOption(options[1])
assertNoActiveComboboxOption()

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

describe('`Any` key aka search', () => {
let Example = defineComponent({
components: getDefaultComponents(),
Expand Down
28 changes: 27 additions & 1 deletion packages/@headlessui-vue/src/components/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type StateDefinition = {
value: ComputedRef<unknown>

mode: ComputedRef<ValueMode>
nullable: ComputedRef<boolean>

inputPropsRef: Ref<{ displayValue?: (item: unknown) => string }>
optionsPropsRef: Ref<{ static: boolean; hold: boolean }>
Expand All @@ -79,6 +80,7 @@ type StateDefinition = {
closeCombobox(): void
openCombobox(): void
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void
change(value: unknown): void
selectOption(id: string): void
selectActiveOption(): void
registerOption(id: string, dataRef: ComputedRef<ComboboxOptionData>): void
Expand Down Expand Up @@ -110,6 +112,7 @@ export let Combobox = defineComponent({
disabled: { type: [Boolean], default: false },
modelValue: { type: [Object, String, Number, Boolean] },
name: { type: String },
nullable: { type: Boolean, default: false },
},
setup(props, { slots, attrs, emit }) {
let comboboxState = ref<StateDefinition['comboboxState']['value']>(ComboboxStates.Closed)
Expand Down Expand Up @@ -161,17 +164,22 @@ export let Combobox = defineComponent({

let value = computed(() => props.modelValue)
let mode = computed(() => (Array.isArray(value.value) ? ValueMode.Multi : ValueMode.Single))
let nullable = computed(() => props.nullable)

let api = {
comboboxState,
value,
mode,
nullable,
inputRef,
labelRef,
buttonRef,
optionsRef,
disabled: computed(() => props.disabled),
options,
change(value: unknown) {
emit('update:modelValue', value)
},
activeOptionIndex: computed(() => {
if (
defaultToFirstOption.value &&
Expand Down Expand Up @@ -436,7 +444,7 @@ export let Combobox = defineComponent({
)
: []),
render({
props: omit(incomingProps, ['onUpdate:modelValue']),
props: omit(incomingProps, ['nullable', 'onUpdate:modelValue']),
slot,
slots,
attrs,
Expand Down Expand Up @@ -615,6 +623,24 @@ export let ComboboxInput = defineComponent({
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12

case Keys.Backspace:
case Keys.Delete:
if (api.mode.value !== ValueMode.Single) return
if (!api.nullable) 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:
if (api.comboboxState.value !== ComboboxStates.Open) return

Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/src/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum Keys {
Enter = 'Enter',
Escape = 'Escape',
Backspace = 'Backspace',
Delete = 'Delete',

ArrowLeft = 'ArrowLeft',
ArrowUp = 'ArrowUp',
Expand Down
17 changes: 17 additions & 0 deletions packages/@headlessui-vue/src/test-utils/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,23 @@ let order: Record<
return fireEvent.keyUp(element, event)
},
],
[Keys.Backspace.key!]: [
function keydown(element, event) {
if (element instanceof HTMLInputElement) {
let ev = Object.assign({}, event, {
target: Object.assign({}, event.target, {
value: element.value.slice(0, -1),
}),
})
return fireEvent.keyDown(element, ev)
}

return fireEvent.keyDown(element, event)
},
function keyup(element, event) {
return fireEvent.keyUp(element, event)
},
],
}

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

0 comments on commit ab6310c

Please sign in to comment.