Skip to content

Commit

Permalink
implement uncontrolled form elements in Vue
Browse files Browse the repository at this point in the history
  • Loading branch information
RobinMalfait committed Aug 1, 2022
1 parent 0fd0493 commit de8474f
Show file tree
Hide file tree
Showing 9 changed files with 628 additions and 36 deletions.
139 changes: 139 additions & 0 deletions packages/@headlessui-vue/src/components/combobox/combobox.test.ts
Expand Up @@ -982,6 +982,145 @@ describe('Rendering', () => {
// Verify that the third combobox option is active
assertActiveComboboxOption(options[2])
})

describe('Uncontrolled', () => {
it('should be possible to use in an uncontrolled way', async () => {
let handleSubmission = jest.fn()

renderTemplate({
template: html`
<form @submit="handleSubmit">
<Combobox name="assignee">
<ComboboxInput />
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxOptions>
<ComboboxOption value="alice">Alice</ComboboxOption>
<ComboboxOption value="bob">Bob</ComboboxOption>
<ComboboxOption value="charlie">Charlie</ComboboxOption>
</ComboboxOptions>
</Combobox>
<button id="submit">submit</button>
</form>
`,
setup: () => ({
handleSubmit(e: SubmitEvent) {
e.preventDefault()
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
},
}),
})

await click(document.getElementById('submit'))

// No values
expect(handleSubmission).toHaveBeenLastCalledWith({})

// Open combobox
await click(getComboboxButton())

// Choose alice
await click(getComboboxOptions()[0])

// Submit
await click(document.getElementById('submit'))

// Alice should be submitted
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })

// Open combobox
await click(getComboboxButton())

// Choose charlie
await click(getComboboxOptions()[2])

// Submit
await click(document.getElementById('submit'))

// Charlie should be submitted
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' })
})

it('should be possible to provide a default value', async () => {
let handleSubmission = jest.fn()

renderTemplate({
template: html`
<form @submit="handleSubmit">
<Combobox name="assignee" defaultValue="bob">
<ComboboxInput />
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxOptions>
<ComboboxOption value="alice">Alice</ComboboxOption>
<ComboboxOption value="bob">Bob</ComboboxOption>
<ComboboxOption value="charlie">Charlie</ComboboxOption>
</ComboboxOptions>
</Combobox>
<button id="submit">submit</button>
</form>
`,
setup: () => ({
handleSubmit(e: SubmitEvent) {
e.preventDefault()
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
},
}),
})

await click(document.getElementById('submit'))

// Bob is the defaultValue
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })

// Open combobox
await click(getComboboxButton())

// Choose alice
await click(getComboboxOptions()[0])

// Submit
await click(document.getElementById('submit'))

// Alice should be submitted
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
})

it('should still call the onChange listeners when choosing new values', async () => {
let handleChange = jest.fn()

renderTemplate({
template: html`
<Combobox name="assignee" @update:modelValue="handleChange">
<ComboboxInput />
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxOptions>
<ComboboxOption value="alice">Alice</ComboboxOption>
<ComboboxOption value="bob">Bob</ComboboxOption>
<ComboboxOption value="charlie">Charlie</ComboboxOption>
</ComboboxOptions>
</Combobox>
`,
setup: () => ({
handleChange,
}),
})

// Open combobox
await click(getComboboxButton())

// Choose alice
await click(getComboboxOptions()[0])

// Open combobox
await click(getComboboxButton())

// Choose bob
await click(getComboboxOptions()[1])

// Change handler should have been called twice
expect(handleChange).toHaveBeenNthCalledWith(1, 'alice')
expect(handleChange).toHaveBeenNthCalledWith(2, 'bob')
})
})
})

