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

Fix keyboard form submission #904

Merged
merged 49 commits into from
Dec 23, 2021
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
cf7b32f
Fix keyboard form submission
snowystinger Aug 7, 2020
b62208f
fix lint
snowystinger Aug 7, 2020
358507d
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Aug 7, 2020
0085d46
Update packages/@react-aria/interactions/src/usePress.ts
snowystinger Aug 7, 2020
7138b64
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Aug 7, 2020
daa71c1
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Aug 7, 2020
da53bed
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Aug 10, 2020
8ee1766
Don't perform a second valid key check
snowystinger Aug 11, 2020
0490a2f
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Aug 11, 2020
39abf9e
These are the only spots I know of so far where we should allow defau…
snowystinger Aug 13, 2020
41daff9
Remove unused function
snowystinger Aug 13, 2020
1a05474
add a code comment/docs description
snowystinger Aug 13, 2020
36255ad
Fix reversed logic
snowystinger Aug 13, 2020
94fcf3e
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Aug 13, 2020
948a721
Add tests and handle clicking ourselves
snowystinger Aug 14, 2020
54e4608
fix lint
snowystinger Aug 14, 2020
2e51740
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Aug 14, 2020
7357538
default preventDefault onclick, but allow a prop that can allow it so…
snowystinger Aug 14, 2020
19433e7
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Sep 9, 2020
13be6cf
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Sep 9, 2020
2789f98
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Sep 10, 2020
7719fc1
fix useCheckboxGroup test
snowystinger Sep 11, 2020
513a508
Add another test and explain why the allowClickDefault is needed
snowystinger Sep 11, 2020
af106c1
fix lint
snowystinger Sep 11, 2020
791e544
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Sep 21, 2020
9e9e553
Merge main into fix-keyboard-form-submission
snowystinger Apr 7, 2021
c88543c
Remove potentially extraneous code
snowystinger Apr 7, 2021
30b5cad
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Apr 26, 2021
64f7a63
reduce number of changed lines
snowystinger Apr 26, 2021
913e28a
Merge branch 'main' into fix-keyboard-form-submission
snowystinger May 3, 2021
44b7e61
Merge branch 'main' into fix-keyboard-form-submission
LFDanLu May 26, 2021
62a7877
Merge branch 'main' into fix-keyboard-form-submission
snowystinger May 26, 2021
b45e1c6
Merge branch 'main' into fix-keyboard-form-submission
snowystinger May 28, 2021
87b3722
[WIP] - Make form submission fix in usePress instead of in useButton …
snowystinger Jun 26, 2021
3fef03a
Add check for element type
snowystinger Jun 26, 2021
99e572f
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Jun 26, 2021
1e1d03d
fix default buttons
snowystinger Jun 30, 2021
feec335
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Jun 30, 2021
c243261
add node version to cache since icons depends on it
snowystinger Jun 30, 2021
a0ee5f1
fix typescript and remove cache busting
snowystinger Jun 30, 2021
2beebf1
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Jul 13, 2021
83a2505
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Jul 14, 2021
851dc21
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Sep 1, 2021
bfb6ce3
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Oct 5, 2021
1249260
Merge branch 'main' into fix-keyboard-form-submission
LFDanLu Oct 12, 2021
c9005be
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Oct 27, 2021
d327f0b
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Dec 14, 2021
be3d7ce
Merge branch 'main' into fix-keyboard-form-submission
snowystinger Dec 23, 2021
b99f1f3
Merge branch 'main' into fix-keyboard-form-submission
devongovett Dec 23, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/@react-aria/button/src/useButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ export function useButton(props: AriaButtonProps<ElementType>, ref: RefObject<an
});

let {focusableProps} = useFocusable(props, ref);
let buttonProps = mergeProps(focusableProps, pressProps);
buttonProps = mergeProps(buttonProps, filterDOMProps(props, {labelable: true}));
let buttonProps = mergeProps(focusableProps, pressProps, filterDOMProps(props, {labelable: true}));

