Skip to content

Commit c475cab

Browse files
RobinMalfaitarlyon
andauthored
Allow Enter for form submit in RadioGroup, Switch and Combobox 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]>
1 parent 6897d2c commit c475cab

File tree

20 files changed

+579
-311
lines changed

20 files changed

+579
-311
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434
- Fix incorrect closing while interacting with third party libraries in `Dialog` component ([#1268](https://github.com/tailwindlabs/headlessui/pull/1268))
3535
- Mimic browser select on focus when navigating via `Tab` ([#1272](https://github.com/tailwindlabs/headlessui/pull/1272))
3636
- 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))
37+
- Allow `Enter` for form submit in `RadioGroup`, `Switch` and `Combobox` improvements ([#1285](https://github.com/tailwindlabs/headlessui/pull/1285))
3738

3839
### Added
3940

@@ -70,6 +71,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7071
- Mimic browser select on focus when navigating via `Tab` ([#1272](https://github.com/tailwindlabs/headlessui/pull/1272))
7172
- Resolve `initialFocusRef` correctly ([#1276](https://github.com/tailwindlabs/headlessui/pull/1276))
7273
- 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))
74+
- Allow `Enter` for form submit in `RadioGroup`, `Switch` and `Combobox` improvements ([#1285](https://github.com/tailwindlabs/headlessui/pull/1285))
7375

7476
### Added
7577

packages/@headlessui-react/src/components/combobox/combobox.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,6 +1792,56 @@ describe('Keyboard interactions', () => {
17921792
assertActiveComboboxOption(getComboboxOptions()[0])
17931793
})
17941794
)
1795+
1796+
it(
1797+
'should submit the form on `Enter`',
1798+
suppressConsoleLogs(async () => {
1799+
let submits = jest.fn()
1800+
1801+
function Example() {
1802+
let [value, setValue] = useState<string>('b')
1803+
1804+
return (
1805+
<form
1806+
onKeyUp={(event) => {
1807+
// JSDom doesn't automatically submit the form but if we can
1808+
// catch an `Enter` event, we can assume it was a submit.
1809+
if (event.key === 'Enter') event.currentTarget.submit()
1810+
}}
1811+
onSubmit={(event) => {
1812+
event.preventDefault()
1813+
submits([...new FormData(event.currentTarget).entries()])
1814+
}}
1815+
>
1816+
<Combobox value={value} onChange={setValue} name="option">
1817+
<Combobox.Input onChange={NOOP} />
1818+
<Combobox.Button>Trigger</Combobox.Button>
1819+
<Combobox.Options>
1820+
<Combobox.Option value="a">Option A</Combobox.Option>
1821+
<Combobox.Option value="b">Option B</Combobox.Option>
1822+
<Combobox.Option value="c">Option C</Combobox.Option>
1823+
</Combobox.Options>
1824+
</Combobox>
1825+
1826+
<button>Submit</button>
1827+
</form>
1828+
)
1829+
}
1830+
1831+
render(<Example />)
1832+
1833+
// Focus the input field
1834+
getComboboxInput()?.focus()
1835+
assertActiveElement(getComboboxInput())
1836+
1837+
// Press enter (which should submit the form)
1838+
await press(Keys.Enter)
1839+
1840+
// Verify the form was submitted
1841+
expect(submits).toHaveBeenCalledTimes(1)
1842+
expect(submits).toHaveBeenCalledWith([['option', 'b']])
1843+
})
1844+
)
17951845
})
17961846

17971847
describe('`Tab` key', () => {

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -534,14 +534,6 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
534534
useIsoMorphicEffect(syncInputValue, [syncInputValue])
535535
let ourProps = ref === null ? {} : { ref }
536536

537-
let renderConfiguration = {
538-
ourProps,
539-
theirProps,
540-
slot,
541-
defaultTag: DEFAULT_COMBOBOX_TAG,
542-
name: 'Combobox',
543-
}
544-
545537
return (
546538
<ComboboxActions.Provider value={actionsBag}>
547539
<ComboboxData.Provider value={dataBag}>
@@ -552,26 +544,28 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
552544
[ComboboxStates.Closed]: State.Closed,
553545
})}
554546
>
555-
{name != null && value != null ? (
556-
<>
557-
{objectToFormEntries({ [name]: value }).map(([name, value]) => (
558-
<VisuallyHidden
559-
{...compact({
560-
key: name,
561-
as: 'input',
562-
type: 'hidden',
563-
hidden: true,
564-
readOnly: true,
565-
name,
566-
value,
567-
})}
568-
/>
569-
))}
570-
{render(renderConfiguration)}
571-
</>
572-
) : (
573-
render(renderConfiguration)
574-
)}
547+
{name != null &&
548+
value != null &&
549+
objectToFormEntries({ [name]: value }).map(([name, value]) => (
550+
<VisuallyHidden
551+
{...compact({
552+
key: name,
553+
as: 'input',
554+
type: 'hidden',
555+
hidden: true,
556+
readOnly: true,
557+
name,
558+
value,
559+
})}
560+
/>
561+
))}
562+
{render({
563+
ourProps,
564+
theirProps,
565+
slot,
566+
defaultTag: DEFAULT_COMBOBOX_TAG,
567+
name: 'Combobox',
568+
})}
575569
</OpenClosedProvider>
576570
</ComboboxContext.Provider>
577571
</ComboboxData.Provider>
@@ -632,9 +626,16 @@ let Input = forwardRefWithAs(function Input<
632626
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
633627

634628
case Keys.Enter:
629+
if (state.comboboxState !== ComboboxStates.Open) return
630+
635631
event.preventDefault()
636632
event.stopPropagation()
637633

634+
if (data.activeOptionIndex === null) {
635+
actions.closeCombobox()
636+
return
637+
}
638+
638639
actions.selectActiveOption()
639640
if (data.mode === ValueMode.Single) {
640641
actions.closeCombobox()

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -384,14 +384,6 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
384384

385385
let ourProps = { ref: listboxRef }
386386

387-
let renderConfiguration = {
388-
ourProps,
389-
theirProps,
390-
slot,
391-
defaultTag: DEFAULT_LISTBOX_TAG,
392-
name: 'Listbox',
393-
}
394-
395387
return (
396388
<ListboxContext.Provider value={reducerBag}>
397389
<OpenClosedProvider
@@ -400,26 +392,22 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
400392
[ListboxStates.Closed]: State.Closed,
401393
})}
402394
>
403-
{name != null && value != null ? (
404-
<>
405-
{objectToFormEntries({ [name]: value }).map(([name, value]) => (
406-
<VisuallyHidden
407-
{...compact({
408-
key: name,
409-
as: 'input',
410-
type: 'hidden',
411-
hidden: true,
412-
readOnly: true,
413-
name,
414-
value,
415-
})}
416-
/>
417-
))}
418-
{render(renderConfiguration)}
419-
</>
420-
) : (
421-
render(renderConfiguration)
422-
)}
395+
{name != null &&
396+
value != null &&
397+
objectToFormEntries({ [name]: value }).map(([name, value]) => (
398+
<VisuallyHidden
399+
{...compact({
400+
key: name,
401+
as: 'input',
402+
type: 'hidden',
403+
hidden: true,
404+
readOnly: true,
405+
name,
406+
value,
407+
})}
408+
/>
409+
))}
410+
{render({ ourProps, theirProps, slot, defaultTag: DEFAULT_LISTBOX_TAG, name: 'Listbox' })}
423411
</OpenClosedProvider>
424412
</ListboxContext.Provider>
425413
)

packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,44 @@ describe('Keyboard interactions', () => {
780780
expect(changeFn).toHaveBeenNthCalledWith(1, 'pickup')
781781
})
782782
})
783+
784+
describe('`Enter`', () => {
785+
it('should submit the form on `Enter`', async () => {
786+
let submits = jest.fn()
787+
788+
function Example() {
789+
let [value, setValue] = useState('bob')
790+
791+
return (
792+
<form
793+
onSubmit={(event) => {
794+
event.preventDefault()
795+
submits([...new FormData(event.currentTarget).entries()])
796+
}}
797+
>
798+
<RadioGroup value={value} onChange={setValue} name="option">
799+
<RadioGroup.Option value="alice">Alice</RadioGroup.Option>
800+
<RadioGroup.Option value="bob">Bob</RadioGroup.Option>
801+
<RadioGroup.Option value="charlie">Charlie</RadioGroup.Option>
802+
</RadioGroup>
803+
<button>Submit</button>
804+
</form>
805+
)
806+
}
807+
808+
render(<Example />)
809+
810+
// Focus the RadioGroup
811+
await press(Keys.Tab)
812+
813+
// Press enter (which should submit the form)
814+
await press(Keys.Enter)
815+
816+
// Verify the form was submitted
817+
expect(submits).toHaveBeenCalledTimes(1)
818+
expect(submits).toHaveBeenCalledWith([['option', 'bob']])
819+
})
820+
})
783821
})
784822

