Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add <form> compatibility #1214

Merged
merged 12 commits into from
Mar 9, 2022
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