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` +
+ + + Trigger + + Alice + Bob + Charlie + + + +
+ `, + 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` +
+ + + Trigger + + Alice + Bob + Charlie + + + +
+ `, + 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` +
+ + Trigger + + Alice + Bob + Charlie + + + +
+ `, + 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` +
+ + Trigger + + Alice + Bob + Charlie + + + +
+ `, + 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` +
+ + Alice + Bob + Charlie + + +
+ `, + 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` +
+ + Alice + Bob + Charlie + + +
+ `, + 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