785823
describe('Mouse interactions', () => {

packages/@headlessui-react/src/components/radio-group/radio-group.tsx

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { Description, useDescriptions } from '../../components/description/descr
2727
import { useTreeWalker } from '../../hooks/use-tree-walker'
2828
import { useSyncRefs } from '../../hooks/use-sync-refs'
2929
import { VisuallyHidden } from '../../internal/visually-hidden'
30-
import { objectToFormEntries } from '../../utils/form'
30+
import { attemptSubmit, objectToFormEntries } from '../../utils/form'
3131
import { getOwnerDocument } from '../../utils/owner'
3232

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

184184
switch (event.key) {
185+
case Keys.Enter:
186+
attemptSubmit(event.currentTarget)
187+
break
185188
case Keys.ArrowLeft:
186189
case Keys.ArrowUp:
187190
{
@@ -261,38 +264,32 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
261264
onKeyDown: handleKeyDown,
262265
}
263266

264-
let renderConfiguration = {
265-
ourProps,
266-
theirProps,
267-
defaultTag: DEFAULT_RADIO_GROUP_TAG,
268-
name: 'RadioGroup',
269-
}
270-
271267
return (
272268
<DescriptionProvider name="RadioGroup.Description">
273269
<LabelProvider name="RadioGroup.Label">
274270
<RadioGroupContext.Provider value={api}>
275-
{name != null && value != null ? (
276-
<>
277-
{objectToFormEntries({ [name]: value }).map(([name, value]) => (
278-
<VisuallyHidden
279-
{...compact({
280-
key: name,
281-
as: 'input',
282-
type: 'radio',
283-
checked: value != null,
284-
hidden: true,
285-
readOnly: true,
286-
name,
287-
value,
288-
})}
289-
/>
290-
))}
291-
{render(renderConfiguration)}
292-
</>
293-
) : (
294-
render(renderConfiguration)
295-
)}
271+
{name != null &&
272+
value != null &&
273+
objectToFormEntries({ [name]: value }).map(([name, value]) => (
274+
<VisuallyHidden
275+
{...compact({
276+
key: name,
277+
as: 'input',
278+
type: 'radio',
279+
checked: value != null,
280+
hidden: true,
281+
readOnly: true,
282+
name,
283+
value,
284+
})}
285+
/>
286+
))}
287+
{render({
288+
ourProps,
289+
theirProps,
290+
defaultTag: DEFAULT_RADIO_GROUP_TAG,
291+
name: 'RadioGroup',
292+
})}
296293
</RadioGroupContext.Provider>
297294
</LabelProvider>
298295
</DescriptionProvider>

packages/@headlessui-react/src/components/switch/switch.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,39 @@ describe('Keyboard interactions', () => {
261261

262262
expect(handleChange).not.toHaveBeenCalled()
263263
})
264+
265+
it('should submit the form on `Enter`', async () => {
266+
let submits = jest.fn()
267+
268+
function Example() {
269+
let [value, setValue] = useState(true)
270+
271+
return (
272+
<form
273+
onSubmit={(event) => {
274+
event.preventDefault()
275+
submits([...new FormData(event.currentTarget).entries()])
276+
}}
277+
>
278+
<Switch checked={value} onChange={setValue} name="option" />
279+
<button>Submit</button>
280+
</form>
281+
)
282+
}
283+
284+
render(<Example />)
285+
286+
// Focus the input field
287+
getSwitch()?.focus()
288+
assertActiveElement(getSwitch())
289+
290+
// Press enter (which should submit the form)
291+
await press(Keys.Enter)
292+
293+
// Verify the form was submitted
294+
expect(submits).toHaveBeenCalledTimes(1)
295+
expect(submits).toHaveBeenCalledWith([['option', 'on']])
296+
})
264297
})
265298

266299
describe('`Tab` key', () => {

0 commit comments

Comments
 (0)