describe('Rendering composition', () => {
Expand Down
37 changes: 24 additions & 13 deletions packages/@headlessui-vue/src/components/combobox/combobox.ts
Expand Up @@ -34,6 +34,7 @@ import { sortByDomNode } from '../../utils/focus-management'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { objectToFormEntries } from '../../utils/form'
import { useControllable } from '../../hooks/use-controllable'

function defaultComparator<T>(a: T, z: T): boolean {
return a === z
Expand Down Expand Up @@ -117,7 +118,8 @@ export let Combobox = defineComponent({
as: { type: [Object, String], default: 'template' },
disabled: { type: [Boolean], default: false },
by: { type: [String, Function], default: () => defaultComparator },
modelValue: { type: [Object, String, Number, Boolean] },
modelValue: { type: [Object, String, Number, Boolean], default: undefined },
defaultValue: { type: [Object, String, Number, Boolean], default: undefined },
name: { type: String },
nullable: { type: Boolean, default: false },
multiple: { type: [Boolean], default: false },
Expand Down Expand Up @@ -171,9 +173,13 @@ export let Combobox = defineComponent({
}
}

let value = computed(() => props.modelValue)
let mode = computed(() => (props.multiple ? ValueMode.Multi : ValueMode.Single))
let nullable = computed(() => props.nullable)
let [value, theirOnChange] = useControllable(
computed(() => props.modelValue),
(value: unknown) => emit('update:modelValue', value),
computed(() => props.defaultValue)
)

let api = {
comboboxState,
Expand All @@ -194,7 +200,7 @@ export let Combobox = defineComponent({
disabled: computed(() => props.disabled),
options,
change(value: unknown) {
emit('update:modelValue', value)
theirOnChange(value as typeof props.modelValue)
},
activeOptionIndex: computed(() => {
if (
Expand Down Expand Up @@ -308,8 +314,7 @@ export let Combobox = defineComponent({
if (!option) return

let { dataRef } = option
emit(
'update:modelValue',
theirOnChange(
match(mode.value, {
[ValueMode.Single]: () => dataRef.value,
[ValueMode.Multi]: () => {
Expand All @@ -333,8 +338,7 @@ export let Combobox = defineComponent({
if (api.activeOptionIndex.value === null) return

let { dataRef, id } = options.value[api.activeOptionIndex.value]
emit(
'update:modelValue',
theirOnChange(
match(mode.value, {
[ValueMode.Single]: () => dataRef.value,
[ValueMode.Multi]: () => {
Expand Down Expand Up @@ -440,7 +444,7 @@ export let Combobox = defineComponent({
)

return () => {
let { name, modelValue, disabled, ...theirProps } = props
let { name, disabled, ...theirProps } = props
let slot = {
open: comboboxState.value === ComboboxStates.Open,
disabled,
Expand All @@ -449,9 +453,9 @@ export let Combobox = defineComponent({
}

return h(Fragment, [
...(name != null && modelValue != null
? objectToFormEntries({ [name]: modelValue }).map(([name, value]) =>
h(
...(name != null && value.value != null
? objectToFormEntries({ [name]: value.value }).map(([name, value]) => {
return h(
Hidden,
compact({
features: HiddenFeatures.Hidden,
Expand All @@ -464,12 +468,19 @@ export let Combobox = defineComponent({
value,
})
)
)
})
: []),
render({
theirProps: {
...attrs,
...omit(theirProps, ['nullable', 'multiple', 'onUpdate:modelValue', 'by']),
...omit(theirProps, [
'modelValue',
'defaultValue',
'nullable',
'multiple',
'onUpdate:modelValue',
'by',
]),
},
ourProps: {},
slot,
Expand Down
134 changes: 134 additions & 0 deletions packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
Expand Up @@ -806,6 +806,140 @@ describe('Rendering', () => {
// Verify that the third listbox option is active
assertActiveListboxOption(options[2])
})

describe('Uncontrolled', () => {
it('should be possible to use in an uncontrolled way', async () => {
let handleSubmission = jest.fn()

renderTemplate({
template: html`
<form @submit="handleSubmit">
<Listbox name="assignee">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="alice">Alice</ListboxOption>
<ListboxOption value="bob">Bob</ListboxOption>
<ListboxOption value="charlie">Charlie</ListboxOption>
</ListboxOptions>
</Listbox>
<button id="submit">submit</button>
</form>
`,
setup: () => ({
handleSubmit(e: SubmitEvent) {
e.preventDefault()
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
},
}),
})

await click(document.getElementById('submit'))

// No values
expect(handleSubmission).toHaveBeenLastCalledWith({})

// Open listbox
await click(getListboxButton())

// Choose alice
await click(getListboxOptions()[0])

// Submit
await click(document.getElementById('submit'))

// Alice should be submitted
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })

// Open listbox
await click(getListboxButton())

// Choose charlie
await click(getListboxOptions()[2])

// Submit
await click(document.getElementById('submit'))

// Charlie should be submitted
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' })
})

it('should be possible to provide a default value', async () => {
let handleSubmission = jest.fn()

renderTemplate({
template: html`
<form @submit="handleSubmit">
<Listbox name="assignee" defaultValue="bob">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="alice">Alice</ListboxOption>
<ListboxOption value="bob">Bob</ListboxOption>
<ListboxOption value="charlie">Charlie</ListboxOption>
</ListboxOptions>
</Listbox>
<button id="submit">submit</button>
</form>
`,
setup: () => ({
handleSubmit(e: SubmitEvent) {
e.preventDefault()
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
},
}),
})

await click(document.getElementById('submit'))

// Bob is the defaultValue
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })

// Open listbox
await click(getListboxButton())

// Choose alice
await click(getListboxOptions()[0])

// Submit
await click(document.getElementById('submit'))

// Alice should be submitted
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
})

it('should still call the onChange listeners when choosing new values', async () => {
let handleChange = jest.fn()

renderTemplate({
template: html`
<Listbox name="assignee" @update:modelValue="handleChange">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="alice">Alice</ListboxOption>
<ListboxOption value="bob">Bob</ListboxOption>
<ListboxOption value="charlie">Charlie</ListboxOption>
</ListboxOptions>
</Listbox>
`,
setup: () => ({ handleChange }),
})

// Open listbox
await click(getListboxButton())

// Choose alice
await click(getListboxOptions()[0])

// Open listbox
await click(getListboxButton())

// Choose bob
await click(getListboxOptions()[1])

// Change handler should have been called twice
expect(handleChange).toHaveBeenNthCalledWith(1, 'alice')
expect(handleChange).toHaveBeenNthCalledWith(2, 'bob')
})
})
})

describe('Rendering composition', () => {
Expand Down

0 comments on commit de8474f

Please sign in to comment.