Skip to content

Commit

Permalink
Add <form> compatibility (#1214)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
RobinMalfait authored Mar 9, 2022
1 parent 2414bbd commit 7bb8987
Show file tree
Hide file tree
Showing 30 changed files with 1,953 additions and 66 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
166 changes: 166 additions & 0 deletions packages/@headlessui-react/src/components/combobox/combobox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
])
})
})
42 changes: 33 additions & 9 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}>
Expand All @@ -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>
Expand Down
Loading

0 comments on commit 7bb8987

Please sign in to comment.