diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts
index 5454fb89a5..0430539431 100644
--- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts
+++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts
@@ -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`
+
+ `,
+ 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`
+
+ `,
+ 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`
+
+
+ Trigger
+
+ Alice
+ Bob
+ Charlie
+
+
+ `,
+ 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', () => {
diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts
index 09e76832c6..c314429070 100644
--- a/packages/@headlessui-vue/src/components/combobox/combobox.ts
+++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts
@@ -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(a: T, z: T): boolean {
return a === z
@@ -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 },
@@ -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,
@@ -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 (
@@ -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]: () => {
@@ -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]: () => {
@@ -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,
@@ -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,
@@ -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,
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
index 71d0ee77eb..5db764f218 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
@@ -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`
+
+ `,
+ 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`
+
+ `,
+ 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`
+
+ Trigger
+
+ Alice
+ Bob
+ Charlie
+
+
+ `,
+ 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', () => {
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts
index 4c08c1075e..f3dc397699 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.ts
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts
@@ -32,6 +32,7 @@ import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/fo
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(a: T, z: T): boolean {
return a === z
@@ -118,7 +119,8 @@ export let Listbox = defineComponent({
disabled: { type: [Boolean], default: false },
by: { type: [String, Function], default: () => defaultComparator },
horizontal: { type: [Boolean], default: false },
- 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, optional: true },
multiple: { type: [Boolean], default: false },
},
@@ -164,8 +166,12 @@ export let Listbox = defineComponent({
}
}
- let value = computed(() => props.modelValue)
let mode = computed(() => (props.multiple ? ValueMode.Multi : ValueMode.Single))
+ let [value, theirOnChange] = useControllable(
+ computed(() => props.modelValue),
+ (value: unknown) => emit('update:modelValue', value),
+ computed(() => props.defaultValue)
+ )
let api = {
listboxState,
@@ -275,8 +281,7 @@ export let Listbox = defineComponent({
},
select(value: unknown) {
if (props.disabled) return
- emit(
- 'update:modelValue',
+ theirOnChange(
match(mode.value, {
[ValueMode.Single]: () => value,
[ValueMode.Multi]: () => {
@@ -328,8 +333,8 @@ export let Listbox = defineComponent({
let slot = { open: listboxState.value === ListboxStates.Open, disabled }
return h(Fragment, [
- ...(name != null && modelValue != null
- ? objectToFormEntries({ [name]: modelValue }).map(([name, value]) =>
+ ...(name != null && value.value != null
+ ? objectToFormEntries({ [name]: value.value }).map(([name, value]) =>
h(
Hidden,
compact({
@@ -349,7 +354,13 @@ export let Listbox = defineComponent({
ourProps: {},
theirProps: {
...attrs,
- ...omit(theirProps, ['onUpdate:modelValue', 'horizontal', 'multiple', 'by']),
+ ...omit(theirProps, [
+ 'defaultValue',
+ 'onUpdate:modelValue',
+ 'horizontal',
+ 'multiple',
+ 'by',
+ ]),
},
slot,
slots,
diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts
index 2b5ca7d5b6..1aed26d7df 100644
--- a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts
+++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts
@@ -590,6 +590,125 @@ describe('Rendering', () => {
})
)
})
+
+ describe('Uncontrolled', () => {
+ it(
+ 'should be possible to use in an uncontrolled way',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ 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({})
+
+ // Choose alice
+ await click(getRadioGroupOptions()[0])
+
+ // Submit
+ await click(document.getElementById('submit'))
+
+ // Alice should be submitted
+ expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
+
+ // Choose charlie
+ await click(getRadioGroupOptions()[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',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ 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' })
+
+ // Choose alice
+ await click(getRadioGroupOptions()[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',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ Alice
+ Bob
+ Charlie
+
+ `,
+ setup: () => ({ handleChange }),
+ })
+
+ // Choose alice
+ await click(getRadioGroupOptions()[0])
+
+ // Choose bob
+ await click(getRadioGroupOptions()[1])
+
+ // Change handler should have been called twice
+ expect(handleChange).toHaveBeenNthCalledWith(1, 'alice')
+ expect(handleChange).toHaveBeenNthCalledWith(2, 'bob')
+ })
+ )
+ })
})
describe('Keyboard interactions', () => {
diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts
index c6c05df969..ec0b1f1a8f 100644
--- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts
+++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts
@@ -26,6 +26,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit, objectToFormEntries } from '../../utils/form'
import { getOwnerDocument } from '../../utils/owner'
+import { useControllable } from '../../hooks/use-controllable'
function defaultComparator(a: T, z: T): boolean {
return a === z
@@ -76,7 +77,8 @@ export let RadioGroup = defineComponent({
as: { type: [Object, String], default: 'div' },
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, optional: true },
},
inheritAttrs: false,
@@ -88,7 +90,11 @@ export let RadioGroup = defineComponent({
expose({ el: radioGroupRef, $el: radioGroupRef })
- let value = computed(() => props.modelValue)
+ let [value, theirOnChange] = useControllable(
+ computed(() => props.modelValue),
+ (value: unknown) => emit('update:modelValue', value),
+ computed(() => props.defaultValue)
+ )
// TODO: Fix type
let api: any = {
@@ -120,7 +126,7 @@ export let RadioGroup = defineComponent({
api.compare(toRaw(option.propsRef.value), toRaw(nextValue))
)?.propsRef
if (nextOption?.disabled) return false
- emit('update:modelValue', nextValue)
+ theirOnChange(nextValue)
return true
},
registerOption(action: UnwrapRef