return {
isPressed, // Used to indicate press state for visual
Expand Down
61 changes: 40 additions & 21 deletions packages/@react-aria/checkbox/test/useCheckboxGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@
* governing permissions and limitations under the License.
*/

import {act, render} from '@testing-library/react';
import {AriaCheckboxGroupItemProps, AriaCheckboxGroupProps} from '@react-types/checkbox';
import {CheckboxGroupState, useCheckboxGroupState} from '@react-stately/checkbox';
import React, {useRef} from 'react';
import {render} from '@testing-library/react';
import {useCheckboxGroup, useCheckboxGroupItem} from '../';
import userEvent from '@testing-library/user-event';

function Checkbox({checkboxGroupState, ...props}: AriaCheckboxGroupItemProps & { checkboxGroupState: CheckboxGroupState }) {
const ref = useRef<HTMLInputElement>();
const {children} = props;
const {inputProps} = useCheckboxGroupItem(props, checkboxGroupState, ref);
return <label>{children}<input ref={ref} {...inputProps} /></label>;
return <label><input ref={ref} {...inputProps} />{children}</label>;
}

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

expect(checkboxes[0].checked).toBe(false);
expect(checkboxes[1].checked).toBe(false);
expect(checkboxes[2].checked).toBe(false);
expect(checkboxes[0].checked).toBeFalsy();
expect(checkboxes[1].checked).toBeFalsy();
expect(checkboxes[2].checked).toBeFalsy();

let dragons = getByLabelText('Dragons');
userEvent.click(dragons);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
expect(onChangeSpy).toHaveBeenCalledWith(['dragons']);

expect(checkboxes[0].checked).toBe(false);
expect(checkboxes[1].checked).toBe(false);
expect(checkboxes[2].checked).toBe(true);
expect(checkboxes[0].checked).toBeFalsy();
expect(checkboxes[1].checked).toBeFalsy();
expect(checkboxes[2].checked).toBeTruthy();
});

it('can have a default value', () => {
Expand Down Expand Up @@ -159,13 +159,15 @@ describe('useCheckboxGroup', () => {
});

it('sets aria-disabled and makes checkboxes disabled when isDisabled is true', () => {
let {getAllByRole, getByRole} = render(
let groupOnChangeSpy = jest.fn();
let checkboxOnChangeSpy = jest.fn();
let {getAllByRole, getByRole, getByLabelText} = render(
<CheckboxGroup
groupProps={{label: 'Favorite Pet', isDisabled: true}}
groupProps={{label: 'Favorite Pet', isDisabled: true, onChange: groupOnChangeSpy}}
checkboxProps={[
{value: 'dogs', children: 'Dogs'},
{value: 'cats', children: 'Cats'},
{value: 'dragons', children: 'Dragons'}
{value: 'dragons', children: 'Dragons', onChange: checkboxOnChangeSpy}
]} />
);

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

let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
expect(checkboxes[0]).toHaveAttribute('disabled');
expect(checkboxes[0]).toHaveAttribute('disabled');
expect(checkboxes[0]).toHaveAttribute('disabled');
expect(checkboxes[1]).toHaveAttribute('disabled');
expect(checkboxes[2]).toHaveAttribute('disabled');
let dragons = getByLabelText('Dragons');

act(() => {userEvent.click(dragons);});

expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
expect(checkboxOnChangeSpy).toHaveBeenCalledTimes(0);
expect(checkboxes[2].checked).toBeFalsy();
});

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

let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[1]).not.toHaveAttribute('disabled');
expect(checkboxes[2]).not.toHaveAttribute('disabled');
});

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

let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[1]).not.toHaveAttribute('disabled');
expect(checkboxes[2]).not.toHaveAttribute('disabled');
});

