Skip to content

Commit

Permalink
[react-events] Keyboard support for virtual clicks (#16780)
Browse files Browse the repository at this point in the history
This accounts for all clicks that are natively dispatched following relevant
keyboard interactions (e.g., key is "Enter"), as well as programmatic clicks,
and screen-reader virtual clicks.
  • Loading branch information
necolas authored Sep 16, 2019
1 parent b8d079b commit 9691eb2
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 27 deletions.
52 changes: 34 additions & 18 deletions packages/react-events/src/dom/Keyboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ import type {
ReactDOMResponderEvent,
ReactDOMResponderContext,
} from 'shared/ReactDOMTypes';
import type {ReactEventResponderListener} from 'shared/ReactTypes';

import React from 'react';
import {DiscreteEvent} from 'shared/ReactTypes';
import type {ReactEventResponderListener} from 'shared/ReactTypes';
import {isVirtualClick} from './shared';

type KeyboardEventType = 'keyboard:keydown' | 'keyboard:keyup';
type KeyboardEventType =
| 'keyboard:click'
| 'keyboard:keydown'
| 'keyboard:keyup';

type KeyboardProps = {|
disabled?: boolean,
onClick?: (e: KeyboardEvent) => ?boolean,
onKeyDown?: (e: KeyboardEvent) => ?boolean,
onKeyUp?: (e: KeyboardEvent) => ?boolean,
preventKeys?: PreventKeysArray,
Expand All @@ -34,8 +39,8 @@ export type KeyboardEvent = {|
altKey: boolean,
ctrlKey: boolean,
defaultPrevented: boolean,
isComposing: boolean,
key: string,
isComposing?: boolean,
key?: string,
metaKey: boolean,
pointerType: 'keyboard',
shiftKey: boolean,
Expand Down Expand Up @@ -120,10 +125,6 @@ const translateToKey = {
'224': 'Meta',
};

function isFunction(obj): boolean {
return typeof obj === 'function';
}

function getEventKey(nativeEvent: Object): string {
const nativeKey = nativeEvent.key;
if (nativeKey) {
Expand All @@ -147,21 +148,24 @@ function createKeyboardEvent(
defaultPrevented: boolean,
): KeyboardEvent {
const nativeEvent = (event: any).nativeEvent;
const {altKey, ctrlKey, isComposing, metaKey, shiftKey} = nativeEvent;

return {
const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
let keyboardEvent = {
altKey,
ctrlKey,
defaultPrevented,
isComposing,
key: getEventKey(nativeEvent),
metaKey,
pointerType: 'keyboard',
shiftKey,
target: event.target,
timeStamp: context.getTimeStamp(),
type,
};
if (type !== 'keyboard:click') {
const key = getEventKey(nativeEvent);
const isComposing = nativeEvent.isComposing;
keyboardEvent = context.objectAssign({isComposing, key}, keyboardEvent);
}
return keyboardEvent;
}

function dispatchKeyboardEvent(
Expand Down Expand Up @@ -242,7 +246,7 @@ const keyboardResponderImpl = {
}
state.isActive = true;
const onKeyDown = props.onKeyDown;
if (isFunction(onKeyDown)) {
if (onKeyDown != null) {
dispatchKeyboardEvent(
event,
((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
Expand All @@ -251,13 +255,25 @@ const keyboardResponderImpl = {
state.defaultPrevented,
);
}
} else if (type === 'click' && state.isActive && state.defaultPrevented) {
// 'click' occurs before 'keyup' and may need native behavior prevented
nativeEvent.preventDefault();
} else if (type === 'click' && isVirtualClick(event)) {
const onClick = props.onClick;
if (onClick != null) {
dispatchKeyboardEvent(
event,
onClick,
context,
'keyboard:click',
state.defaultPrevented,
);
}
if (state.defaultPrevented && !nativeEvent.defaultPrevented) {
// 'click' occurs before 'keyup' and may need native behavior prevented
nativeEvent.preventDefault();
}
} else if (type === 'keyup') {
state.isActive = false;
const onKeyUp = props.onKeyUp;
if (isFunction(onKeyUp)) {
if (onKeyUp != null) {
dispatchKeyboardEvent(
event,
((onKeyUp: any): (e: KeyboardEvent) => ?boolean),
Expand Down
98 changes: 89 additions & 9 deletions packages/react-events/src/dom/__tests__/Keyboard-test.internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,21 @@ describe('Keyboard responder', () => {
});

function renderPropagationTest(propagates) {
const onClickInner = jest.fn(() => propagates);
const onKeyDownInner = jest.fn(() => propagates);
const onKeyDownOuter = jest.fn();
const onKeyUpInner = jest.fn(() => propagates);
const onClickOuter = jest.fn();
const onKeyDownOuter = jest.fn();
const onKeyUpOuter = jest.fn();
const ref = React.createRef();
const Component = () => {
const listenerInner = useKeyboard({
onClick: onClickInner,
onKeyDown: onKeyDownInner,
onKeyUp: onKeyUpInner,
});
const listenerOuter = useKeyboard({
onClick: onClickOuter,
onKeyDown: onKeyDownOuter,
onKeyUp: onKeyUpOuter,
});
Expand All @@ -63,19 +67,23 @@ describe('Keyboard responder', () => {
};
ReactDOM.render(<Component />, container);
return {
onClickInner,
onKeyDownInner,
onKeyDownOuter,
onKeyUpInner,
onClickOuter,
onKeyDownOuter,
onKeyUpOuter,
ref,
};
}

test('propagates event when a callback returns true', () => {
test('propagates key event when a callback returns true', () => {
const {
onClickInner,
onKeyDownInner,
onKeyDownOuter,
onKeyUpInner,
onClickOuter,
onKeyDownOuter,
onKeyUpOuter,
ref,
} = renderPropagationTest(true);
Expand All @@ -86,13 +94,18 @@ describe('Keyboard responder', () => {
target.keyup();
expect(onKeyUpInner).toBeCalled();
expect(onKeyUpOuter).toBeCalled();
target.virtualclick();
expect(onClickInner).toBeCalled();
expect(onClickOuter).toBeCalled();
});

test('does not propagate event when a callback returns false', () => {
test('does not propagate key event when a callback returns false', () => {
const {
onClickInner,
onKeyDownInner,
onKeyDownOuter,
onKeyUpInner,
onClickOuter,
onKeyDownOuter,
onKeyUpOuter,
ref,
} = renderPropagationTest(false);
Expand All @@ -103,6 +116,9 @@ describe('Keyboard responder', () => {
target.keyup();
expect(onKeyUpInner).toBeCalled();
expect(onKeyUpOuter).not.toBeCalled();
target.virtualclick();
expect(onClickInner).toBeCalled();
expect(onClickOuter).not.toBeCalled();
});

describe('disabled', () => {
Expand All @@ -128,6 +144,64 @@ describe('Keyboard responder', () => {
});
});

describe('onClick', () => {
let onClick, ref;

beforeEach(() => {
onClick = jest.fn();
ref = React.createRef();
const Component = () => {
const listener = useKeyboard({onClick});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
});

// e.g, "Enter" on link
test('keyboard click is between key events', () => {
const target = createEventTarget(ref.current);
target.keydown({key: 'Enter'});
target.keyup({key: 'Enter'});
target.virtualclick();
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledWith(
expect.objectContaining({
altKey: false,
ctrlKey: false,
defaultPrevented: false,
metaKey: false,
pointerType: 'keyboard',
shiftKey: false,
target: target.node,
timeStamp: expect.any(Number),
type: 'keyboard:click',
}),
);
});

// e.g., "Spacebar" on button
test('keyboard click is after key events', () => {
const target = createEventTarget(ref.current);
target.keydown({key: 'Enter'});
target.keyup({key: 'Enter'});
target.virtualclick();
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledWith(
expect.objectContaining({
altKey: false,
ctrlKey: false,
defaultPrevented: false,
metaKey: false,
pointerType: 'keyboard',
shiftKey: false,
target: target.node,
timeStamp: expect.any(Number),
type: 'keyboard:click',
}),
);
});
});

describe('onKeyDown', () => {
let onKeyDown, ref;

Expand Down Expand Up @@ -271,7 +345,7 @@ describe('Keyboard responder', () => {

const target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault});
target.click({preventDefault: preventDefaultClick});
target.virtualclick({preventDefault: preventDefaultClick});

expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).toBeCalled();
Expand All @@ -293,7 +367,10 @@ describe('Keyboard responder', () => {

const target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault, shiftKey: true});
target.click({preventDefault: preventDefaultClick, shiftKey: true});
target.virtualclick({
preventDefault: preventDefaultClick,
shiftKey: true,
});

expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).toBeCalled();
Expand All @@ -316,7 +393,10 @@ describe('Keyboard responder', () => {

const target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault, shiftKey: false});
target.click({preventDefault: preventDefaultClick, shiftKey: false});
target.virtualclick({
preventDefault: preventDefaultClick,
shiftKey: false,
});

expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).not.toBeCalled();
Expand Down
14 changes: 14 additions & 0 deletions packages/react-events/src/dom/shared/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,17 @@ export function hasModifierKey(event: ReactDOMResponderEvent): boolean {
altKey === true || ctrlKey === true || metaKey === true || shiftKey === true
);
}

// Keyboards, Assitive Technologies, and element.click() all produce "virtual"
// clicks that do not include coordinates and "detail" is always 0 (where
// pointer clicks are > 0).
export function isVirtualClick(event: ReactDOMResponderEvent): boolean {
const nativeEvent: any = event.nativeEvent;
return (
nativeEvent.detail === 0 &&
nativeEvent.screenX === 0 &&
nativeEvent.screenY === 0 &&
nativeEvent.clientX === 0 &&
nativeEvent.clientY === 0
);
}

0 comments on commit 9691eb2

Please sign in to comment.