diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
index 4dd5dd594..5ef29f926 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
@@ -901,6 +901,134 @@ 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()
+
+ render(
+
+ )
+
+ 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()
+
+ render(
+
+ )
+
+ 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()
+
+ render(
+
+
+ Trigger
+
+ Alice
+ Bob
+ Charlie
+
+
+ )
+
+ // 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-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index c7e0c73b2..957ea5a0a 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -40,6 +40,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { Keys } from '../keyboard'
+import { useControllable } from '../../hooks/use-controllable'
enum ComboboxState {
Open,
@@ -301,6 +302,7 @@ interface ComboboxRenderPropArg {
disabled: boolean
activeIndex: number | null
activeOption: T | null
+ value: T
}
let ComboboxRoot = forwardRefWithAs(function Combobox<
@@ -311,10 +313,11 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
props: Props<
TTag,
ComboboxRenderPropArg,
- 'value' | 'onChange' | 'disabled' | 'name' | 'nullable' | 'multiple' | 'by'
+ 'value' | 'defaultValue' | 'onChange' | 'by' | 'disabled' | 'name' | 'nullable' | 'multiple'
> & {
- value: TType
- onChange(value: TType): void
+ value?: TType
+ defaultValue?: TType
+ onChange?(value: TType): void
by?: (keyof TActualType & string) | ((a: TActualType, z: TActualType) => boolean)
disabled?: boolean
__demoMode?: boolean
@@ -325,9 +328,10 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
ref: Ref
) {
let {
+ value: controlledValue,
+ defaultValue,
+ onChange: controlledOnChange,
name,
- value,
- onChange: theirOnChange,
by = (a, z) => a === z,
disabled = false,
__demoMode = false,
@@ -335,6 +339,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
multiple = false,
...theirProps
} = props
+ let [value, theirOnChange] = useControllable(controlledValue, controlledOnChange, defaultValue)
let [state, dispatch] = useReducer(stateReducer, {
dataRef: createRef(),
@@ -430,8 +435,9 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
data.activeOptionIndex === null
? null
: (data.options[data.activeOptionIndex].dataRef.current.value as TType),
+ value,
}),
- [data, disabled]
+ [data, disabled, value]
)
let syncInputValue = useCallback(() => {
@@ -495,7 +501,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
let onChange = useEvent((value: unknown) => {
return match(data.mode, {
[ValueMode.Single]() {
- return theirOnChange(value as TType)
+ return theirOnChange?.(value as TType)
},
[ValueMode.Multi]() {
let copy = (data.value as TActualType[]).slice()
@@ -507,7 +513,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
copy.splice(idx, 1)
}
- return theirOnChange(copy as unknown as TType)
+ return theirOnChange?.(copy as unknown as TType)
},
})
})
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index df432e1d6..d12469e22 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -719,6 +719,131 @@ describe('Rendering', () => {
// Verify that the third menu item is active
assertActiveListboxOption(options[2])
})
+
+ describe('Uncontrolled', () => {
+ it('should be possible to use in an uncontrolled way', async () => {
+ let handleSubmission = jest.fn()
+
+ render(
+
+ )
+
+ 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()
+
+ render(
+
+ )
+
+ 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()
+
+ render(
+
+ Trigger
+
+ Alice
+ Bob
+ Charlie
+
+
+ )
+
+ // 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-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 974cb38e4..cb0b38ea3 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -37,6 +37,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { objectToFormEntries } from '../../utils/form'
import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
+import { useControllable } from '../../hooks/use-controllable'
enum ListboxStates {
Open,
@@ -298,9 +299,10 @@ function stateReducer(state: StateDefinition, action: Actions) {
// ---
let DEFAULT_LISTBOX_TAG = Fragment
-interface ListboxRenderPropArg {
+interface ListboxRenderPropArg {
open: boolean
disabled: boolean
+ value: TType
}
let ListboxRoot = forwardRefWithAs(function Listbox<
@@ -310,11 +312,12 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
>(
props: Props<
TTag,
- ListboxRenderPropArg,
- 'value' | 'onChange' | 'disabled' | 'horizontal' | 'name' | 'multiple' | 'by'
+ ListboxRenderPropArg,
+ 'value' | 'defaultValue' | 'onChange' | 'by' | 'disabled' | 'horizontal' | 'name' | 'multiple'
> & {
- value: TType
- onChange(value: TType): void
+ value?: TType
+ defaultValue?: TType
+ onChange?(value: TType): void
by?: (keyof TActualType & string) | ((a: TActualType, z: TActualType) => boolean)
disabled?: boolean
horizontal?: boolean
@@ -324,9 +327,10 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
ref: Ref
) {
let {
- value,
+ value: controlledValue,
+ defaultValue,
name,
- onChange,
+ onChange: controlledOnChange,
by = (a, z) => a === z,
disabled = false,
horizontal = false,
@@ -336,6 +340,8 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
const orientation = horizontal ? 'horizontal' : 'vertical'
let listboxRef = useSyncRefs(ref)
+ let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue)
+
let reducerBag = useReducer(stateReducer, {
listboxState: ListboxStates.Closed,
propsRef: {
@@ -412,9 +418,9 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
listboxState === ListboxStates.Open
)
- let slot = useMemo(
- () => ({ open: listboxState === ListboxStates.Open, disabled }),
- [listboxState, disabled]
+ let slot = useMemo>(
+ () => ({ open: listboxState === ListboxStates.Open, disabled, value }),
+ [listboxState, disabled, value]
)
let ourProps = { ref: listboxRef }
diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx
index 916cf0467..d6a2c84a7 100644
--- a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx
+++ b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx
@@ -458,6 +458,116 @@ describe('Rendering', () => {
})
)
})
+
+ describe('Uncontrolled', () => {
+ it(
+ 'should be possible to use in an uncontrolled way',
+ suppressConsoleLogs(async () => {
+ let handleSubmission = jest.fn()
+
+ render(
+
+ )
+
+ 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()
+
+ render(
+
+ )
+
+ 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()
+
+ render(
+
+ Alice
+ Bob
+ Charlie
+
+ )
+
+ // 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-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
index cd848b76f..a1b82a50b 100644
--- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
+++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
@@ -29,6 +29,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit, objectToFormEntries } from '../../utils/form'
import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
+import { useControllable } from '../../hooks/use-controllable'
interface Option {
id: string
@@ -103,7 +104,9 @@ function stateReducer(state: StateDefinition, action: Actions) {
// ---
let DEFAULT_RADIO_GROUP_TAG = 'div' as const
-interface RadioGroupRenderPropArg {}
+interface RadioGroupRenderPropArg {
+ value: TType
+}
type RadioGroupPropsWeControl = 'role' | 'aria-labelledby' | 'aria-describedby' | 'id'
let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
@@ -112,18 +115,27 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
>(
props: Props<
TTag,
- RadioGroupRenderPropArg,
+ RadioGroupRenderPropArg,
RadioGroupPropsWeControl | 'value' | 'onChange' | 'disabled' | 'name' | 'by'
> & {
- value: TType
- onChange(value: TType): void
+ value?: TType
+ defaultValue?: TType
+ onChange?(value: TType): void
by?: (keyof TType & string) | ((a: TType, z: TType) => boolean)
disabled?: boolean
name?: string
},
ref: Ref
) {
- let { value, name, onChange, by = (a, z) => a === z, disabled = false, ...theirProps } = props
+ let {
+ value: controlledValue,
+ defaultValue,
+ name,
+ onChange: controlledOnChange,
+ by = (a, z) => a === z,
+ disabled = false,
+ ...theirProps
+ } = props
let compare = useEvent(
typeof by === 'string'
? (a: TType, z: TType) => {
@@ -140,6 +152,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
let internalRadioGroupRef = useRef(null)
let radioGroupRef = useSyncRefs(internalRadioGroupRef, ref)
+ let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue)
+
let firstOption = useMemo(
() =>
options.find((option) => {
@@ -161,7 +175,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
)?.propsRef.current
if (nextOption?.disabled) return false
- onChange(nextValue)
+ onChange?.(nextValue)
+
return true
})
@@ -266,6 +281,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
onKeyDown: handleKeyDown,
}
+ let slot = useMemo>(() => ({ value }), [value])
+
return (
@@ -290,6 +307,7 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
{render({
ourProps,
theirProps,
+ slot,
defaultTag: DEFAULT_RADIO_GROUP_TAG,
name: 'RadioGroup',
})}
diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx
index 7b66e9175..811f0832e 100644
--- a/packages/@headlessui-react/src/components/switch/switch.test.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.test.tsx
@@ -119,6 +119,136 @@ describe('Rendering', () => {
expect(getSwitch()).not.toHaveAttribute('type')
})
})
+
+ describe('Uncontrolled', () => {
+ it('should be possible to use in an uncontrolled way', async () => {
+ let handleSubmission = jest.fn()
+
+ render(
+
+ )
+
+ await click(document.getElementById('submit'))
+
+ // No values
+ expect(handleSubmission).toHaveBeenLastCalledWith({})
+
+ // Toggle
+ await click(getSwitch())
+
+ // Submit
+ await click(document.getElementById('submit'))
+
+ // Notifications should be on
+ expect(handleSubmission).toHaveBeenLastCalledWith({ notifications: 'on' })
+
+ // Toggle
+ await click(getSwitch())
+
+ // Submit
+ await click(document.getElementById('submit'))
+
+ // Notifications should be off (or in this case, non-existant)
+ expect(handleSubmission).toHaveBeenLastCalledWith({})
+ })
+
+ it('should be possible to use in an uncontrolled way with a value', async () => {
+ let handleSubmission = jest.fn()
+
+ render(
+
+ )
+
+ await click(document.getElementById('submit'))
+
+ // No values
+ expect(handleSubmission).toHaveBeenLastCalledWith({})
+
+ // Toggle
+ await click(getSwitch())
+
+ // Submit
+ await click(document.getElementById('submit'))
+
+ // Notifications should be on
+ expect(handleSubmission).toHaveBeenLastCalledWith({ notifications: 'enabled' })
+
+ // Toggle
+ await click(getSwitch())
+
+ // Submit
+ await click(document.getElementById('submit'))
+
+ // Notifications should be off (or in this case, non-existant)
+ expect(handleSubmission).toHaveBeenLastCalledWith({})
+ })
+
+ it('should be possible to provide a default value', async () => {
+ let handleSubmission = jest.fn()
+
+ render(
+
+ )
+
+ await click(document.getElementById('submit'))
+
+ // Notifications should be on by default
+ expect(handleSubmission).toHaveBeenLastCalledWith({ notifications: 'on' })
+
+ // Toggle
+ await click(getSwitch())
+
+ // Submit
+ await click(document.getElementById('submit'))
+
+ // Notifications should be off (or in this case non-existant)
+ expect(handleSubmission).toHaveBeenLastCalledWith({})
+ })
+
+ it('should still call the onChange listeners when choosing new values', async () => {
+ let handleChange = jest.fn()
+
+ render()
+
+ // Toggle
+ await click(getSwitch())
+
+ // Toggle
+ await click(getSwitch())
+
+ // Toggle
+ await click(getSwitch())
+
+ // Change handler should have been called 3 times
+ expect(handleChange).toHaveBeenNthCalledWith(1, true)
+ expect(handleChange).toHaveBeenNthCalledWith(2, false)
+ expect(handleChange).toHaveBeenNthCalledWith(3, true)
+ })
+ })
})
describe('Render composition', () => {
diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx
index 7a44ea12e..4a4110771 100644
--- a/packages/@headlessui-react/src/components/switch/switch.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.tsx
@@ -25,6 +25,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit } from '../../utils/form'
import { useEvent } from '../../hooks/use-event'
+import { useControllable } from '../../hooks/use-controllable'
interface StateDefinition {
switch: HTMLButtonElement | null
@@ -101,16 +102,24 @@ let SwitchRoot = forwardRefWithAs(function Switch<
props: Props<
TTag,
SwitchRenderPropArg,
- SwitchPropsWeControl | 'checked' | 'onChange' | 'name' | 'value'
+ SwitchPropsWeControl | 'checked' | 'defaultChecked' | 'onChange' | 'name' | 'value'
> & {
- checked: boolean
- onChange(checked: boolean): void
+ checked?: boolean
+ defaultChecked?: boolean
+ onChange?(checked: boolean): void
name?: string
value?: string
},
ref: Ref
) {
- let { checked, onChange, name, value, ...theirProps } = props
+ let {
+ checked: controlledChecked,
+ defaultChecked = false,
+ onChange: controlledOnChange,
+ name,
+ value,
+ ...theirProps
+ } = props
let id = `headlessui-switch-${useId()}`
let groupContext = useContext(GroupContext)
let internalSwitchRef = useRef(null)
@@ -121,7 +130,9 @@ let SwitchRoot = forwardRefWithAs(function Switch<
groupContext === null ? null : groupContext.setSwitch
)
- let toggle = useEvent(() => onChange(!checked))
+ let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)
+
+ let toggle = useEvent(() => onChange?.(!checked))
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
event.preventDefault()
diff --git a/packages/@headlessui-react/src/hooks/use-controllable.ts b/packages/@headlessui-react/src/hooks/use-controllable.ts
new file mode 100644
index 000000000..17da50670
--- /dev/null
+++ b/packages/@headlessui-react/src/hooks/use-controllable.ts
@@ -0,0 +1,23 @@
+import { useState } from 'react'
+import { useEvent } from './use-event'
+
+export function useControllable(
+ controlledValue: T | undefined,
+ onChange?: (value: T) => void,
+ defaultValue?: T
+) {
+ let [internalValue, setInternalValue] = useState(defaultValue)
+ let isControlled = controlledValue !== undefined
+
+ return [
+ (isControlled ? controlledValue : internalValue)!,
+ useEvent((value) => {
+ if (isControlled) {
+ return onChange?.(value)
+ } else {
+ setInternalValue(value)
+ return onChange?.(value)
+ }
+ }),
+ ] as const
+}
diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts
index 5454fb89a..043053943 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 09e76832c..c31442907 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 71d0ee77e..5db764f21 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 4c08c1075..f3dc39769 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 2b5ca7d5b..1aed26d7d 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 c6c05df96..ec0b1f1a8 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