it('sets aria-readonly="true" on each checkbox', () => {
let {getAllByRole} = render(
let groupOnChangeSpy = jest.fn();
let checkboxOnChangeSpy = jest.fn();
let {getAllByRole, getByLabelText} = render(
<CheckboxGroup
groupProps={{label: 'Favorite Pet', isReadOnly: true}}
groupProps={{label: 'Favorite Pet', isReadOnly: true, onChange: groupOnChangeSpy}}
checkboxProps={[
{value: 'dogs', children: 'Dogs'},
{value: 'cats', children: 'Cats'},
{value: 'dragons', children: 'Dragons'}
{value: 'dragons', children: 'Dragons', onChange: checkboxOnChangeSpy}
]} />
);

let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
expect(checkboxes[0]).toHaveAttribute('aria-readonly', 'true');
expect(checkboxes[1]).toHaveAttribute('aria-readonly', 'true');
expect(checkboxes[2]).toHaveAttribute('aria-readonly', 'true');
expect(checkboxes[2].checked).toBeFalsy();
let dragons = getByLabelText('Dragons');

act(() => {userEvent.click(dragons);});

expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
expect(checkboxOnChangeSpy).toHaveBeenCalledTimes(0);
expect(checkboxes[2].checked).toBeFalsy();
});

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

expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
expect(checkboxOnChangeSpy).toHaveBeenCalledTimes(0);
expect(checkboxes[2].checked).toBe(false);
expect(checkboxes[2].checked).toBeFalsy();
});
});
14 changes: 11 additions & 3 deletions packages/@react-aria/interactions/src/usePress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function usePress(props: PressHookProps): PressResult {
preventFocusOnPress,
shouldCancelOnPointerExit,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ref: _, // Removing `ref` from `domProps` because TypeScript is dumb,
ref: _, // Removing `ref` from `domProps` because TypeScript is dumb
...domProps
} = usePressResponderContext(props);
let propsRef = useRef<PressHookProps>(null);
Expand Down Expand Up @@ -224,7 +224,9 @@ export function usePress(props: PressHookProps): PressResult {
let pressProps: HTMLAttributes<HTMLElement> = {
onKeyDown(e) {
if (isValidKeyboardEvent(e.nativeEvent) && e.currentTarget.contains(e.target as HTMLElement)) {
e.preventDefault();
if (shouldPreventDefaultKeyboard(e.target as Element)) {
e.preventDefault();
}
e.stopPropagation();

// If the event is repeating, it may have started on a different element
Expand Down Expand Up @@ -278,7 +280,9 @@ export function usePress(props: PressHookProps): PressResult {

let onKeyUp = (e: KeyboardEvent) => {
if (state.isPressed && isValidKeyboardEvent(e)) {
e.preventDefault();
if (shouldPreventDefaultKeyboard(e.target as Element)) {
e.preventDefault();
}
e.stopPropagation();

state.isPressed = false;
Expand Down Expand Up @@ -737,6 +741,10 @@ function shouldPreventDefault(target: Element) {
return !target.closest('[draggable="true"]');
}

function shouldPreventDefaultKeyboard(target: Element) {
return !((target.tagName === 'INPUT' || target.tagName === 'BUTTON') && (target as HTMLButtonElement | HTMLInputElement).type === 'submit');
}

function isVirtualPointerEvent(event: PointerEvent) {
// If the pointer size is zero, then we assume it's from a screen reader.
return event.width === 0 && event.height === 0;
Expand Down
84 changes: 77 additions & 7 deletions packages/@react-spectrum/button/test/Button.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
*/

import {ActionButton, Button, ClearButton, LogicButton} from '../';
import {Checkbox, defaultTheme} from '@adobe/react-spectrum';
import {fireEvent, render} from '@testing-library/react';
import {Form} from '@react-spectrum/form';
import {Provider} from '@react-spectrum/provider';
import React from 'react';
import {triggerPress} from '@react-spectrum/test-utils';
import V2Button from '@react/react-spectrum/Button';
Expand Down Expand Up @@ -201,13 +204,6 @@ describe('Button', function () {
expect(onPressSpy).not.toHaveBeenCalled();
});

// when a user uses the keyboard and keyDowns 'enter' or 'space' on a button, it fires an onclick.
// when code dispatches a keyDown for 'enter' or 'space', it does not fire onclick
// this means that it's impossible for us to write a test for the 'button' elementType for keyDown 'enter' or 'space'
// see https://jsfiddle.net/snowystinger/z6vmrw4d/1/
// it's also extraneous to test with 'enter' or 'space' on a button because it'd just be testing
// the spec https://www.w3.org/TR/WCAG20-TECHS/SCR35.html

it.each`
Name | Component
${'ActionButton'} | ${ActionButton}
Expand All @@ -219,4 +215,78 @@ describe('Button', function () {
let button = getByRole('button');
expect(document.activeElement).toBe(button);
});


it('prevents default for non-submit types', function () {
let eventDown;
let eventUp;
let btn = React.createRef();
let {getByRole} = render(
<Provider theme={defaultTheme}>
<Form>
<Checkbox>An Input</Checkbox>
<Button variant="primary" ref={btn}>Click Me</Button>
</Form>
</Provider>
);
// need to attach event listeners after instead of directly on Button because the ones directly on Button
// will fire before the usePress ones
btn.current.UNSAFE_getDOMNode().addEventListener('keydown', e => eventDown = e);
btn.current.UNSAFE_getDOMNode().addEventListener('keyup', e => eventUp = e);

let button = getByRole('button');
fireEvent.keyDown(button, {key: 'Enter'});
fireEvent.keyUp(button, {key: 'Enter'});
expect(eventDown.defaultPrevented).toBeTruthy();
expect(eventUp.defaultPrevented).toBeTruthy();

fireEvent.keyDown(button, {key: ' '});
fireEvent.keyUp(button, {key: ' '});
expect(eventDown.defaultPrevented).toBeTruthy();
expect(eventUp.defaultPrevented).toBeTruthy();
});

// 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
// space submits on key up and is actually a click
it('submit in form using space', function () {
let eventUp;
let btn = React.createRef();
let {getByRole} = render(
<Provider theme={defaultTheme}>
<Form>
<Checkbox>An Input</Checkbox>
<Button variant="primary" type="submit" ref={btn}>Click Me</Button>
</Form>
</Provider>
);
btn.current.UNSAFE_getDOMNode().addEventListener('keyup', e => eventUp = e);

let button = getByRole('button');
fireEvent.keyDown(button, {key: ' '});
fireEvent.keyUp(button, {key: ' '});
expect(eventUp.defaultPrevented).toBeFalsy();
});

// enter submits on keydown
it('submit in form using enter', function () {
let eventDown;
let btn = React.createRef();
let {getByRole} = render(
<Provider theme={defaultTheme}>
<Form>
<Checkbox>An Input</Checkbox>
<Button variant="primary" type="submit" ref={btn}>Click Me</Button>
</Form>
</Provider>
);
btn.current.UNSAFE_getDOMNode().addEventListener('keydown', e => eventDown = e);

let button = getByRole('button');
fireEvent.keyDown(button, {key: 'Enter'});
fireEvent.keyUp(button, {key: 'Enter'});
expect(eventDown.defaultPrevented).toBeFalsy();
});


// 'implicit submission' can't be tested https://github.com/testing-library/react-testing-library/issues/487
});
16 changes: 15 additions & 1 deletion packages/@react-spectrum/checkbox/test/Checkbox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
* governing permissions and limitations under the License.
*/

import {act, render} from '@testing-library/react';
import {Checkbox} from '../';
import React from 'react';
import {render} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import V2Checkbox from '@react/react-spectrum/Checkbox';

Expand Down Expand Up @@ -250,4 +250,18 @@ describe('Checkbox', function () {
expect(checkbox.checked).toBeTruthy();
expect(onChangeSpy).not.toHaveBeenCalled();
});

it.each`
Name | Component | props
${'Checkbox'} | ${Checkbox} | ${{onChange: onChangeSpy, isReadOnly: true}}
`('$Name supports uncontrolled readOnly', function ({Component, props}) {
let {getByLabelText} = render(<Component {...props}>Click Me</Component>);

let checkbox = getByLabelText('Click Me');
expect(checkbox.checked).toBeFalsy();

act(() => {userEvent.click(checkbox);});
expect(checkbox.checked).toBeFalsy();
expect(onChangeSpy).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion packages/@react-spectrum/form/stories/Form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function FormWithControls(props: any = {}) {

return (
<Flex>
<Checkbox isSelected={preventDefault} onChange={setPreventDefault}>Prevent Default onSubmit</Checkbox>
<Checkbox alignSelf="start" isSelected={preventDefault} onChange={setPreventDefault}>Prevent Default onSubmit</Checkbox>
<Form
onSubmit={e => {
action('onSubmit')(e);
Expand Down