Skip to content

Commit 320c0b4

Browse files
authored
Fix keyboard form submission (#904)
1 parent 8e44d5a commit 320c0b4

File tree

6 files changed

+145
-35
lines changed

6 files changed

+145
-35
lines changed

packages/@react-aria/button/src/useButton.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,7 @@ export function useButton(props: AriaButtonProps<ElementType>, ref: RefObject<an
9696
if (allowFocusWhenDisabled) {
9797
focusableProps.tabIndex = isDisabled ? -1 : focusableProps.tabIndex;
9898
}
99-
let buttonProps = mergeProps(focusableProps, pressProps);
100-
buttonProps = mergeProps(buttonProps, filterDOMProps(props, {labelable: true}));
99+
let buttonProps = mergeProps(focusableProps, pressProps, filterDOMProps(props, {labelable: true}));
101100

102101
return {
103102
isPressed, // Used to indicate press state for visual

packages/@react-aria/checkbox/test/useCheckboxGroup.test.tsx

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {act, render} from '@testing-library/react';
1314
import {AriaCheckboxGroupItemProps, AriaCheckboxGroupProps} from '@react-types/checkbox';
1415
import {CheckboxGroupState, useCheckboxGroupState} from '@react-stately/checkbox';
1516
import React, {useRef} from 'react';
16-
import {render} from '@testing-library/react';
1717
import {useCheckboxGroup, useCheckboxGroupItem} from '../';
1818
import userEvent from '@testing-library/user-event';
1919

2020
function Checkbox({checkboxGroupState, ...props}: AriaCheckboxGroupItemProps & { checkboxGroupState: CheckboxGroupState }) {
2121
const ref = useRef<HTMLInputElement>();
2222
const {children} = props;
2323
const {inputProps} = useCheckboxGroupItem(props, checkboxGroupState, ref);
24-
return <label>{children}<input ref={ref} {...inputProps} /></label>;
24+
return <label><input ref={ref} {...inputProps} />{children}</label>;
2525
}
2626

2727
function CheckboxGroup({groupProps, checkboxProps}: {groupProps: AriaCheckboxGroupProps, checkboxProps: AriaCheckboxGroupItemProps[]}) {
@@ -63,18 +63,18 @@ describe('useCheckboxGroup', () => {
6363
expect(checkboxes[1].value).toBe('cats');
6464
expect(checkboxes[2].value).toBe('dragons');
6565

66-
expect(checkboxes[0].checked).toBe(false);
67-
expect(checkboxes[1].checked).toBe(false);
68-
expect(checkboxes[2].checked).toBe(false);
66+
expect(checkboxes[0].checked).toBeFalsy();
67+
expect(checkboxes[1].checked).toBeFalsy();
68+
expect(checkboxes[2].checked).toBeFalsy();
6969

7070
let dragons = getByLabelText('Dragons');
7171
userEvent.click(dragons);
7272
expect(onChangeSpy).toHaveBeenCalledTimes(1);
7373
expect(onChangeSpy).toHaveBeenCalledWith(['dragons']);
7474

75-
expect(checkboxes[0].checked).toBe(false);
76-
expect(checkboxes[1].checked).toBe(false);
77-
expect(checkboxes[2].checked).toBe(true);
75+
expect(checkboxes[0].checked).toBeFalsy();
76+
expect(checkboxes[1].checked).toBeFalsy();
77+
expect(checkboxes[2].checked).toBeTruthy();
7878
});
7979

8080
it('can have a default value', () => {
@@ -159,13 +159,15 @@ describe('useCheckboxGroup', () => {
159159
});
160160

161161
it('sets aria-disabled and makes checkboxes disabled when isDisabled is true', () => {
162-
let {getAllByRole, getByRole} = render(
162+
let groupOnChangeSpy = jest.fn();
163+
let checkboxOnChangeSpy = jest.fn();
164+
let {getAllByRole, getByRole, getByLabelText} = render(
163165
<CheckboxGroup
164-
groupProps={{label: 'Favorite Pet', isDisabled: true}}
166+
groupProps={{label: 'Favorite Pet', isDisabled: true, onChange: groupOnChangeSpy}}
165167
checkboxProps={[
166168
{value: 'dogs', children: 'Dogs'},
167169
{value: 'cats', children: 'Cats'},
168-
{value: 'dragons', children: 'Dragons'}
170+
{value: 'dragons', children: 'Dragons', onChange: checkboxOnChangeSpy}
169171
]} />
170172
);
171173

@@ -174,8 +176,15 @@ describe('useCheckboxGroup', () => {
174176

175177
let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
176178
expect(checkboxes[0]).toHaveAttribute('disabled');
177-
expect(checkboxes[0]).toHaveAttribute('disabled');
178-
expect(checkboxes[0]).toHaveAttribute('disabled');
179+
expect(checkboxes[1]).toHaveAttribute('disabled');
180+
expect(checkboxes[2]).toHaveAttribute('disabled');
181+
let dragons = getByLabelText('Dragons');
182+
183+
act(() => {userEvent.click(dragons);});
184+
185+
expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
186+
expect(checkboxOnChangeSpy).toHaveBeenCalledTimes(0);
187+
expect(checkboxes[2].checked).toBeFalsy();
179188
});
180189

181190
it('doesn\'t set aria-disabled or make checkboxes disabled by default', () => {
@@ -194,8 +203,8 @@ describe('useCheckboxGroup', () => {
194203

195204
let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
196205
expect(checkboxes[0]).not.toHaveAttribute('disabled');
197-
expect(checkboxes[0]).not.toHaveAttribute('disabled');
198-
expect(checkboxes[0]).not.toHaveAttribute('disabled');
206+
expect(checkboxes[1]).not.toHaveAttribute('disabled');
207+
expect(checkboxes[2]).not.toHaveAttribute('disabled');
199208
});
200209

201210
it('doesn\'t set aria-disabled or make checkboxes disabled when isDisabled is false', () => {
@@ -214,25 +223,35 @@ describe('useCheckboxGroup', () => {
214223

215224
let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
216225
expect(checkboxes[0]).not.toHaveAttribute('disabled');
217-
expect(checkboxes[0]).not.toHaveAttribute('disabled');
218-
expect(checkboxes[0]).not.toHaveAttribute('disabled');
226+
expect(checkboxes[1]).not.toHaveAttribute('disabled');
227+
expect(checkboxes[2]).not.toHaveAttribute('disabled');
219228
});
220229

221230
it('sets aria-readonly="true" on each checkbox', () => {
222-
let {getAllByRole} = render(
231+
let groupOnChangeSpy = jest.fn();
232+
let checkboxOnChangeSpy = jest.fn();
233+
let {getAllByRole, getByLabelText} = render(
223234
<CheckboxGroup
224-
groupProps={{label: 'Favorite Pet', isReadOnly: true}}
235+
groupProps={{label: 'Favorite Pet', isReadOnly: true, onChange: groupOnChangeSpy}}
225236
checkboxProps={[
226237
{value: 'dogs', children: 'Dogs'},
227238
{value: 'cats', children: 'Cats'},
228-
{value: 'dragons', children: 'Dragons'}
239+
{value: 'dragons', children: 'Dragons', onChange: checkboxOnChangeSpy}
229240
]} />
230241
);
231242

232243
let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
233244
expect(checkboxes[0]).toHaveAttribute('aria-readonly', 'true');
234245
expect(checkboxes[1]).toHaveAttribute('aria-readonly', 'true');
235246
expect(checkboxes[2]).toHaveAttribute('aria-readonly', 'true');
247+
expect(checkboxes[2].checked).toBeFalsy();
248+
let dragons = getByLabelText('Dragons');
249+
250+
act(() => {userEvent.click(dragons);});
251+
252+
expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
253+
expect(checkboxOnChangeSpy).toHaveBeenCalledTimes(0);
254+
expect(checkboxes[2].checked).toBeFalsy();
236255
});
237256

238257
it('should not update state for readonly checkbox', () => {
@@ -255,6 +274,6 @@ describe('useCheckboxGroup', () => {
255274

256275
expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
257276
expect(checkboxOnChangeSpy).toHaveBeenCalledTimes(0);
258-
expect(checkboxes[2].checked).toBe(false);
277+
expect(checkboxes[2].checked).toBeFalsy();
259278
});
260279
});

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export function usePress(props: PressHookProps): PressResult {
103103
shouldCancelOnPointerExit,
104104
allowTextSelectionOnPress,
105105
// eslint-disable-next-line @typescript-eslint/no-unused-vars
106-
ref: _, // Removing `ref` from `domProps` because TypeScript is dumb,
106+
ref: _, // Removing `ref` from `domProps` because TypeScript is dumb
107107
...domProps
108108
} = usePressResponderContext(props);
109109
let propsRef = useRef<PressHookProps>(null);
@@ -229,7 +229,9 @@ export function usePress(props: PressHookProps): PressResult {
229229
let pressProps: HTMLAttributes<HTMLElement> = {
230230
onKeyDown(e) {
231231
if (isValidKeyboardEvent(e.nativeEvent) && e.currentTarget.contains(e.target as HTMLElement)) {
232-
e.preventDefault();
232+
if (shouldPreventDefaultKeyboard(e.target as Element)) {
233+
e.preventDefault();
234+
}
233235
e.stopPropagation();
234236

235237
// If the event is repeating, it may have started on a different element
@@ -283,7 +285,9 @@ export function usePress(props: PressHookProps): PressResult {
283285

284286
let onKeyUp = (e: KeyboardEvent) => {
285287
if (state.isPressed && isValidKeyboardEvent(e)) {
286-
e.preventDefault();
288+
if (shouldPreventDefaultKeyboard(e.target as Element)) {
289+
e.preventDefault();
290+
}
287291
e.stopPropagation();
288292

289293
state.isPressed = false;
@@ -765,6 +769,10 @@ function shouldPreventDefault(target: Element) {
765769
return !target.closest('[draggable="true"]');
766770
}
767771

772+
function shouldPreventDefaultKeyboard(target: Element) {
773+
return !((target.tagName === 'INPUT' || target.tagName === 'BUTTON') && (target as HTMLButtonElement | HTMLInputElement).type === 'submit');
774+
}
775+
768776
function isVirtualPointerEvent(event: PointerEvent) {
769777
// If the pointer size is zero, then we assume it's from a screen reader.
770778
// Android TalkBack double tap will sometimes return a event with width and height of 1

packages/@react-spectrum/button/test/Button.test.js

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
*/
1212

1313
import {ActionButton, Button, ClearButton, LogicButton} from '../';
14+
import {Checkbox, defaultTheme} from '@adobe/react-spectrum';
1415
import {fireEvent, render} from '@testing-library/react';
16+
import {Form} from '@react-spectrum/form';
17+
import {Provider} from '@react-spectrum/provider';
1518
import React from 'react';
1619
import {triggerPress} from '@react-spectrum/test-utils';
1720

@@ -188,13 +191,6 @@ describe('Button', function () {
188191
expect(onPressSpy).not.toHaveBeenCalled();
189192
});
190193

191-
// when a user uses the keyboard and keyDowns 'enter' or 'space' on a button, it fires an onclick.
192-
// when code dispatches a keyDown for 'enter' or 'space', it does not fire onclick
193-
// this means that it's impossible for us to write a test for the 'button' elementType for keyDown 'enter' or 'space'
194-
// see https://jsfiddle.net/snowystinger/z6vmrw4d/1/
195-
// it's also extraneous to test with 'enter' or 'space' on a button because it'd just be testing
196-
// the spec https://www.w3.org/TR/WCAG20-TECHS/SCR35.html
197-
198194
it.each`
199195
Name | Component
200196
${'ActionButton'} | ${ActionButton}
@@ -206,4 +202,78 @@ describe('Button', function () {
206202
let button = getByRole('button');
207203
expect(document.activeElement).toBe(button);
208204
});
205+
206+
207+
it('prevents default for non-submit types', function () {
208+
let eventDown;
209+
let eventUp;
210+
let btn = React.createRef();
211+
let {getByRole} = render(
212+
<Provider theme={defaultTheme}>
213+
<Form>
214+
<Checkbox>An Input</Checkbox>
215+
<Button variant="primary" ref={btn}>Click Me</Button>
216+
</Form>
217+
</Provider>
218+
);
219+
// need to attach event listeners after instead of directly on Button because the ones directly on Button
220+
// will fire before the usePress ones
221+
btn.current.UNSAFE_getDOMNode().addEventListener('keydown', e => eventDown = e);
222+
btn.current.UNSAFE_getDOMNode().addEventListener('keyup', e => eventUp = e);
223+
224+
let button = getByRole('button');
225+
fireEvent.keyDown(button, {key: 'Enter'});
226+
fireEvent.keyUp(button, {key: 'Enter'});
227+
expect(eventDown.defaultPrevented).toBeTruthy();
228+
expect(eventUp.defaultPrevented).toBeTruthy();
229+
230+
fireEvent.keyDown(button, {key: ' '});
231+
fireEvent.keyUp(button, {key: ' '});
232+
expect(eventDown.defaultPrevented).toBeTruthy();
233+
expect(eventUp.defaultPrevented).toBeTruthy();
234+
});
235+
236+
// we only need to test that we allow the browser to do the default thing when space or enter is pressed on a submit button
237+
// space submits on key up and is actually a click
238+
it('submit in form using space', function () {
239+
let eventUp;
240+
let btn = React.createRef();
241+
let {getByRole} = render(
242+
<Provider theme={defaultTheme}>
243+
<Form>
244+
<Checkbox>An Input</Checkbox>
245+
<Button variant="primary" type="submit" ref={btn}>Click Me</Button>
246+
</Form>
247+
</Provider>
248+
);
249+
btn.current.UNSAFE_getDOMNode().addEventListener('keyup', e => eventUp = e);
250+
251+
let button = getByRole('button');
252+
fireEvent.keyDown(button, {key: ' '});
253+
fireEvent.keyUp(button, {key: ' '});
254+
expect(eventUp.defaultPrevented).toBeFalsy();
255+
});
256+
257+
// enter submits on keydown
258+
it('submit in form using enter', function () {
259+
let eventDown;
260+
let btn = React.createRef();
261+
let {getByRole} = render(
262+
<Provider theme={defaultTheme}>
263+
<Form>
264+
<Checkbox>An Input</Checkbox>
265+
<Button variant="primary" type="submit" ref={btn}>Click Me</Button>
266+
</Form>
267+
</Provider>
268+
);
269+
btn.current.UNSAFE_getDOMNode().addEventListener('keydown', e => eventDown = e);
270+
271+
let button = getByRole('button');
272+
fireEvent.keyDown(button, {key: 'Enter'});
273+
fireEvent.keyUp(button, {key: 'Enter'});
274+
expect(eventDown.defaultPrevented).toBeFalsy();
275+
});
276+
277+
278+
// 'implicit submission' can't be tested https://github.com/testing-library/react-testing-library/issues/487
209279
});

packages/@react-spectrum/checkbox/test/Checkbox.test.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {act, render} from '@testing-library/react';
1314
import {Checkbox} from '../';
1415
import React from 'react';
15-
import {render} from '@testing-library/react';
1616
import userEvent from '@testing-library/user-event';
1717

1818

@@ -233,4 +233,18 @@ describe('Checkbox', function () {
233233
expect(checkbox.checked).toBeTruthy();
234234
expect(onChangeSpy).not.toHaveBeenCalled();
235235
});
236+
237+
it.each`
238+
Name | Component | props
239+
${'Checkbox'} | ${Checkbox} | ${{onChange: onChangeSpy, isReadOnly: true}}
240+
`('$Name supports uncontrolled readOnly', function ({Component, props}) {
241+
let {getByLabelText} = render(<Component {...props}>Click Me</Component>);
242+
243+
let checkbox = getByLabelText('Click Me');
244+
expect(checkbox.checked).toBeFalsy();
245+
246+
act(() => {userEvent.click(checkbox);});
247+
expect(checkbox.checked).toBeFalsy();
248+
expect(onChangeSpy).not.toHaveBeenCalled();
249+
});
236250
});

packages/@react-spectrum/form/stories/Form.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ function FormWithControls(props: any = {}) {
273273

274274
return (
275275
<Flex>
276-
<Checkbox isSelected={preventDefault} onChange={setPreventDefault}>Prevent Default onSubmit</Checkbox>
276+
<Checkbox alignSelf="start" isSelected={preventDefault} onChange={setPreventDefault}>Prevent Default onSubmit</Checkbox>
277277
<Form
278278
onSubmit={e => {
279279
action('onSubmit')(e);

0 commit comments

Comments
 (0)