diff --git a/CHANGELOG.md b/CHANGELOG.md index 209d7f6c7a..ee8261a380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index b5a2d704eb..ce295d8e21 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -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('b') + + return ( +
{ + // 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()]) + }} + > + + + Trigger + + Option A + Option B + Option C + + + + +
+ ) + } + + render() + + // 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', () => { diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index d277298456..897a89723e 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -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 ( @@ -552,26 +544,28 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< [ComboboxStates.Closed]: State.Closed, })} > - {name != null && value != null ? ( - <> - {objectToFormEntries({ [name]: value }).map(([name, value]) => ( - - ))} - {render(renderConfiguration)} - - ) : ( - render(renderConfiguration) - )} + {name != null && + value != null && + objectToFormEntries({ [name]: value }).map(([name, value]) => ( + + ))} + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_COMBOBOX_TAG, + name: 'Combobox', + })} @@ -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() diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 7e3938bd0d..f832b15267 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -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 ( - {name != null && value != null ? ( - <> - {objectToFormEntries({ [name]: value }).map(([name, value]) => ( - - ))} - {render(renderConfiguration)} - - ) : ( - render(renderConfiguration) - )} + {name != null && + value != null && + objectToFormEntries({ [name]: value }).map(([name, value]) => ( + + ))} + {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_LISTBOX_TAG, name: 'Listbox' })} ) diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx index 494fce3753..0aee104afc 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx @@ -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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + Alice + Bob + Charlie + + +
+ ) + } + + render() + + // 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', () => { diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx index c244a29e57..2628a5ac17 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -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 { @@ -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: { @@ -261,38 +264,32 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< onKeyDown: handleKeyDown, } - let renderConfiguration = { - ourProps, - theirProps, - defaultTag: DEFAULT_RADIO_GROUP_TAG, - name: 'RadioGroup', - } - return ( - {name != null && value != null ? ( - <> - {objectToFormEntries({ [name]: value }).map(([name, value]) => ( - - ))} - {render(renderConfiguration)} - - ) : ( - render(renderConfiguration) - )} + {name != null && + value != null && + objectToFormEntries({ [name]: value }).map(([name, value]) => ( + + ))} + {render({ + ourProps, + theirProps, + defaultTag: DEFAULT_RADIO_GROUP_TAG, + name: 'RadioGroup', + })} diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx index aff2ebe7b4..28cd20b8fd 100644 --- a/packages/@headlessui-react/src/components/switch/switch.test.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.test.tsx @@ -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 ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + + + ) + } + + render() + + // 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', () => { diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index 2ada6c9e28..b2a17843ee 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -24,6 +24,7 @@ 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' +import { attemptSubmit } from '../../utils/form' interface StateDefinition { switch: HTMLButtonElement | null @@ -130,9 +131,13 @@ let SwitchRoot = forwardRefWithAs(function Switch< [toggle] ) let handleKeyUp = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key !== Keys.Tab) event.preventDefault() - if (event.key === Keys.Space) toggle() + (event: ReactKeyboardEvent) => { + if (event.key === Keys.Space) { + event.preventDefault() + toggle() + } else if (event.key === Keys.Enter) { + attemptSubmit(event.currentTarget) + } }, [toggle] ) @@ -158,17 +163,9 @@ let SwitchRoot = forwardRefWithAs(function Switch< onKeyPress: handleKeyPress, } - let renderConfiguration = { - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_SWITCH_TAG, - name: 'Switch', - } - - if (name != null && checked) { - return ( - <> + return ( + <> + {name != null && checked && ( - {render(renderConfiguration)} - - ) - } - - return render(renderConfiguration) + )} + {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_SWITCH_TAG, name: 'Switch' })} + + ) }) // --- diff --git a/packages/@headlessui-react/src/internal/visually-hidden.tsx b/packages/@headlessui-react/src/internal/visually-hidden.tsx index 8ae0fb5d8f..e3a0b2bbb6 100644 --- a/packages/@headlessui-react/src/internal/visually-hidden.tsx +++ b/packages/@headlessui-react/src/internal/visually-hidden.tsx @@ -20,6 +20,7 @@ export let VisuallyHidden = forwardRefWithAs(function VisuallyHidden< clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', borderWidth: '0', + display: 'none', }, } diff --git a/packages/@headlessui-react/src/utils/form.ts b/packages/@headlessui-react/src/utils/form.ts index 730b67529e..739b7e9e3c 100644 --- a/packages/@headlessui-react/src/utils/form.ts +++ b/packages/@headlessui-react/src/utils/form.ts @@ -35,3 +35,23 @@ function append(entries: Entries, key: string, value: any): void { objectToFormEntries(value, key, entries) } } + +export function attemptSubmit(element: HTMLElement) { + let form = (element as any)?.form ?? element.closest('form') + if (!form) return + + for (let element of form.elements) { + if ( + (element.tagName === 'INPUT' && element.type === 'submit') || + (element.tagName === 'BUTTON' && element.type === 'submit') || + (element.nodeName === 'INPUT' && element.type === 'image') + ) { + // If you press `enter` in a normal input[type='text'] field, then the form will submit by + // searching for the a submit element and "click" it. We could also use the + // `form.requestSubmit()` function, but this has a downside where an `event.preventDefault()` + // inside a `click` listener on the submit button won't stop the form from submitting. + element.click() + return + } + } +} diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 427fcaef11..ceee161d50 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -1950,6 +1950,57 @@ describe('Keyboard interactions', () => { assertActiveComboboxOption(getComboboxOptions()[0]) }) ) + + it( + 'should submit the form on `Enter`', + suppressConsoleLogs(async () => { + let submits = jest.fn() + + renderTemplate({ + template: html` +
+ + + Trigger + + Option A + Option B + Option C + + + + +
+ `, + setup() { + let value = ref('b') + return { + value, + handleKeyUp(event: KeyboardEvent) { + // 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 as HTMLFormElement).submit() + }, + handleSubmit(event: SubmitEvent) { + event.preventDefault() + submits([...new FormData(event.currentTarget as HTMLFormElement).entries()]) + }, + } + }, + }) + + // 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', () => { diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index d807388b7a..6391c9086e 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -418,35 +418,31 @@ export let Combobox = defineComponent({ activeOption: activeOption.value, } - let renderConfiguration = { - props: omit(incomingProps, ['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, - }) + return h(Fragment, [ + ...(name != null && modelValue != null + ? 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) + : []), + render({ + props: omit(incomingProps, ['onUpdate:modelValue']), + slot, + slots, + attrs, + name: 'Combobox', + }), + ]) } }, }) @@ -620,9 +616,16 @@ export let ComboboxInput = defineComponent({ // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 case Keys.Enter: + if (api.comboboxState.value !== ComboboxStates.Open) return + event.preventDefault() event.stopPropagation() + if (api.activeOptionIndex.value === null) { + api.closeCombobox() + return + } + api.selectActiveOption() if (api.mode.value === ValueMode.Single) { api.closeCombobox() diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index aa774ed125..4df5973bf4 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -308,35 +308,32 @@ export let Listbox = defineComponent({ let { name, modelValue, disabled, ...incomingProps } = props let slot = { open: listboxState.value === ListboxStates.Open, disabled } - let renderConfiguration = { - props: omit(incomingProps, ['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, - }) + return h(Fragment, [ + ...(name != null && modelValue != null + ? 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) + : []), + render({ + props: omit(incomingProps, ['onUpdate:modelValue', 'horizontal']), + slot, + slots, + attrs, + name: 'Listbox', + }), + ]) } }, }) diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts index b2c15a940a..f0573610fa 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts @@ -1075,6 +1075,45 @@ describe('Keyboard interactions', () => { expect(changeFn).toHaveBeenNthCalledWith(1, 'pickup') }) }) + + describe('`Enter`', () => { + it('should submit the form on `Enter`', async () => { + let submits = jest.fn() + + renderTemplate({ + template: html` +
+ + Alice + Bob + Charlie + + +
+ `, + setup() { + let value = ref('bob') + return { + value, + handleSubmit(event: KeyboardEvent) { + event.preventDefault() + submits([...new FormData(event.currentTarget as HTMLFormElement).entries()]) + }, + } + }, + }) + + // 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', () => { diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts index d7a16ae004..2ca9bff452 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -24,7 +24,7 @@ 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' +import { attemptSubmit, objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' interface Option { @@ -72,6 +72,7 @@ export let RadioGroup = defineComponent({ modelValue: { type: [Object, String, Number, Boolean] }, name: { type: String, optional: true }, }, + inheritAttrs: false, setup(props, { emit, attrs, slots, expose }) { let radioGroupRef = ref(null) let options = ref([]) @@ -140,6 +141,9 @@ export let RadioGroup = defineComponent({ .map((radio) => radio.element) as HTMLElement[] switch (event.key) { + case Keys.Enter: + attemptSubmit(event.currentTarget as unknown as EventTarget & HTMLButtonElement) + break case Keys.ArrowLeft: case Keys.ArrowUp: { @@ -202,35 +206,31 @@ export let RadioGroup = defineComponent({ onKeydown: handleKeyDown, } - let renderConfiguration = { - props: { ...incomingProps, ...ourProps }, - 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, - }) + return h(Fragment, [ + ...(name != null && modelValue != null + ? 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) + : []), + render({ + props: { ...attrs, ...incomingProps, ...ourProps }, + slot: {}, + attrs, + slots, + name: 'RadioGroup', + }), + ]) } }, }) diff --git a/packages/@headlessui-vue/src/components/switch/switch.test.tsx b/packages/@headlessui-vue/src/components/switch/switch.test.tsx index 9ee10fbf9b..67e83416de 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.test.tsx +++ b/packages/@headlessui-vue/src/components/switch/switch.test.tsx @@ -391,6 +391,39 @@ describe('Keyboard interactions', () => { expect(handleChange).not.toHaveBeenCalled() }) + + it('should submit the form on `Enter`', async () => { + let submits = jest.fn() + renderTemplate({ + template: html` +
+ + + + `, + setup() { + let checked = ref(true) + return { + checked, + handleSubmit(event: KeyboardEvent) { + event.preventDefault() + submits([...new FormData(event.currentTarget as HTMLFormElement).entries()]) + }, + } + }, + }) + + // 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', () => { diff --git a/packages/@headlessui-vue/src/components/switch/switch.ts b/packages/@headlessui-vue/src/components/switch/switch.ts index 07925dd062..d64c0b2d3e 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.ts +++ b/packages/@headlessui-vue/src/components/switch/switch.ts @@ -19,6 +19,7 @@ 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' +import { attemptSubmit } from '../../utils/form' type StateDefinition = { // State @@ -69,7 +70,7 @@ export let Switch = defineComponent({ name: { type: String, optional: true }, value: { type: String, optional: true }, }, - + inheritAttrs: false, setup(props, { emit, attrs, slots, expose }) { let api = inject(GroupContext, null) let id = `headlessui-switch-${useId()}` @@ -93,8 +94,12 @@ export let Switch = defineComponent({ } function handleKeyUp(event: KeyboardEvent) { - if (event.key !== Keys.Tab) event.preventDefault() - if (event.key === Keys.Space) toggle() + if (event.key === Keys.Space) { + event.preventDefault() + toggle() + } else if (event.key === Keys.Enter) { + attemptSubmit(event.currentTarget) + } } // This is needed so that we can "cancel" the click event when we use the `Enter` key on a button. @@ -119,33 +124,29 @@ export let Switch = defineComponent({ onKeypress: handleKeyPress, } - let renderConfiguration = { - props: { ...incomingProps, ...ourProps }, - 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) + return h(Fragment, [ + name != null && modelValue != null + ? h( + VisuallyHidden, + compact({ + as: 'input', + type: 'checkbox', + hidden: true, + readOnly: true, + checked: modelValue, + name, + value, + }) + ) + : null, + render({ + props: { ...attrs, ...incomingProps, ...ourProps }, + slot, + attrs, + slots, + name: 'Switch', + }), + ]) } }, }) diff --git a/packages/@headlessui-vue/src/internal/visually-hidden.ts b/packages/@headlessui-vue/src/internal/visually-hidden.ts index cf7498d196..d4268fe8a9 100644 --- a/packages/@headlessui-vue/src/internal/visually-hidden.ts +++ b/packages/@headlessui-vue/src/internal/visually-hidden.ts @@ -19,6 +19,7 @@ export let VisuallyHidden = defineComponent({ clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', borderWidth: '0', + display: 'none', }, } diff --git a/packages/@headlessui-vue/src/utils/form.ts b/packages/@headlessui-vue/src/utils/form.ts index 730b67529e..739b7e9e3c 100644 --- a/packages/@headlessui-vue/src/utils/form.ts +++ b/packages/@headlessui-vue/src/utils/form.ts @@ -35,3 +35,23 @@ function append(entries: Entries, key: string, value: any): void { objectToFormEntries(value, key, entries) } } + +export function attemptSubmit(element: HTMLElement) { + let form = (element as any)?.form ?? element.closest('form') + if (!form) return + + for (let element of form.elements) { + if ( + (element.tagName === 'INPUT' && element.type === 'submit') || + (element.tagName === 'BUTTON' && element.type === 'submit') || + (element.nodeName === 'INPUT' && element.type === 'image') + ) { + // If you press `enter` in a normal input[type='text'] field, then the form will submit by + // searching for the a submit element and "click" it. We could also use the + // `form.requestSubmit()` function, but this has a downside where an `event.preventDefault()` + // inside a `click` listener on the submit button won't stop the form from submitting. + element.click() + return + } + } +} diff --git a/packages/playground-react/pages/combinations/form.tsx b/packages/playground-react/pages/combinations/form.tsx index 4b726f1782..3c8fcb5265 100644 --- a/packages/playground-react/pages/combinations/form.tsx +++ b/packages/playground-react/pages/combinations/form.tsx @@ -24,11 +24,9 @@ export default function App() { 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 [size, setSize] = useState(sizes[0]) + let [person, setPerson] = useState(people[0]) + let [activeLocation, setActiveLocation] = useState(locations[0]) let [query, setQuery] = useState('') return ( @@ -47,26 +45,23 @@ export default function App() { Enable notifications 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' + 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2', + checked ? 'bg-blue-600' : 'bg-gray-200' ) } > {({ checked }) => ( - <> - - + )} @@ -77,27 +72,24 @@ export default function App() { Apple 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' + 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2', + checked ? 'bg-blue-600' : 'bg-gray-200' ) } > {({ checked }) => ( - <> - - + )} @@ -105,27 +97,24 @@ export default function App() { Banana 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' + 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2', + checked ? 'bg-blue-600' : 'bg-gray-200' ) } > {({ checked }) => ( - <> - - + )} @@ -141,8 +130,8 @@ export default function App() { 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' + 'relative flex w-20 border px-2 py-4 first:rounded-l-md last:rounded-r-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2', + active ? 'z-10 border-blue-200 bg-blue-50' : 'border-gray-200' ) } > @@ -152,7 +141,7 @@ export default function App() { {size} @@ -165,7 +154,7 @@ export default function App() { fill="none" viewBox="0 0 24 24" stroke="currentColor" - className="h-5 w-5 text-indigo-500" + className="h-5 w-5 text-blue-500" >
- + {person.name.first} -
+
{people.map((person) => ( { 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' + 'relative cursor-default select-none py-2 pl-3 pr-9 ', + active ? 'bg-blue-600 text-white' : 'text-gray-900' ) }} > @@ -236,7 +225,7 @@ export default function App() { @@ -261,11 +250,12 @@ export default function App() {
setActiveLocation(location)} - className="w-full rounded border border-black/5 bg-white bg-clip-padding shadow-sm" + onChange={(location) => { + setActiveLocation(location) + setQuery('') + }} > {({ open }) => { return ( @@ -273,7 +263,7 @@ export default function App() {
setQuery(e.target.value)} - className="w-full rounded-md border-none px-3 py-1 outline-none" + className="w-full rounded-md rounded border-gray-300 bg-clip-padding px-3 py-1 shadow-sm focus:border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" placeholder="Search users..." />
-
- - {locations.map((location) => ( - { - 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 }) => ( - <> - - {location} - - {active && ( +
+ + {locations + .filter((location) => + location.toLowerCase().includes(query.toLowerCase()) + ) + .map((location) => ( + { + return classNames( + 'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9 ', + active ? 'bg-blue-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> - - - + {location} - )} - - )} - - ))} + {active && ( + + + + + + )} + + )} + + ))}
@@ -339,7 +337,7 @@ export default function App() {
-