Add <form> 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 `<input type="hidden" name="..." value="...">` 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
<input type="hidden" name="person[]" value="Alice" />
<input type="hidden" name="person[]" value="Bob" />
<input type="hidden" name="person[]" value="Charlie" />
```

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
<input type="hidden" name="person[id]" value="1" />
<input type="hidden" name="person[name][first]" value="Jane" />
<input type="hidden" name="person[name][last]" value="Doe" />
```

* 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
This commit is contained in:
Robin Malfait
2022-03-09 11:24:45 +01:00
committed by GitHub
parent 2414bbd127
commit 7bb89871ba
30 changed files with 1953 additions and 66 deletions
+8
View File
@@ -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 `<form>` 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 `<form>` compatibility ([#1214](https://github.com/tailwindlabs/headlessui/pull/1214))
## [@headlessui/react@v1.5.0] - 2022-02-17
### Fixed
@@ -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 (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<Combobox value={value} onChange={setValue} name="delivery">
<Combobox.Input onChange={console.log} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Label>Pizza Delivery</Combobox.Label>
<Combobox.Options>
<Combobox.Option value="pickup">Pickup</Combobox.Option>
<Combobox.Option value="home-delivery">Home delivery</Combobox.Option>
<Combobox.Option value="dine-in">Dine in</Combobox.Option>
</Combobox.Options>
</Combobox>
<button>Submit</button>
</form>
)
}
render(<Example />)
// 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 (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<Combobox value={value} onChange={setValue} name="delivery">
<Combobox.Input onChange={console.log} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Label>Pizza Delivery</Combobox.Label>
<Combobox.Options>
{options.map((option) => (
<Combobox.Option key={option.id} value={option}>
{option.label}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
<button>Submit</button>
</form>
)
}
render(<Example />)
// 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'],
])
})
})
@@ -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<TTag, ComboboxRenderPropArg<TType>, 'value' | 'onChange' | 'disabled'> & {
props: Props<TTag, ComboboxRenderPropArg<TType>, 'value' | 'onChange' | 'disabled' | 'name'> & {
value: TType
onChange(value: TType): void
disabled?: boolean
__demoMode?: boolean
name?: string
},
ref: Ref<TTag>
) {
let { value, onChange, disabled = false, __demoMode = false, ...passThroughProps } = props
let { name, value, onChange, disabled = false, __demoMode = false, ...passThroughProps } = props
let comboboxPropsRef = useRef<StateDefinition['comboboxPropsRef']['current']>({
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 (
<ComboboxActions.Provider value={actionsBag}>
<ComboboxContext.Provider value={reducerBag}>
@@ -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]) => (
<VisuallyHidden
{...compact({
key: name,
as: 'input',
type: 'hidden',
hidden: true,
readOnly: true,
name,
value,
})}
/>
))}
{render(renderConfiguration)}
</>
) : (
render(renderConfiguration)
)}
</OpenClosedProvider>
</ComboboxContext.Provider>
</ComboboxActions.Provider>
@@ -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 (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<Listbox value={value} onChange={setValue} name="delivery">
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Label>Pizza Delivery</Listbox.Label>
<Listbox.Options>
<Listbox.Option value="pickup">Pickup</Listbox.Option>
<Listbox.Option value="home-delivery">Home delivery</Listbox.Option>
<Listbox.Option value="dine-in">Dine in</Listbox.Option>
</Listbox.Options>
</Listbox>
<button>Submit</button>
</form>
)
}
render(<Example />)
// 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 (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<Listbox value={value} onChange={setValue} name="delivery">
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Label>Pizza Delivery</Listbox.Label>
<Listbox.Options>
{options.map((option) => (
<Listbox.Option key={option.id} value={option}>
{option.label}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
<button>Submit</button>
</form>
)
}
render(<Example />)
// 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'],
])
})
})
@@ -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<TTag, ListboxRenderPropArg, 'value' | 'onChange'> & {
props: Props<
TTag,
ListboxRenderPropArg,
'value' | 'onChange' | 'disabled' | 'horizontal' | 'name'
> & {
value: TType
onChange(value: TType): void
disabled?: boolean
horizontal?: boolean
name?: string
},
ref: Ref<TTag>
) {
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 (
<ListboxContext.Provider value={reducerBag}>
<OpenClosedProvider
@@ -326,12 +340,26 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
[ListboxStates.Closed]: State.Closed,
})}
>
{render({
props: { ref: listboxRef, ...passThroughProps },
slot,
defaultTag: DEFAULT_LISTBOX_TAG,
name: 'Listbox',
})}
{name != null && value != null ? (
<>
{objectToFormEntries({ [name]: value }).map(([name, value]) => (
<VisuallyHidden
{...compact({
key: name,
as: 'input',
type: 'hidden',
hidden: true,
readOnly: true,
name,
value,
})}
/>
))}
{render(renderConfiguration)}
</>
) : (
render(renderConfiguration)
)}
</OpenClosedProvider>
</ListboxContext.Provider>
)
@@ -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 (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<RadioGroup value={value} onChange={setValue} name="delivery">
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
<RadioGroup.Option value="pickup">Pickup</RadioGroup.Option>
<RadioGroup.Option value="home-delivery">Home delivery</RadioGroup.Option>
<RadioGroup.Option value="dine-in">Dine in</RadioGroup.Option>
</RadioGroup>
<button>Submit</button>
</form>
)
}
render(<Example />)
// 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 (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<RadioGroup value={value} onChange={setValue} name="delivery">
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
{options.map((option) => (
<RadioGroup.Option key={option.id} value={option}>
{option.label}
</RadioGroup.Option>
))}
</RadioGroup>
<button>Submit</button>
</form>
)
}
render(<Example />)
// 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'],
])
})
})
@@ -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<HTMLElement>
) {
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 (
<DescriptionProvider name="RadioGroup.Description">
<LabelProvider name="RadioGroup.Label">
<RadioGroupContext.Provider value={api}>
{render({
props: { ...passThroughProps, ...propsWeControl },
defaultTag: DEFAULT_RADIO_GROUP_TAG,
name: 'RadioGroup',
})}
{name != null && value != null ? (
<>
{objectToFormEntries({ [name]: value }).map(([name, value]) => (
<VisuallyHidden
{...compact({
key: name,
as: 'input',
type: 'radio',
checked: value != null,
hidden: true,
readOnly: true,
name,
value,
})}
/>
))}
{render(renderConfiguration)}
</>
) : (
render(renderConfiguration)
)}
</RadioGroupContext.Provider>
</LabelProvider>
</DescriptionProvider>
@@ -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 (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<Switch.Group>
<Switch checked={state} onChange={setState} name="notifications" />
<Switch.Label>Enable notifications</Switch.Label>
</Switch.Group>
<button>Submit</button>
</form>
)
}
render(<Example />)
// 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 (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<Switch.Group>
<Switch checked={state} onChange={setState} name="fruit" value="apple" />
<Switch.Label>Apple</Switch.Label>
</Switch.Group>
<button>Submit</button>
</form>
)
}
render(<Example />)
// 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']])
})
})
@@ -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<TTag, SwitchRenderPropArg, SwitchPropsWeControl | 'checked' | 'onChange'> & {
props: Props<
TTag,
SwitchRenderPropArg,
SwitchPropsWeControl | 'checked' | 'onChange' | 'name' | 'value'
> & {
checked: boolean
onChange(checked: boolean): void
name?: string
value?: string
},
ref: Ref<HTMLElement>
) {
let { checked, onChange, ...passThroughProps } = props
let { checked, onChange, name, value, ...passThroughProps } = props
let id = `headlessui-switch-${useId()}`
let groupContext = useContext(GroupContext)
let internalSwitchRef = useRef<HTMLButtonElement | null>(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 (
<>
<VisuallyHidden
{...compact({
as: 'input',
type: 'checkbox',
hidden: true,
readOnly: true,
checked,
name,
value,
})}
/>
{render(renderConfiguration)}
</>
)
}
return render(renderConfiguration)
})
// ---
@@ -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<TTag>, ref: Ref<HTMLElement>) {
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',
})
})
@@ -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)
})
@@ -0,0 +1,37 @@
type Entries = [string, string][]
export function objectToFormEntries(
source: Record<string, any> = {},
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)
}
}
@@ -218,7 +218,7 @@ export function forwardRefWithAs<T extends { name: string; displayName?: string
})
}
function compact<T extends Record<any, any>>(object: T) {
export function compact<T extends Record<any, any>>(object: T) {
let clone = Object.assign({}, object)
for (let key in clone) {
if (clone[key] === undefined) delete clone[key]
+1 -1
View File
@@ -2,7 +2,7 @@
"include": ["src", "types"],
"compilerOptions": {
"module": "esnext",
"lib": ["dom", "esnext"],
"lib": ["dom", "esnext", "dom.iterable"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
@@ -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`
<form @submit="handleSubmit">
<Combobox v-model="value" name="delivery">
<ComboboxInput />
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxOptions>
<ComboboxOption value="pickup">Pickup</ComboboxOption>
<ComboboxOption value="home-delivery">Home delivery</ComboboxOption>
<ComboboxOption value="dine-in">Dine in</ComboboxOption>
</ComboboxOptions>
</Combobox>
<button>Submit</button>
</form>
`,
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`
<form @submit="handleSubmit">
<Combobox v-model="value" name="delivery">
<ComboboxButton>Trigger</ComboboxButton>
<ComboboxInput />
<ComboboxOptions>
<ComboboxOption v-for="option in options" :key="option.id" :value="option"
>{{option.label}}</ComboboxOption
>
</ComboboxOptions>
</Combobox>
<button>Submit</button>
</form>
`,
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'],
])
})
})
@@ -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<StateDefinition['comboboxState']['value']>(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)
}
},
})
@@ -1,8 +1,6 @@
import {
computed,
defineComponent,
onMounted,
onUnmounted,
ref,
// Types
@@ -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`
<form @submit="handleSubmit">
<Listbox v-model="value" name="delivery">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="pickup">Pickup</ListboxOption>
<ListboxOption value="home-delivery">Home delivery</ListboxOption>
<ListboxOption value="dine-in">Dine in</ListboxOption>
</ListboxOptions>
</Listbox>
<button>Submit</button>
</form>
`,
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`
<form @submit="handleSubmit">
<Listbox v-model="value" name="delivery">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption v-for="option in options" :key="option.id" :value="option"
>{{option.label}}</ListboxOption
>
</ListboxOptions>
</Listbox>
<button>Submit</button>
</form>
`,
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'],
])
})
})
@@ -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<StateDefinition['listboxState']['value']>(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)
}
},
})
@@ -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`
<form @submit="handleSubmit">
<RadioGroup v-model="deliveryMethod" name="delivery">
<RadioGroupLabel>Pizza Delivery</RadioGroupLabel>
<RadioGroupOption value="pickup">Pickup</RadioGroupOption>
<RadioGroupOption value="home-delivery">Home delivery</RadioGroupOption>
<RadioGroupOption value="dine-in">Dine in</RadioGroupOption>
</RadioGroup>
<button>Submit</button>
</form>
`,
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`
<form @submit="handleSubmit">
<RadioGroup v-model="deliveryMethod" name="delivery">
<RadioGroupLabel>Pizza Delivery</RadioGroupLabel>
<RadioGroupOption v-for="option in options" :key="option.id" :value="option"
>{{ option.label }}</RadioGroupOption
>
</RadioGroup>
<button>Submit</button>
</form>
`,
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'],
])
})
})
@@ -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<HTMLElement | null>(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)
}
},
})
@@ -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`
<form @submit="handleSubmit">
<SwitchGroup>
<Switch v-model="checked" name="notifications" />
<SwitchLabel>Enable notifications</SwitchLabel>
</SwitchGroup>
<button>Submit</button>
</form>
`,
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`
<form @submit="handleSubmit">
<SwitchGroup>
<Switch v-model="checked" name="fruit" value="apple" />
<SwitchLabel>Apple</SwitchLabel>
</SwitchGroup>
<button>Submit</button>
</form>
`,
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']])
})
})
@@ -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)
}
},
})
@@ -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',
})
}
},
})
@@ -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)
})
@@ -0,0 +1,37 @@
type Entries = [string, string][]
export function objectToFormEntries(
source: Record<string, any> = {},
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)
}
}
@@ -125,6 +125,14 @@ function _render({
return h(as, passThroughProps, children)
}
export function compact<T extends Record<any, any>>(object: T) {
let clone = Object.assign({}, object)
for (let key in clone) {
if (clone[key] === undefined) delete clone[key]
}
return clone
}
export function omit<T extends Record<any, any>, Keys extends keyof T>(
object: T,
keysToOmit: readonly Keys[] = []
+1 -1
View File
@@ -2,7 +2,7 @@
"include": ["src", "types"],
"compilerOptions": {
"module": "esnext",
"lib": ["dom", "esnext"],
"lib": ["dom", "esnext", "dom.iterable"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
@@ -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 (
<fieldset className="rounded-lg border bg-gray-200/20 p-3">
<legend className="rounded-md border bg-gray-100 px-2 text-sm uppercase">{title}</legend>
<div className="flex flex-col gap-3">{children}</div>
</fieldset>
)
}
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 (
<div className="py-8">
<form
className="mx-auto flex h-full max-w-4xl flex-col items-start justify-center gap-8 rounded-lg border bg-white p-6"
onSubmit={(event) => {
event.preventDefault()
setResult(new FormData(event.currentTarget))
}}
>
<div className="grid w-full grid-cols-[repeat(auto-fill,minmax(350px,1fr))] items-start gap-3">
<Section title="Switch">
<Section title="Single value">
<Switch.Group as="div" className="flex items-center justify-between space-x-4">
<Switch.Label>Enable notifications</Switch.Label>
<Switch
as="button"
checked={notifications}
onChange={setNotifications}
name="notifications"
className={({ checked }) =>
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 }) => (
<>
<span
className={classNames(
'inline-block h-5 w-5 transform rounded-full bg-white transition duration-200 ease-in-out',
checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
</>
)}
</Switch>
</Switch.Group>
</Section>
<Section title="Multiple values">
<Switch.Group as="div" className="flex items-center justify-between space-x-4">
<Switch.Label>Apple</Switch.Label>
<Switch
as="button"
checked={apple}
onChange={setApple}
name="fruit[]"
value="apple"
className={({ checked }) =>
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 }) => (
<>
<span
className={classNames(
'inline-block h-5 w-5 transform rounded-full bg-white transition duration-200 ease-in-out',
checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
</>
)}
</Switch>
</Switch.Group>
<Switch.Group as="div" className="flex items-center justify-between space-x-4">
<Switch.Label>Banana</Switch.Label>
<Switch
as="button"
checked={banana}
onChange={setBanana}
name="fruit[]"
value="banana"
className={({ checked }) =>
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 }) => (
<>
<span
className={classNames(
'inline-block h-5 w-5 transform rounded-full bg-white transition duration-200 ease-in-out',
checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
</>
)}
</Switch>
</Switch.Group>
</Section>
</Section>
<Section title="Radio Group">
<RadioGroup value={size} onChange={setSize} name="size">
<div className="flex -space-x-px rounded-md bg-white">
{sizes.map((size) => {
return (
<RadioGroup.Option
key={size}
value={size}
className={({ active }) =>
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 }) => (
<div className="flex w-full items-center justify-between">
<div className="ml-3 flex cursor-pointer flex-col">
<span
className={classNames(
'block text-sm font-medium leading-5',
active ? 'text-indigo-900' : 'text-gray-900'
)}
>
{size}
</span>
</div>
<div>
{checked && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="h-5 w-5 text-indigo-500"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</div>
</div>
)}
</RadioGroup.Option>
)
})}
</div>
</RadioGroup>
</Section>
<Section title="Listbox">
<div className="w-full space-y-1">
<Listbox value={person} onChange={setPerson} name="person">
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
<span className="block truncate">{person.name.first}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>
</span>
<div className="absolute mt-1 w-full rounded-md bg-white shadow-lg">
<Listbox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
{people.map((person) => (
<Listbox.Option
key={person.id}
value={person}
className={({ active }) => {
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 }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{person.name.first}
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</div>
</Listbox>
</div>
</Section>
<Section title="Combobox">
<div className="w-full space-y-1">
<Combobox
as="div"
name="location"
value={activeLocation}
onChange={(location) => setActiveLocation(location)}
className="w-full rounded border border-black/5 bg-white bg-clip-padding shadow-sm"
>
{({ open }) => {
return (
<div className="relative">
<div className="flex w-full flex-col">
<Combobox.Input
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border-none px-3 py-1 outline-none"
placeholder="Search users..."
/>
<div
className={classNames(
'flex border-t',
activeLocation && !open ? 'border-transparent' : 'border-gray-200'
)}
>
<div className="absolute mt-1 w-full rounded-md bg-white shadow-lg">
<Combobox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
{locations.map((location) => (
<Combobox.Option
key={location}
value={location}
className={({ active }) => {
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 }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{location}
</span>
{active && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="h-5 w-5" viewBox="0 0 25 24" fill="none">
<path
d="M11.25 8.75L14.75 12L11.25 15.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</div>
</div>
</div>
)
}}
</Combobox>
</div>
</Section>
</div>
<button className="focus:shadow-outline-blue rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
Submit
</button>
<div className="w-full border-t py-4">
<span>Form data (entries):</span>
<pre className="text-sm">{JSON.stringify([...result.entries()], null, 2)}</pre>
</div>
</form>
</div>
)
}
+1
View File
@@ -11,6 +11,7 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"downlevelIteration": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"