diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ac65f2..5c85d81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Only activate the `Tab` on mouseup ([#1192](https://github.com/tailwindlabs/headlessui/pull/1192))
- Ignore "outside click" on removed elements ([#1193](https://github.com/tailwindlabs/headlessui/pull/1193))
+### Added
+
+- Add `
+ )
+ }
+
+ render()
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Open combobox again
+ await click(getComboboxButton())
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'home-delivery']])
+
+ // Open combobox again
+ await click(getComboboxButton())
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'pickup']])
+ })
+
+ it('should be possible to submit a form with a complex value object', async () => {
+ let submits = jest.fn()
+ let options = [
+ {
+ id: 1,
+ value: 'pickup',
+ label: 'Pickup',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 2,
+ value: 'home-delivery',
+ label: 'Home delivery',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 3,
+ value: 'dine-in',
+ label: 'Dine in',
+ extra: { info: 'Some extra info' },
+ },
+ ]
+
+ function Example() {
+ let [value, setValue] = useState(options[0])
+
+ return (
+
+ )
+ }
+
+ render()
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '2'],
+ ['delivery[value]', 'home-delivery'],
+ ['delivery[label]', 'Home delivery'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+ })
+})
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index e3dab39..e23a069 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -24,7 +24,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useComputed } from '../../hooks/use-computed'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { Props } from '../../types'
-import { Features, forwardRefWithAs, PropsForFeatures, render } from '../../utils/render'
+import { Features, forwardRefWithAs, PropsForFeatures, render, compact } from '../../utils/render'
import { match } from '../../utils/match'
import { disposables } from '../../utils/disposables'
import { Keys } from '../keyboard'
@@ -36,6 +36,8 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useLatestValue } from '../../hooks/use-latest-value'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { sortByDomNode } from '../../utils/focus-management'
+import { VisuallyHidden } from '../../internal/visually-hidden'
+import { objectToFormEntries } from '../../utils/form'
enum ComboboxStates {
Open,
@@ -261,15 +263,16 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG,
TType = string
>(
- props: Props, 'value' | 'onChange' | 'disabled'> & {
+ props: Props, 'value' | 'onChange' | 'disabled' | 'name'> & {
value: TType
onChange(value: TType): void
disabled?: boolean
__demoMode?: boolean
+ name?: string
},
ref: Ref
) {
- let { value, onChange, disabled = false, __demoMode = false, ...passThroughProps } = props
+ let { name, value, onChange, disabled = false, __demoMode = false, ...passThroughProps } = props
let comboboxPropsRef = useRef({
value,
@@ -377,6 +380,13 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
// Ensure that we update the inputRef if the value changes
useIsoMorphicEffect(syncInputValue, [syncInputValue])
+ let renderConfiguration = {
+ props: ref === null ? passThroughProps : { ...passThroughProps, ref },
+ slot,
+ defaultTag: DEFAULT_COMBOBOX_TAG,
+ name: 'Combobox',
+ }
+
return (
@@ -386,12 +396,26 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
[ComboboxStates.Closed]: State.Closed,
})}
>
- {render({
- props: ref === null ? passThroughProps : { ...passThroughProps, ref },
- slot,
- defaultTag: DEFAULT_COMBOBOX_TAG,
- name: 'Combobox',
- })}
+ {name != null && value != null ? (
+ <>
+ {objectToFormEntries({ [name]: value }).map(([name, value]) => (
+
+ ))}
+ {render(renderConfiguration)}
+ >
+ ) : (
+ render(renderConfiguration)
+ )}
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index 2a48fca..77de0ae 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -3953,3 +3953,167 @@ describe('Mouse interactions', () => {
})
)
})
+
+describe('Form compatibility', () => {
+ it('should be possible to submit a form with a value', async () => {
+ let submits = jest.fn()
+
+ function Example() {
+ let [value, setValue] = useState(null)
+ return (
+
+ )
+ }
+
+ render()
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Open listbox again
+ await click(getListboxButton())
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'home-delivery']])
+
+ // Open listbox again
+ await click(getListboxButton())
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'pickup']])
+ })
+
+ it('should be possible to submit a form with a complex value object', async () => {
+ let submits = jest.fn()
+ let options = [
+ {
+ id: 1,
+ value: 'pickup',
+ label: 'Pickup',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 2,
+ value: 'home-delivery',
+ label: 'Home delivery',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 3,
+ value: 'dine-in',
+ label: 'Dine in',
+ extra: { info: 'Some extra info' },
+ },
+ ]
+
+ function Example() {
+ let [value, setValue] = useState(options[0])
+
+ return (
+
+ )
+ }
+
+ render()
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '2'],
+ ['delivery[value]', 'home-delivery'],
+ ['delivery[label]', 'Home delivery'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+ })
+})
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index eb973a6..58b239b 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -23,7 +23,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useComputed } from '../../hooks/use-computed'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { Props } from '../../types'
-import { Features, forwardRefWithAs, PropsForFeatures, render } from '../../utils/render'
+import { Features, forwardRefWithAs, PropsForFeatures, render, compact } from '../../utils/render'
import { match } from '../../utils/match'
import { disposables } from '../../utils/disposables'
import { Keys } from '../keyboard'
@@ -33,6 +33,8 @@ import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/fo
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useOutsideClick } from '../../hooks/use-outside-click'
+import { VisuallyHidden } from '../../internal/visually-hidden'
+import { objectToFormEntries } from '../../utils/form'
enum ListboxStates {
Open,
@@ -262,15 +264,20 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG,
TType = string
>(
- props: Props & {
+ props: Props<
+ TTag,
+ ListboxRenderPropArg,
+ 'value' | 'onChange' | 'disabled' | 'horizontal' | 'name'
+ > & {
value: TType
onChange(value: TType): void
disabled?: boolean
horizontal?: boolean
+ name?: string
},
ref: Ref
) {
- let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
+ let { value, name, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
const orientation = horizontal ? 'horizontal' : 'vertical'
let listboxRef = useSyncRefs(ref)
@@ -318,6 +325,13 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
[listboxState, disabled]
)
+ let renderConfiguration = {
+ props: { ref: listboxRef, ...passThroughProps },
+ slot,
+ defaultTag: DEFAULT_LISTBOX_TAG,
+ name: 'Listbox',
+ }
+
return (
- {render({
- props: { ref: listboxRef, ...passThroughProps },
- slot,
- defaultTag: DEFAULT_LISTBOX_TAG,
- name: 'Listbox',
- })}
+ {name != null && value != null ? (
+ <>
+ {objectToFormEntries({ [name]: value }).map(([name, value]) => (
+
+ ))}
+ {render(renderConfiguration)}
+ >
+ ) : (
+ render(renderConfiguration)
+ )}
)
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 010602e..494fce3 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
@@ -841,3 +841,143 @@ describe('Mouse interactions', () => {
expect(changeFn).toHaveBeenCalledTimes(1)
})
})
+
+describe('Form compatibility', () => {
+ it('should be possible to submit a form with a value', async () => {
+ let submits = jest.fn()
+
+ function Example() {
+ let [value, setValue] = useState(null)
+ return (
+
+ )
+ }
+
+ render()
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'home-delivery']])
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'pickup']])
+ })
+
+ it('should be possible to submit a form with a complex value object', async () => {
+ let submits = jest.fn()
+ let options = [
+ {
+ id: 1,
+ value: 'pickup',
+ label: 'Pickup',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 2,
+ value: 'home-delivery',
+ label: 'Home delivery',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 3,
+ value: 'dine-in',
+ label: 'Dine in',
+ extra: { info: 'Some extra info' },
+ },
+ ]
+
+ function Example() {
+ let [value, setValue] = useState(options[0])
+
+ return (
+
+ )
+ }
+
+ render()
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '2'],
+ ['delivery[value]', 'home-delivery'],
+ ['delivery[label]', 'Home delivery'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+ })
+})
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 375868e..5ec51a9 100644
--- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
+++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx
@@ -15,7 +15,7 @@ import React, {
} from 'react'
import { Props, Expand } from '../../types'
-import { forwardRefWithAs, render } from '../../utils/render'
+import { forwardRefWithAs, render, compact } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { match } from '../../utils/match'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -26,6 +26,8 @@ import { Label, useLabels } from '../../components/label/label'
import { Description, useDescriptions } from '../../components/description/description'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useSyncRefs } from '../../hooks/use-sync-refs'
+import { VisuallyHidden } from '../../internal/visually-hidden'
+import { objectToFormEntries } from '../../utils/form'
interface Option {
id: string
@@ -109,15 +111,16 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
props: Props<
TTag,
RadioGroupRenderPropArg,
- RadioGroupPropsWeControl | 'value' | 'onChange' | 'disabled'
+ RadioGroupPropsWeControl | 'value' | 'onChange' | 'disabled' | 'name'
> & {
value: TType
onChange(value: TType): void
disabled?: boolean
+ name?: string
},
ref: Ref
) {
- let { value, onChange, disabled = false, ...passThroughProps } = props
+ let { value, name, onChange, disabled = false, ...passThroughProps } = props
let [{ options }, dispatch] = useReducer(stateReducer, {
options: [],
} as StateDefinition)
@@ -255,15 +258,37 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
onKeyDown: handleKeyDown,
}
+ let renderConfiguration = {
+ props: { ...passThroughProps, ...propsWeControl },
+ defaultTag: DEFAULT_RADIO_GROUP_TAG,
+ name: 'RadioGroup',
+ }
+
return (
- {render({
- props: { ...passThroughProps, ...propsWeControl },
- defaultTag: DEFAULT_RADIO_GROUP_TAG,
- name: 'RadioGroup',
- })}
+ {name != null && value != null ? (
+ <>
+ {objectToFormEntries({ [name]: value }).map(([name, value]) => (
+
+ ))}
+ {render(renderConfiguration)}
+ >
+ ) : (
+ render(renderConfiguration)
+ )}
diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx
index 986664b..aff2ebe 100644
--- a/packages/@headlessui-react/src/components/switch/switch.test.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.test.tsx
@@ -8,6 +8,7 @@ import {
getSwitch,
assertActiveElement,
getSwitchLabel,
+ getByText,
} from '../../test-utils/accessibility-assertions'
import { press, click, Keys } from '../../test-utils/interactions'
@@ -395,3 +396,83 @@ describe('Mouse interactions', () => {
assertSwitch({ state: SwitchState.Off })
})
})
+
+describe('Form compatibility', () => {
+ it('should be possible to submit a form with an boolean value', async () => {
+ let submits = jest.fn()
+
+ function Example() {
+ let [state, setState] = useState(false)
+ return (
+
+ )
+ }
+
+ render()
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Toggle
+ await click(getSwitchLabel())
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['notifications', 'on']])
+ })
+
+ it('should be possible to submit a form with a provided string value', async () => {
+ let submits = jest.fn()
+
+ function Example() {
+ let [state, setState] = useState(false)
+ return (
+
+ )
+ }
+
+ render()
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Toggle
+ await click(getSwitchLabel())
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['fruit', 'apple']])
+ })
+})
diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx
index d1366fc..16e86b4 100644
--- a/packages/@headlessui-react/src/components/switch/switch.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.tsx
@@ -5,17 +5,17 @@ import React, {
useContext,
useMemo,
useState,
+ useRef,
// Types
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
- useRef,
Ref,
} from 'react'
import { Props } from '../../types'
-import { forwardRefWithAs, render } from '../../utils/render'
+import { forwardRefWithAs, render, compact } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../keyboard'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
@@ -23,6 +23,7 @@ import { Label, useLabels } from '../label/label'
import { Description, useDescriptions } from '../description/description'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useSyncRefs } from '../../hooks/use-sync-refs'
+import { VisuallyHidden } from '../../internal/visually-hidden'
interface StateDefinition {
switch: HTMLButtonElement | null
@@ -88,13 +89,19 @@ type SwitchPropsWeControl =
let SwitchRoot = forwardRefWithAs(function Switch<
TTag extends ElementType = typeof DEFAULT_SWITCH_TAG
>(
- props: Props & {
+ props: Props<
+ TTag,
+ SwitchRenderPropArg,
+ SwitchPropsWeControl | 'checked' | 'onChange' | 'name' | 'value'
+ > & {
checked: boolean
onChange(checked: boolean): void
+ name?: string
+ value?: string
},
ref: Ref
) {
- let { checked, onChange, ...passThroughProps } = props
+ let { checked, onChange, name, value, ...passThroughProps } = props
let id = `headlessui-switch-${useId()}`
let groupContext = useContext(GroupContext)
let internalSwitchRef = useRef(null)
@@ -143,12 +150,33 @@ let SwitchRoot = forwardRefWithAs(function Switch<
onKeyPress: handleKeyPress,
}
- return render({
+ let renderConfiguration = {
props: { ...passThroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_SWITCH_TAG,
name: 'Switch',
- })
+ }
+
+ if (name != null && checked) {
+ return (
+ <>
+
+ {render(renderConfiguration)}
+ >
+ )
+ }
+
+ return render(renderConfiguration)
})
// ---
diff --git a/packages/@headlessui-react/src/internal/visually-hidden.tsx b/packages/@headlessui-react/src/internal/visually-hidden.tsx
new file mode 100644
index 0000000..9aceeff
--- /dev/null
+++ b/packages/@headlessui-react/src/internal/visually-hidden.tsx
@@ -0,0 +1,30 @@
+import { ElementType, Ref } from 'react'
+import { Props } from '../types'
+import { forwardRefWithAs, render } from '../utils/render'
+
+let DEFAULT_VISUALLY_HIDDEN_TAG = 'div' as const
+
+export let VisuallyHidden = forwardRefWithAs(function VisuallyHidden<
+ TTag extends ElementType = typeof DEFAULT_VISUALLY_HIDDEN_TAG
+>(props: Props, ref: Ref) {
+ return render({
+ props: {
+ ...props,
+ ref,
+ style: {
+ position: 'absolute',
+ width: 1,
+ height: 1,
+ padding: 0,
+ margin: -1,
+ overflow: 'hidden',
+ clip: 'rect(0, 0, 0, 0)',
+ whiteSpace: 'nowrap',
+ borderWidth: '0',
+ },
+ },
+ slot: {},
+ defaultTag: DEFAULT_VISUALLY_HIDDEN_TAG,
+ name: 'VisuallyHidden',
+ })
+})
diff --git a/packages/@headlessui-react/src/utils/form.test.ts b/packages/@headlessui-react/src/utils/form.test.ts
new file mode 100644
index 0000000..752be52
--- /dev/null
+++ b/packages/@headlessui-react/src/utils/form.test.ts
@@ -0,0 +1,25 @@
+import { objectToFormEntries } from './form'
+
+it.each([
+ [{ a: 'b' }, [['a', 'b']]],
+ [
+ [1, 2, 3],
+ [
+ ['0', '1'],
+ ['1', '2'],
+ ['2', '3'],
+ ],
+ ],
+ [
+ { id: 1, admin: true, name: { first: 'Jane', last: 'Doe', nickname: { preferred: 'JDoe' } } },
+ [
+ ['id', '1'],
+ ['admin', '1'],
+ ['name[first]', 'Jane'],
+ ['name[last]', 'Doe'],
+ ['name[nickname][preferred]', 'JDoe'],
+ ],
+ ],
+])('should encode an input of %j to an form data output', (input, output) => {
+ expect(objectToFormEntries(input)).toEqual(output)
+})
diff --git a/packages/@headlessui-react/src/utils/form.ts b/packages/@headlessui-react/src/utils/form.ts
new file mode 100644
index 0000000..730b675
--- /dev/null
+++ b/packages/@headlessui-react/src/utils/form.ts
@@ -0,0 +1,37 @@
+type Entries = [string, string][]
+
+export function objectToFormEntries(
+ source: Record = {},
+ parentKey: string | null = null,
+ entries: Entries = []
+): Entries {
+ for (let [key, value] of Object.entries(source)) {
+ append(entries, composeKey(parentKey, key), value)
+ }
+
+ return entries
+}
+
+function composeKey(parent: string | null, key: string): string {
+ return parent ? parent + '[' + key + ']' : key
+}
+
+function append(entries: Entries, key: string, value: any): void {
+ if (Array.isArray(value)) {
+ for (let [subkey, subvalue] of value.entries()) {
+ append(entries, composeKey(key, subkey.toString()), subvalue)
+ }
+ } else if (value instanceof Date) {
+ entries.push([key, value.toISOString()])
+ } else if (typeof value === 'boolean') {
+ entries.push([key, value ? '1' : '0'])
+ } else if (typeof value === 'string') {
+ entries.push([key, value])
+ } else if (typeof value === 'number') {
+ entries.push([key, `${value}`])
+ } else if (value === null || value === undefined) {
+ entries.push([key, ''])
+ } else {
+ objectToFormEntries(value, key, entries)
+ }
+}
diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts
index 984de48..eedd1e5 100644
--- a/packages/@headlessui-react/src/utils/render.ts
+++ b/packages/@headlessui-react/src/utils/render.ts
@@ -218,7 +218,7 @@ export function forwardRefWithAs>(object: T) {
+export function compact>(object: T) {
let clone = Object.assign({}, object)
for (let key in clone) {
if (clone[key] === undefined) delete clone[key]
diff --git a/packages/@headlessui-react/tsconfig.json b/packages/@headlessui-react/tsconfig.json
index 5fb92fd..68bf66c 100644
--- a/packages/@headlessui-react/tsconfig.json
+++ b/packages/@headlessui-react/tsconfig.json
@@ -2,7 +2,7 @@
"include": ["src", "types"],
"compilerOptions": {
"module": "esnext",
- "lib": ["dom", "esnext"],
+ "lib": ["dom", "esnext", "dom.iterable"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts
index 32203a9..5ad62e1 100644
--- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts
+++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts
@@ -4614,3 +4614,170 @@ describe('Mouse interactions', () => {
})
)
})
+
+describe('Form compatibility', () => {
+ it('should be possible to submit a form with a value', async () => {
+ let submits = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => {
+ let value = ref(null)
+ return {
+ value,
+ handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+ submits([...new FormData(event.currentTarget as HTMLFormElement).entries()])
+ },
+ }
+ },
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Open combobox again
+ await click(getComboboxButton())
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'home-delivery']])
+
+ // Open combobox again
+ await click(getComboboxButton())
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'pickup']])
+ })
+
+ it('should be possible to submit a form with a complex value object', async () => {
+ let submits = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => {
+ let options = ref([
+ {
+ id: 1,
+ value: 'pickup',
+ label: 'Pickup',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 2,
+ value: 'home-delivery',
+ label: 'Home delivery',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 3,
+ value: 'dine-in',
+ label: 'Dine in',
+ extra: { info: 'Some extra info' },
+ },
+ ])
+ let value = ref(options.value[0])
+
+ return {
+ value,
+ options,
+ handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+ submits([...new FormData(event.currentTarget as HTMLFormElement).entries()])
+ },
+ }
+ },
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '2'],
+ ['delivery[value]', 'home-delivery'],
+ ['delivery[label]', 'Home delivery'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+ })
+})
diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts
index ba9a22f..e157636 100644
--- a/packages/@headlessui-vue/src/components/combobox/combobox.ts
+++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts
@@ -1,6 +1,8 @@
import {
+ Fragment,
computed,
defineComponent,
+ h,
inject,
nextTick,
onMounted,
@@ -10,6 +12,8 @@ import {
toRaw,
watch,
watchEffect,
+
+ // Types
ComputedRef,
InjectionKey,
PropType,
@@ -17,7 +21,7 @@ import {
UnwrapNestedRefs,
} from 'vue'
-import { Features, render, omit } from '../../utils/render'
+import { Features, render, omit, compact } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
@@ -28,6 +32,8 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { sortByDomNode } from '../../utils/focus-management'
import { useOutsideClick } from '../../hooks/use-outside-click'
+import { VisuallyHidden } from '../../internal/visually-hidden'
+import { objectToFormEntries } from '../../utils/form'
enum ComboboxStates {
Open,
@@ -96,6 +102,7 @@ export let Combobox = defineComponent({
as: { type: [Object, String], default: 'template' },
disabled: { type: [Boolean], default: false },
modelValue: { type: [Object, String, Number, Boolean] },
+ name: { type: String },
},
setup(props, { slots, attrs, emit }) {
let comboboxState = ref(ComboboxStates.Closed)
@@ -276,20 +283,43 @@ export let Combobox = defineComponent({
)
return () => {
+ let { name, modelValue, disabled, ...passThroughProps } = props
let slot = {
open: comboboxState.value === ComboboxStates.Open,
- disabled: props.disabled,
+ disabled,
activeIndex: activeOptionIndex.value,
activeOption: activeOption.value,
}
- return render({
- props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled']),
+ let renderConfiguration = {
+ props: omit(passThroughProps, ['onUpdate:modelValue']),
slot,
slots,
attrs,
name: 'Combobox',
- })
+ }
+
+ if (name != null && modelValue != null) {
+ return h(Fragment, [
+ ...objectToFormEntries({ [name]: modelValue }).map(([name, value]) =>
+ h(
+ VisuallyHidden,
+ compact({
+ key: name,
+ as: 'input',
+ type: 'hidden',
+ hidden: true,
+ readOnly: true,
+ name,
+ value,
+ })
+ )
+ ),
+ render(renderConfiguration),
+ ])
+ }
+
+ return render(renderConfiguration)
}
},
})
diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts
index b69db0e..c9de90e 100644
--- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts
+++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts
@@ -1,8 +1,6 @@
import {
computed,
defineComponent,
- onMounted,
- onUnmounted,
ref,
// Types
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
index 4b4c187..4e9c4e1 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
@@ -4078,3 +4078,168 @@ describe('Mouse interactions', () => {
})
)
})
+
+describe('Form compatibility', () => {
+ it('should be possible to submit a form with a value', async () => {
+ let submits = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => {
+ let value = ref(null)
+ return {
+ value,
+ handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+ submits([...new FormData(event.currentTarget as HTMLFormElement).entries()])
+ },
+ }
+ },
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Open listbox again
+ await click(getListboxButton())
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'home-delivery']])
+
+ // Open listbox again
+ await click(getListboxButton())
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'pickup']])
+ })
+
+ it('should be possible to submit a form with a complex value object', async () => {
+ let submits = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup: () => {
+ let options = ref([
+ {
+ id: 1,
+ value: 'pickup',
+ label: 'Pickup',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 2,
+ value: 'home-delivery',
+ label: 'Home delivery',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 3,
+ value: 'dine-in',
+ label: 'Dine in',
+ extra: { info: 'Some extra info' },
+ },
+ ])
+ let value = ref(options.value[0])
+
+ return {
+ value,
+ options,
+ handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+ submits([...new FormData(event.currentTarget as HTMLFormElement).entries()])
+ },
+ }
+ },
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '2'],
+ ['delivery[value]', 'home-delivery'],
+ ['delivery[label]', 'Home delivery'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+ })
+})
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts
index 9173cdb..78f5233 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.ts
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts
@@ -1,22 +1,26 @@
import {
+ Fragment,
+ computed,
defineComponent,
- ref,
- provide,
+ h,
inject,
+ nextTick,
onMounted,
onUnmounted,
- computed,
- nextTick,
- InjectionKey,
- Ref,
- ComputedRef,
- watchEffect,
+ provide,
+ ref,
toRaw,
watch,
+ watchEffect,
+
+ // Types
+ ComputedRef,
+ InjectionKey,
+ Ref,
UnwrapNestedRefs,
} from 'vue'
-import { Features, render, omit } from '../../utils/render'
+import { Features, render, omit, compact } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
@@ -26,6 +30,8 @@ import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management'
import { useOutsideClick } from '../../hooks/use-outside-click'
+import { VisuallyHidden } from '../../internal/visually-hidden'
+import { objectToFormEntries } from '../../utils/form'
enum ListboxStates {
Open,
@@ -98,6 +104,7 @@ export let Listbox = defineComponent({
disabled: { type: [Boolean], default: false },
horizontal: { type: [Boolean], default: false },
modelValue: { type: [Object, String, Number, Boolean] },
+ name: { type: String, optional: true },
},
setup(props, { slots, attrs, emit }) {
let listboxState = ref(ListboxStates.Closed)
@@ -270,14 +277,38 @@ export let Listbox = defineComponent({
)
return () => {
- let slot = { open: listboxState.value === ListboxStates.Open, disabled: props.disabled }
- return render({
- props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']),
+ let { name, modelValue, disabled, ...passThroughProps } = props
+
+ let slot = { open: listboxState.value === ListboxStates.Open, disabled }
+ let renderConfiguration = {
+ props: omit(passThroughProps, ['onUpdate:modelValue', 'horizontal']),
slot,
slots,
attrs,
name: 'Listbox',
- })
+ }
+
+ if (name != null && modelValue != null) {
+ return h(Fragment, [
+ ...objectToFormEntries({ [name]: modelValue }).map(([name, value]) =>
+ h(
+ VisuallyHidden,
+ compact({
+ key: name,
+ as: 'input',
+ type: 'hidden',
+ hidden: true,
+ readOnly: true,
+ name,
+ value,
+ })
+ )
+ ),
+ render(renderConfiguration),
+ ])
+ }
+
+ return render(renderConfiguration)
}
},
})
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 aacac78..b2c15a9 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
@@ -1135,3 +1135,148 @@ describe('Mouse interactions', () => {
expect(changeFn).toHaveBeenCalledTimes(1)
})
})
+
+describe('Form compatibility', () => {
+ it('should be possible to submit a form with a value', async () => {
+ let submits = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup() {
+ let deliveryMethod = ref(null)
+ return {
+ deliveryMethod,
+ handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+
+ submits([...new FormData(event.currentTarget as HTMLFormElement).entries()])
+ },
+ }
+ },
+ })
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'home-delivery']])
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['delivery', 'pickup']])
+ })
+
+ it('should be possible to submit a form with a complex value object', async () => {
+ let submits = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup() {
+ let options = ref([
+ {
+ id: 1,
+ value: 'pickup',
+ label: 'Pickup',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 2,
+ value: 'home-delivery',
+ label: 'Home delivery',
+ extra: { info: 'Some extra info' },
+ },
+ {
+ id: 3,
+ value: 'dine-in',
+ label: 'Dine in',
+ extra: { info: 'Some extra info' },
+ },
+ ])
+ let deliveryMethod = ref(options.value[0])
+
+ return {
+ deliveryMethod,
+ options,
+ handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+
+ submits([...new FormData(event.currentTarget as HTMLFormElement).entries()])
+ },
+ }
+ },
+ })
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Choose home delivery
+ await click(getByText('Home delivery'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '2'],
+ ['delivery[value]', 'home-delivery'],
+ ['delivery[label]', 'Home delivery'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+
+ // Choose pickup
+ await click(getByText('Pickup'))
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([
+ ['delivery[id]', '1'],
+ ['delivery[value]', 'pickup'],
+ ['delivery[label]', 'Pickup'],
+ ['delivery[extra][info]', 'Some extra info'],
+ ])
+ })
+})
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 e4a231b..a473c55 100644
--- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts
+++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts
@@ -1,6 +1,8 @@
import {
+ Fragment,
computed,
defineComponent,
+ h,
inject,
onMounted,
onUnmounted,
@@ -17,10 +19,12 @@ import { dom } from '../../utils/dom'
import { Keys } from '../../keyboard'
import { focusIn, Focus, FocusResult, sortByDomNode } from '../../utils/focus-management'
import { useId } from '../../hooks/use-id'
-import { omit, render } from '../../utils/render'
+import { compact, omit, render } from '../../utils/render'
import { Label, useLabels } from '../label/label'
import { Description, useDescriptions } from '../description/description'
import { useTreeWalker } from '../../hooks/use-tree-walker'
+import { VisuallyHidden } from '../../internal/visually-hidden'
+import { objectToFormEntries } from '../../utils/form'
interface Option {
id: string
@@ -65,6 +69,7 @@ export let RadioGroup = defineComponent({
as: { type: [Object, String], default: 'div' },
disabled: { type: [Boolean], default: false },
modelValue: { type: [Object, String, Number, Boolean] },
+ name: { type: String, optional: true },
},
setup(props, { emit, attrs, slots }) {
let radioGroupRef = ref(null)
@@ -183,7 +188,7 @@ export let RadioGroup = defineComponent({
let id = `headlessui-radiogroup-${useId()}`
return () => {
- let { modelValue, disabled, ...passThroughProps } = props
+ let { modelValue, disabled, name, ...passThroughProps } = props
let propsWeControl = {
ref: radioGroupRef,
@@ -194,13 +199,35 @@ export let RadioGroup = defineComponent({
onKeydown: handleKeyDown,
}
- return render({
+ let renderConfiguration = {
props: { ...passThroughProps, ...propsWeControl },
slot: {},
attrs,
slots,
name: 'RadioGroup',
- })
+ }
+
+ if (name != null && modelValue != null) {
+ return h(Fragment, [
+ ...objectToFormEntries({ [name]: modelValue }).map(([name, value]) =>
+ h(
+ VisuallyHidden,
+ compact({
+ key: name,
+ as: 'input',
+ type: 'hidden',
+ hidden: true,
+ readOnly: true,
+ name,
+ value,
+ })
+ )
+ ),
+ render(renderConfiguration),
+ ])
+ }
+
+ return render(renderConfiguration)
}
},
})
diff --git a/packages/@headlessui-vue/src/components/switch/switch.test.tsx b/packages/@headlessui-vue/src/components/switch/switch.test.tsx
index 207239c..9ee10fb 100644
--- a/packages/@headlessui-vue/src/components/switch/switch.test.tsx
+++ b/packages/@headlessui-vue/src/components/switch/switch.test.tsx
@@ -515,3 +515,87 @@ describe('Mouse interactions', () => {
assertSwitch({ state: SwitchState.Off })
})
})
+
+describe('Form compatibility', () => {
+ it('should be possible to submit a form with an boolean value', async () => {
+ let submits = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup() {
+ let checked = ref(false)
+ return {
+ checked,
+ handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+ submits([...new FormData(event.currentTarget as HTMLFormElement).entries()])
+ },
+ }
+ },
+ })
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Toggle
+ await click(getSwitchLabel())
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['notifications', 'on']])
+ })
+
+ it('should be possible to submit a form with a provided string value', async () => {
+ let submits = jest.fn()
+
+ renderTemplate({
+ template: html`
+
+ `,
+ setup() {
+ let checked = ref(false)
+ return {
+ checked,
+ handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+ submits([...new FormData(event.currentTarget as HTMLFormElement).entries()])
+ },
+ }
+ },
+ })
+
+ // Submit the form
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([]) // no data
+
+ // Toggle
+ await click(getSwitchLabel())
+
+ // Submit the form again
+ await click(getByText('Submit'))
+
+ // Verify that the form has been submitted
+ expect(submits).lastCalledWith([['fruit', 'apple']])
+ })
+})
diff --git a/packages/@headlessui-vue/src/components/switch/switch.ts b/packages/@headlessui-vue/src/components/switch/switch.ts
index 8ca770e..383cc15 100644
--- a/packages/@headlessui-vue/src/components/switch/switch.ts
+++ b/packages/@headlessui-vue/src/components/switch/switch.ts
@@ -1,5 +1,8 @@
import {
+ Fragment,
+ computed,
defineComponent,
+ h,
inject,
provide,
ref,
@@ -7,15 +10,15 @@ import {
// Types
InjectionKey,
Ref,
- computed,
} from 'vue'
-import { render } from '../../utils/render'
+import { render, compact } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { Label, useLabels } from '../label/label'
import { Description, useDescriptions } from '../description/description'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
+import { VisuallyHidden } from '../../internal/visually-hidden'
type StateDefinition = {
// State
@@ -63,6 +66,8 @@ export let Switch = defineComponent({
props: {
as: { type: [Object, String], default: 'button' },
modelValue: { type: Boolean, default: false },
+ name: { type: String, optional: true },
+ value: { type: String, optional: true },
},
setup(props, { emit, attrs, slots }) {
@@ -96,14 +101,15 @@ export let Switch = defineComponent({
}
return () => {
- let slot = { checked: props.modelValue }
+ let { name, value, modelValue, ...passThroughProps } = props
+ let slot = { checked: modelValue }
let propsWeControl = {
id,
ref: switchRef,
role: 'switch',
type: type.value,
tabIndex: 0,
- 'aria-checked': props.modelValue,
+ 'aria-checked': modelValue,
'aria-labelledby': api?.labelledby.value,
'aria-describedby': api?.describedby.value,
onClick: handleClick,
@@ -111,13 +117,33 @@ export let Switch = defineComponent({
onKeypress: handleKeyPress,
}
- return render({
- props: { ...props, ...propsWeControl },
+ let renderConfiguration = {
+ props: { ...passThroughProps, ...propsWeControl },
slot,
attrs,
slots,
name: 'Switch',
- })
+ }
+
+ if (name != null && modelValue != null) {
+ return h(Fragment, [
+ h(
+ VisuallyHidden,
+ compact({
+ as: 'input',
+ type: 'checkbox',
+ hidden: true,
+ readOnly: true,
+ checked: modelValue,
+ name,
+ value,
+ })
+ ),
+ render(renderConfiguration),
+ ])
+ }
+
+ return render(renderConfiguration)
}
},
})
diff --git a/packages/@headlessui-vue/src/internal/visually-hidden.ts b/packages/@headlessui-vue/src/internal/visually-hidden.ts
new file mode 100644
index 0000000..e74d6ae
--- /dev/null
+++ b/packages/@headlessui-vue/src/internal/visually-hidden.ts
@@ -0,0 +1,34 @@
+import { defineComponent } from 'vue'
+import { render } from '../utils/render'
+
+export let VisuallyHidden = defineComponent({
+ name: 'VisuallyHidden',
+ props: {
+ as: { type: [Object, String], default: 'div' },
+ },
+ setup(props, { slots, attrs }) {
+ return () => {
+ let propsWeControl = {
+ style: {
+ position: 'absolute',
+ width: 1,
+ height: 1,
+ padding: 0,
+ margin: -1,
+ overflow: 'hidden',
+ clip: 'rect(0, 0, 0, 0)',
+ whiteSpace: 'nowrap',
+ borderWidth: '0',
+ },
+ }
+
+ return render({
+ props: { ...props, ...propsWeControl },
+ slot: {},
+ attrs,
+ slots,
+ name: 'VisuallyHidden',
+ })
+ }
+ },
+})
diff --git a/packages/@headlessui-vue/src/utils/form.test.ts b/packages/@headlessui-vue/src/utils/form.test.ts
new file mode 100644
index 0000000..752be52
--- /dev/null
+++ b/packages/@headlessui-vue/src/utils/form.test.ts
@@ -0,0 +1,25 @@
+import { objectToFormEntries } from './form'
+
+it.each([
+ [{ a: 'b' }, [['a', 'b']]],
+ [
+ [1, 2, 3],
+ [
+ ['0', '1'],
+ ['1', '2'],
+ ['2', '3'],
+ ],
+ ],
+ [
+ { id: 1, admin: true, name: { first: 'Jane', last: 'Doe', nickname: { preferred: 'JDoe' } } },
+ [
+ ['id', '1'],
+ ['admin', '1'],
+ ['name[first]', 'Jane'],
+ ['name[last]', 'Doe'],
+ ['name[nickname][preferred]', 'JDoe'],
+ ],
+ ],
+])('should encode an input of %j to an form data output', (input, output) => {
+ expect(objectToFormEntries(input)).toEqual(output)
+})
diff --git a/packages/@headlessui-vue/src/utils/form.ts b/packages/@headlessui-vue/src/utils/form.ts
new file mode 100644
index 0000000..730b675
--- /dev/null
+++ b/packages/@headlessui-vue/src/utils/form.ts
@@ -0,0 +1,37 @@
+type Entries = [string, string][]
+
+export function objectToFormEntries(
+ source: Record = {},
+ parentKey: string | null = null,
+ entries: Entries = []
+): Entries {
+ for (let [key, value] of Object.entries(source)) {
+ append(entries, composeKey(parentKey, key), value)
+ }
+
+ return entries
+}
+
+function composeKey(parent: string | null, key: string): string {
+ return parent ? parent + '[' + key + ']' : key
+}
+
+function append(entries: Entries, key: string, value: any): void {
+ if (Array.isArray(value)) {
+ for (let [subkey, subvalue] of value.entries()) {
+ append(entries, composeKey(key, subkey.toString()), subvalue)
+ }
+ } else if (value instanceof Date) {
+ entries.push([key, value.toISOString()])
+ } else if (typeof value === 'boolean') {
+ entries.push([key, value ? '1' : '0'])
+ } else if (typeof value === 'string') {
+ entries.push([key, value])
+ } else if (typeof value === 'number') {
+ entries.push([key, `${value}`])
+ } else if (value === null || value === undefined) {
+ entries.push([key, ''])
+ } else {
+ objectToFormEntries(value, key, entries)
+ }
+}
diff --git a/packages/@headlessui-vue/src/utils/render.ts b/packages/@headlessui-vue/src/utils/render.ts
index 7936a0b..8e424a7 100644
--- a/packages/@headlessui-vue/src/utils/render.ts
+++ b/packages/@headlessui-vue/src/utils/render.ts
@@ -125,6 +125,14 @@ function _render({
return h(as, passThroughProps, children)
}
+export function compact>(object: T) {
+ let clone = Object.assign({}, object)
+ for (let key in clone) {
+ if (clone[key] === undefined) delete clone[key]
+ }
+ return clone
+}
+
export function omit, Keys extends keyof T>(
object: T,
keysToOmit: readonly Keys[] = []
diff --git a/packages/@headlessui-vue/tsconfig.json b/packages/@headlessui-vue/tsconfig.json
index cc575b4..acaffcd 100644
--- a/packages/@headlessui-vue/tsconfig.json
+++ b/packages/@headlessui-vue/tsconfig.json
@@ -2,7 +2,7 @@
"include": ["src", "types"],
"compilerOptions": {
"module": "esnext",
- "lib": ["dom", "esnext"],
+ "lib": ["dom", "esnext", "dom.iterable"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
diff --git a/packages/playground-react/pages/combinations/form.tsx b/packages/playground-react/pages/combinations/form.tsx
new file mode 100644
index 0000000..4b726f1
--- /dev/null
+++ b/packages/playground-react/pages/combinations/form.tsx
@@ -0,0 +1,353 @@
+import { useState } from 'react'
+import { Switch, RadioGroup, Listbox, Combobox } from '@headlessui/react'
+import { classNames } from '../../utils/class-names'
+
+function Section({ title, children }) {
+ return (
+
+ )
+}
+
+let sizes = ['xs', 'sm', 'md', 'lg', 'xl']
+let people = [
+ { id: 1, name: { first: 'Alice' } },
+ { id: 2, name: { first: 'Bob' } },
+ { id: 3, name: { first: 'Charlie' } },
+]
+let locations = ['New York', 'London', 'Paris', 'Berlin']
+
+export default function App() {
+ let [result, setResult] = useState(() => (typeof window === 'undefined' ? [] : new FormData()))
+ let [notifications, setNotifications] = useState(false)
+ let [apple, setApple] = useState(false)
+ let [banana, setBanana] = useState(false)
+ let [size, setSize] = useState(sizes[(Math.random() * sizes.length) | 0])
+ let [person, setPerson] = useState(people[(Math.random() * people.length) | 0])
+ let [activeLocation, setActiveLocation] = useState(
+ locations[(Math.random() * locations.length) | 0]
+ )
+ let [query, setQuery] = useState('')
+
+ return (
+