Skip to content

Commit

Permalink
Allow Enter for form submit in RadioGroup, Switch and `Combobox…
Browse files Browse the repository at this point in the history
…` improvements (#1285)

* improve rendering of hidden form fields

* add `attemptSubmit` helper

This will allow us to _try_ and submit a form based on any element you
pass it. It will try and lookup the current form and if it is
submittable it will attempt to submit it.

Instead of submitting the form directly, we try to follow the native
browser support where it looks for the first `input[type=submit]`,
`input[type=image]`, `button` or `button[type=submit]`, then it clicks
it.

This allows you to disable your submit button, or have an `onClick` that
does an `event.preventDefault()` just like the native form in a browser
would do.

* ensure we can submit a form from a closed Combobox

When the Combobox is closed, then the `Enter` keydown event will be
ignored and thus not use `event.preventDefault()`.

With recent changes where we always have an active option, it means that
you will always be able to select an option.

If we have no option at all (some edge case) or when the combobox is
closed, then the `Enter` keydown event will just bubble, allowing you to
submit a form.

Fixes: #1282

This is a continuation of a PR ([#1176](#1176)) provided by Alexander, so wanted to include
them as a co-author because of their initial work.

Co-authored-by: Alexander Lyon <[email protected]>

* ensure we can submit a form from a RadioGroup

* ensure we can submit a form from a Switch

* simplify / refactor form playground example

* update changelog

Co-authored-by: Alexander Lyon <[email protected]>
  • Loading branch information
RobinMalfait and arlyon authored Mar 31, 2022
1 parent 6897d2c commit c475cab
Show file tree
Hide file tree
Showing 20 changed files with 579 additions and 311 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix incorrect closing while interacting with third party libraries in `Dialog` component ([#1268](https://github.com/tailwindlabs/headlessui/pull/1268))
- Mimic browser select on focus when navigating via `Tab` ([#1272](https://github.com/tailwindlabs/headlessui/pull/1272))
- Ensure that there is always an active option in the `Combobox` ([#1279](https://github.com/tailwindlabs/headlessui/pull/1279), [#1281](https://github.com/tailwindlabs/headlessui/pull/1281))
- Allow `Enter` for form submit in `RadioGroup`, `Switch` and `Combobox` improvements ([#1285](https://github.com/tailwindlabs/headlessui/pull/1285))

### Added

Expand Down Expand Up @@ -70,6 +71,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Mimic browser select on focus when navigating via `Tab` ([#1272](https://github.com/tailwindlabs/headlessui/pull/1272))
- Resolve `initialFocusRef` correctly ([#1276](https://github.com/tailwindlabs/headlessui/pull/1276))
- Ensure that there is always an active option in the `Combobox` ([#1279](https://github.com/tailwindlabs/headlessui/pull/1279), [#1281](https://github.com/tailwindlabs/headlessui/pull/1281))
- Allow `Enter` for form submit in `RadioGroup`, `Switch` and `Combobox` improvements ([#1285](https://github.com/tailwindlabs/headlessui/pull/1285))

### Added

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1792,6 +1792,56 @@ describe('Keyboard interactions', () => {
assertActiveComboboxOption(getComboboxOptions()[0])
})
)

it(
'should submit the form on `Enter`',
suppressConsoleLogs(async () => {
let submits = jest.fn()

function Example() {
let [value, setValue] = useState<string>('b')

return (
<form
onKeyUp={(event) => {
// JSDom doesn't automatically submit the form but if we can
// catch an `Enter` event, we can assume it was a submit.
if (event.key === 'Enter') event.currentTarget.submit()
}}
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<Combobox value={value} onChange={setValue} name="option">
<Combobox.Input onChange={NOOP} />
<Combobox.Button>Trigger</Combobox.Button>
<Combobox.Options>
<Combobox.Option value="a">Option A</Combobox.Option>
<Combobox.Option value="b">Option B</Combobox.Option>
<Combobox.Option value="c">Option C</Combobox.Option>
</Combobox.Options>
</Combobox>

<button>Submit</button>
</form>
)
}

render(<Example />)

// Focus the input field
getComboboxInput()?.focus()
assertActiveElement(getComboboxInput())

// Press enter (which should submit the form)
await press(Keys.Enter)

// Verify the form was submitted
expect(submits).toHaveBeenCalledTimes(1)
expect(submits).toHaveBeenCalledWith([['option', 'b']])
})
)
})

describe('`Tab` key', () => {
Expand Down
57 changes: 29 additions & 28 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -534,14 +534,6 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
useIsoMorphicEffect(syncInputValue, [syncInputValue])
let ourProps = ref === null ? {} : { ref }

let renderConfiguration = {
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_COMBOBOX_TAG,
name: 'Combobox',
}

return (
<ComboboxActions.Provider value={actionsBag}>
<ComboboxData.Provider value={dataBag}>
Expand All @@ -552,26 +544,28 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
[ComboboxStates.Closed]: State.Closed,
})}
>
{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)
)}
{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({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_COMBOBOX_TAG,
name: 'Combobox',
})}
</OpenClosedProvider>
</ComboboxContext.Provider>
</ComboboxData.Provider>
Expand Down Expand Up @@ -632,9 +626,16 @@ let Input = forwardRefWithAs(function Input<
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12

case Keys.Enter:
if (state.comboboxState !== ComboboxStates.Open) return

event.preventDefault()
event.stopPropagation()

if (data.activeOptionIndex === null) {
actions.closeCombobox()
return
}

actions.selectActiveOption()
if (data.mode === ValueMode.Single) {
actions.closeCombobox()
Expand Down
44 changes: 16 additions & 28 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,14 +384,6 @@ let ListboxRoot = forwardRefWithAs(function Listbox<

let ourProps = { ref: listboxRef }

let renderConfiguration = {
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_LISTBOX_TAG,
name: 'Listbox',
}

return (
<ListboxContext.Provider value={reducerBag}>
<OpenClosedProvider
Expand All @@ -400,26 +392,22 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
[ListboxStates.Closed]: State.Closed,
})}
>
{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)
)}
{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({ ourProps, theirProps, slot, defaultTag: DEFAULT_LISTBOX_TAG, name: 'Listbox' })}
</OpenClosedProvider>
</ListboxContext.Provider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,44 @@ describe('Keyboard interactions', () => {
expect(changeFn).toHaveBeenNthCalledWith(1, 'pickup')
})
})

describe('`Enter`', () => {
it('should submit the form on `Enter`', async () => {
let submits = jest.fn()

function Example() {
let [value, setValue] = useState('bob')

return (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<RadioGroup value={value} onChange={setValue} name="option">
<RadioGroup.Option value="alice">Alice</RadioGroup.Option>
<RadioGroup.Option value="bob">Bob</RadioGroup.Option>
<RadioGroup.Option value="charlie">Charlie</RadioGroup.Option>
</RadioGroup>
<button>Submit</button>
</form>
)
}

render(<Example />)

// Focus the RadioGroup
await press(Keys.Tab)

// Press enter (which should submit the form)
await press(Keys.Enter)

// Verify the form was submitted
expect(submits).toHaveBeenCalledTimes(1)
expect(submits).toHaveBeenCalledWith([['option', 'bob']])
})
})
})

describe('Mouse interactions', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { Description, useDescriptions } from '../../components/description/descr
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'
import { attemptSubmit, objectToFormEntries } from '../../utils/form'
import { getOwnerDocument } from '../../utils/owner'

interface Option {
Expand Down Expand Up @@ -182,6 +182,9 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
.map((radio) => radio.element.current) as HTMLElement[]

switch (event.key) {
case Keys.Enter:
attemptSubmit(event.currentTarget)
break
case Keys.ArrowLeft:
case Keys.ArrowUp:
{
Expand Down Expand Up @@ -261,38 +264,32 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
onKeyDown: handleKeyDown,
}

let renderConfiguration = {
ourProps,
theirProps,
defaultTag: DEFAULT_RADIO_GROUP_TAG,
name: 'RadioGroup',
}

return (
<DescriptionProvider name="RadioGroup.Description">
<LabelProvider name="RadioGroup.Label">
<RadioGroupContext.Provider value={api}>
{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)
)}
{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({
ourProps,
theirProps,
defaultTag: DEFAULT_RADIO_GROUP_TAG,
name: 'RadioGroup',
})}
</RadioGroupContext.Provider>
</LabelProvider>
</DescriptionProvider>
Expand Down
33 changes: 33 additions & 0 deletions packages/@headlessui-react/src/components/switch/switch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,39 @@ describe('Keyboard interactions', () => {

expect(handleChange).not.toHaveBeenCalled()
})

it('should submit the form on `Enter`', async () => {
let submits = jest.fn()

function Example() {
let [value, setValue] = useState(true)

return (
<form
onSubmit={(event) => {
event.preventDefault()
submits([...new FormData(event.currentTarget).entries()])
}}
>
<Switch checked={value} onChange={setValue} name="option" />
<button>Submit</button>
</form>
)
}

render(<Example />)

// Focus the input field
getSwitch()?.focus()
assertActiveElement(getSwitch())

// Press enter (which should submit the form)
await press(Keys.Enter)

// Verify the form was submitted
expect(submits).toHaveBeenCalledTimes(1)
expect(submits).toHaveBeenCalledWith([['option', 'on']])
})
})

describe('`Tab` key', () => {
Expand Down
Loading

0 comments on commit c475cab

Please sign in to comment.