[react-events] Keyboard support for virtual clicks (#16780)

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.
This commit is contained in:
Nicolas Gallagher
2019-09-16 10:40:05 -07:00
committed by GitHub
parent b8d079b413
commit 9691eb273a
3 changed files with 137 additions and 27 deletions
+34 -18
View File
@@ -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,
@@ -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,
@@ -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) {
@@ -147,14 +148,11 @@ 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,
@@ -162,6 +160,12 @@ function createKeyboardEvent(
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(
@@ -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),
@@ -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),
@@ -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,
});
@@ -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);
@@ -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);
@@ -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', () => {
@@ -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;
@@ -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();
@@ -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();
@@ -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();
+14
View File
@@ -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
);
}