mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[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:
+34
-18
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user