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:
@@ -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]
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"downlevelIteration": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
|
||||
Reference in New Issue
Block a user