From 7bb89871bae14ace6cbf92e637e1ddc8dbe0d3a8 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 9 Mar 2022 11:24:45 +0100 Subject: [PATCH] Add `
` compatibility (#1214) * implement `objetToFormEntries` functionality If we are working with more complex data structures then we have to encode those data structures into a syntax that the HTML can understand. This means that we have to use `` syntax. To convert a simple array we can use the following syntax: ```js // Assuming we have a `name` of `person` let input = ['Alice', 'Bob', 'Charlie'] ``` Results in: ```html ``` Note: the additional `[]` in the name attribute. --- A more complex object (even deeply nested) can be encoded like this: ```js // Assuming we have a `name` of `person` let input = { id: 1, name: { first: 'Jane', last: 'Doe' } } ``` Results in: ```html ``` * implement VisuallyHidden component * implement and export some extra helper utilities * implement form element for Switch * implement form element for Combobox * implement form element for RadioGroup * implement form element for Listbox * add combined forms example to the playground * update changelog * enable support for iterators * ensure to compile dom iterables * remove unused imports --- CHANGELOG.md | 8 + .../src/components/combobox/combobox.test.tsx | 166 ++++++++ .../src/components/combobox/combobox.tsx | 42 ++- .../src/components/listbox/listbox.test.tsx | 164 ++++++++ .../src/components/listbox/listbox.tsx | 46 ++- .../radio-group/radio-group.test.tsx | 140 +++++++ .../components/radio-group/radio-group.tsx | 41 +- .../src/components/switch/switch.test.tsx | 81 ++++ .../src/components/switch/switch.tsx | 40 +- .../src/internal/visually-hidden.tsx | 30 ++ .../@headlessui-react/src/utils/form.test.ts | 25 ++ packages/@headlessui-react/src/utils/form.ts | 37 ++ .../@headlessui-react/src/utils/render.ts | 2 +- packages/@headlessui-react/tsconfig.json | 2 +- .../src/components/combobox/combobox.test.ts | 167 +++++++++ .../src/components/combobox/combobox.ts | 40 +- .../src/components/focus-trap/focus-trap.ts | 2 - .../src/components/listbox/listbox.test.tsx | 165 ++++++++ .../src/components/listbox/listbox.ts | 57 ++- .../radio-group/radio-group.test.ts | 145 +++++++ .../src/components/radio-group/radio-group.ts | 35 +- .../src/components/switch/switch.test.tsx | 84 +++++ .../src/components/switch/switch.ts | 40 +- .../src/internal/visually-hidden.ts | 34 ++ .../@headlessui-vue/src/utils/form.test.ts | 25 ++ packages/@headlessui-vue/src/utils/form.ts | 37 ++ packages/@headlessui-vue/src/utils/render.ts | 8 + packages/@headlessui-vue/tsconfig.json | 2 +- .../pages/combinations/form.tsx | 353 ++++++++++++++++++ packages/playground-react/tsconfig.json | 1 + 30 files changed, 1953 insertions(+), 66 deletions(-) create mode 100644 packages/@headlessui-react/src/internal/visually-hidden.tsx create mode 100644 packages/@headlessui-react/src/utils/form.test.ts create mode 100644 packages/@headlessui-react/src/utils/form.ts create mode 100644 packages/@headlessui-vue/src/internal/visually-hidden.ts create mode 100644 packages/@headlessui-vue/src/utils/form.test.ts create mode 100644 packages/@headlessui-vue/src/utils/form.ts create mode 100644 packages/playground-react/pages/combinations/form.tsx 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 `` compatibility ([#1214](https://github.com/tailwindlabs/headlessui/pull/1214)) + ## [Unreleased - @headlessui/vue] ### Fixed @@ -41,6 +45,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 `` compatibility ([#1214](https://github.com/tailwindlabs/headlessui/pull/1214)) + ## [@headlessui/react@v1.5.0] - 2022-02-17 ### Fixed diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 2d53e0c..84d8575 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -4392,3 +4392,169 @@ 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 ( + { + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + + Trigger + Pizza Delivery + + Pickup + Home delivery + Dine in + + + +
+ ) + } + + 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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + + Trigger + Pizza Delivery + + {options.map((option) => ( + + {option.label} + + ))} + + + +
+ ) + } + + 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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + Trigger + Pizza Delivery + + Pickup + Home delivery + Dine in + + + +
+ ) + } + + 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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + Trigger + Pizza Delivery + + {options.map((option) => ( + + {option.label} + + ))} + + + +
+ ) + } + + 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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + Pizza Delivery + Pickup + Home delivery + Dine in + + +
+ ) + } + + 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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + Pizza Delivery + {options.map((option) => ( + + {option.label} + + ))} + + +
+ ) + } + + 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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + + Enable notifications + + +
+ ) + } + + 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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + + Apple + + +
+ ) + } + + 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` +
+ + + Trigger + + Pickup + Home delivery + Dine in + + + +
+ `, + 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` +
+ + Trigger + + + {{option.label}} + + + +
+ `, + 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` +
+ + Trigger + + Pickup + Home delivery + Dine in + + + +
+ `, + 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` +
+ + Trigger + + {{option.label}} + + + +
+ `, + 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` +
+ + Pizza Delivery + Pickup + Home delivery + Dine in + + +
+ `, + 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` +
+ + Pizza Delivery + {{ option.label }} + + +
+ `, + 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` +
+ + + Enable notifications + + +
+ `, + 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` +
+ + + Apple + + +
+ `, + 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 ( +
+ {title} +
{children}
+
+ ) +} + +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 ( +
+
{ + event.preventDefault() + setResult(new FormData(event.currentTarget)) + }} + > +
+
+
+ + Enable notifications + + + classNames( + 'focus:shadow-outline relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none', + checked ? 'bg-indigo-600' : 'bg-gray-200' + ) + } + > + {({ checked }) => ( + <> + + + )} + + +
+ +
+ + Apple + + + classNames( + 'focus:shadow-outline relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none', + checked ? 'bg-indigo-600' : 'bg-gray-200' + ) + } + > + {({ checked }) => ( + <> + + + )} + + + + + Banana + + classNames( + 'focus:shadow-outline relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none', + checked ? 'bg-indigo-600' : 'bg-gray-200' + ) + } + > + {({ checked }) => ( + <> + + + )} + + +
+
+
+ +
+ {sizes.map((size) => { + return ( + + classNames( + 'relative flex w-20 border px-2 py-4 first:rounded-l-md last:rounded-r-md focus:outline-none', + active ? 'z-10 border-indigo-200 bg-indigo-50' : 'border-gray-200' + ) + } + > + {({ active, checked }) => ( +
+
+ + {size} + +
+
+ {checked && ( + + + + )} +
+
+ )} +
+ ) + })} +
+
+
+
+
+ +
+ + + {person.name.first} + + + + + + + + +
+ + {people.map((person) => ( + { + return classNames( + 'relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', + active ? 'bg-indigo-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {person.name.first} + + {selected && ( + + + + + + )} + + )} + + ))} + +
+
+
+
+
+
+
+ setActiveLocation(location)} + className="w-full rounded border border-black/5 bg-white bg-clip-padding shadow-sm" + > + {({ open }) => { + return ( +
+
+ setQuery(e.target.value)} + className="w-full rounded-md border-none px-3 py-1 outline-none" + placeholder="Search users..." + /> +
+
+ + {locations.map((location) => ( + { + return classNames( + 'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9 focus:outline-none', + active ? 'bg-indigo-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {location} + + {active && ( + + + + + + )} + + )} + + ))} + +
+
+
+
+ ) + }} +
+
+
+
+ + + +
+ Form data (entries): +
{JSON.stringify([...result.entries()], null, 2)}
+
+
+
+ ) +} diff --git a/packages/playground-react/tsconfig.json b/packages/playground-react/tsconfig.json index 1563f3e..792b48c 100644 --- a/packages/playground-react/tsconfig.json +++ b/packages/playground-react/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", + "downlevelIteration": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve"