[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.
Nicolas Gallagher committed
Sep 16, 2019 at 10:40 UTC
9691eb273a8e8b2f36b2d8d89506d002be3a8d1c
3 files changed
+137
-27
packages/react-events/src/dom/Keyboard.js
+34
-18
@@ -11,15 +11,20 @@ import type {
11
ReactDOMResponderEvent,
12
ReactDOMResponderContext,
13
} from 'shared/ReactDOMTypes';
14
+import type {ReactEventResponderListener} from 'shared/ReactTypes';
15
16
import React from 'react';
17
import {DiscreteEvent} from 'shared/ReactTypes';
17
-import type {ReactEventResponderListener} from 'shared/ReactTypes';
18
+import {isVirtualClick} from './shared';
19
19
-type KeyboardEventType = 'keyboard:keydown' | 'keyboard:keyup';
20
+type KeyboardEventType =
21
+ | 'keyboard:click'
22
+ | 'keyboard:keydown'
23
+ | 'keyboard:keyup';
24
25
type KeyboardProps = {|
26
disabled?: boolean,
27
+ onClick?: (e: KeyboardEvent) => ?boolean,
28
onKeyDown?: (e: KeyboardEvent) => ?boolean,
29
onKeyUp?: (e: KeyboardEvent) => ?boolean,
30
preventKeys?: PreventKeysArray,
@@ -34,8 +39,8 @@ export type KeyboardEvent = {|
39
altKey: boolean,
40
ctrlKey: boolean,
41
defaultPrevented: boolean,
37
- isComposing: boolean,
38
- key: string,
42
+ isComposing?: boolean,
43
+ key?: string,
44
metaKey: boolean,
45
pointerType: 'keyboard',
46
shiftKey: boolean,
@@ -120,10 +125,6 @@ const translateToKey = {
125
'224': 'Meta',
126
};
127
123
-function isFunction(obj): boolean {
124
- return typeof obj === 'function';
125
-}
126
-
128
function getEventKey(nativeEvent: Object): string {
129
const nativeKey = nativeEvent.key;
130
if (nativeKey) {
@@ -147,14 +148,11 @@ function createKeyboardEvent(
148
defaultPrevented: boolean,
149
): KeyboardEvent {
150
const nativeEvent = (event: any).nativeEvent;
150
- const {altKey, ctrlKey, isComposing, metaKey, shiftKey} = nativeEvent;
151
-
152
- return {
151
+ const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
152
+ let keyboardEvent = {
153
altKey,
154
ctrlKey,
155
defaultPrevented,
156
- isComposing,
157
- key: getEventKey(nativeEvent),
156
metaKey,
157
pointerType: 'keyboard',
158
shiftKey,
@@ -162,6 +160,12 @@ function createKeyboardEvent(
160
timeStamp: context.getTimeStamp(),
161
type,
162
};
163
+ if (type !== 'keyboard:click') {
164
+ const key = getEventKey(nativeEvent);
165
+ const isComposing = nativeEvent.isComposing;
166
+ keyboardEvent = context.objectAssign({isComposing, key}, keyboardEvent);
167
+ }
168
+ return keyboardEvent;
169
}
170
171
function dispatchKeyboardEvent(
@@ -242,7 +246,7 @@ const keyboardResponderImpl = {
246
}
247
state.isActive = true;
248
const onKeyDown = props.onKeyDown;
245
- if (isFunction(onKeyDown)) {
249
+ if (onKeyDown != null) {
250
dispatchKeyboardEvent(
251
event,
252
((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
@@ -251,13 +255,25 @@ const keyboardResponderImpl = {
255
state.defaultPrevented,
256
);
257
}
254
- } else if (type === 'click' && state.isActive && state.defaultPrevented) {
255
- // 'click' occurs before 'keyup' and may need native behavior prevented
256
- nativeEvent.preventDefault();
258
+ } else if (type === 'click' && isVirtualClick(event)) {
259
+ const onClick = props.onClick;
260
+ if (onClick != null) {
261
+ dispatchKeyboardEvent(
262
+ event,
263
+ onClick,
264
+ context,
265
+ 'keyboard:click',
266
+ state.defaultPrevented,
267
+ );
268
+ }
269
+ if (state.defaultPrevented && !nativeEvent.defaultPrevented) {
270
+ // 'click' occurs before 'keyup' and may need native behavior prevented
271
+ nativeEvent.preventDefault();
272
+ }
273
} else if (type === 'keyup') {
274
state.isActive = false;
275
const onKeyUp = props.onKeyUp;
260
- if (isFunction(onKeyUp)) {
276
+ if (onKeyUp != null) {
277
dispatchKeyboardEvent(
278
event,
279
((onKeyUp: any): (e: KeyboardEvent) => ?boolean),
packages/react-events/src/dom/__tests__/Keyboard-test.internal.js
+89
-9
@@ -41,17 +41,21 @@ describe('Keyboard responder', () => {
41
});
42
43
function renderPropagationTest(propagates) {
44
+ const onClickInner = jest.fn(() => propagates);
45
const onKeyDownInner = jest.fn(() => propagates);
45
- const onKeyDownOuter = jest.fn();
46
const onKeyUpInner = jest.fn(() => propagates);
47
+ const onClickOuter = jest.fn();
48
+ const onKeyDownOuter = jest.fn();
49
const onKeyUpOuter = jest.fn();
50
const ref = React.createRef();
51
const Component = () => {
52
const listenerInner = useKeyboard({
53
+ onClick: onClickInner,
54
onKeyDown: onKeyDownInner,
55
onKeyUp: onKeyUpInner,
56
});
57
const listenerOuter = useKeyboard({
58
+ onClick: onClickOuter,
59
onKeyDown: onKeyDownOuter,
60
onKeyUp: onKeyUpOuter,
61
});
@@ -63,19 +67,23 @@ describe('Keyboard responder', () => {
67
};
68
ReactDOM.render(<Component />, container);
69
return {
70
+ onClickInner,
71
onKeyDownInner,
67
- onKeyDownOuter,
72
onKeyUpInner,
73
+ onClickOuter,
74
+ onKeyDownOuter,
75
onKeyUpOuter,
76
ref,
77
};
78
}
79
74
- test('propagates event when a callback returns true', () => {
80
+ test('propagates key event when a callback returns true', () => {
81
const {
82
+ onClickInner,
83
onKeyDownInner,
77
- onKeyDownOuter,
84
onKeyUpInner,
85
+ onClickOuter,
86
+ onKeyDownOuter,
87
onKeyUpOuter,
88
ref,
89
} = renderPropagationTest(true);
@@ -86,13 +94,18 @@ describe('Keyboard responder', () => {
94
target.keyup();
95
expect(onKeyUpInner).toBeCalled();
96
expect(onKeyUpOuter).toBeCalled();
97
+ target.virtualclick();
98
+ expect(onClickInner).toBeCalled();
99
+ expect(onClickOuter).toBeCalled();
100
});
101
91
- test('does not propagate event when a callback returns false', () => {
102
+ test('does not propagate key event when a callback returns false', () => {
103
const {
104
+ onClickInner,
105
onKeyDownInner,
94
- onKeyDownOuter,
106
onKeyUpInner,
107
+ onClickOuter,
108
+ onKeyDownOuter,
109
onKeyUpOuter,
110
ref,
111
} = renderPropagationTest(false);
@@ -103,6 +116,9 @@ describe('Keyboard responder', () => {
116
target.keyup();
117
expect(onKeyUpInner).toBeCalled();
118
expect(onKeyUpOuter).not.toBeCalled();
119
+ target.virtualclick();
120
+ expect(onClickInner).toBeCalled();
121
+ expect(onClickOuter).not.toBeCalled();
122
});
123
124
describe('disabled', () => {
@@ -128,6 +144,64 @@ describe('Keyboard responder', () => {
144
});
145
});
146
147
+ describe('onClick', () => {
148
+ let onClick, ref;
149
+
150
+ beforeEach(() => {
151
+ onClick = jest.fn();
152
+ ref = React.createRef();
153
+ const Component = () => {
154
+ const listener = useKeyboard({onClick});
155
+ return <div ref={ref} listeners={listener} />;
156
+ };
157
+ ReactDOM.render(<Component />, container);
158
+ });
159
+
160
+ // e.g, "Enter" on link
161
+ test('keyboard click is between key events', () => {
162
+ const target = createEventTarget(ref.current);
163
+ target.keydown({key: 'Enter'});
164
+ target.keyup({key: 'Enter'});
165
+ target.virtualclick();
166
+ expect(onClick).toHaveBeenCalledTimes(1);
167
+ expect(onClick).toHaveBeenCalledWith(
168
+ expect.objectContaining({
169
+ altKey: false,
170
+ ctrlKey: false,
171
+ defaultPrevented: false,
172
+ metaKey: false,
173
+ pointerType: 'keyboard',
174
+ shiftKey: false,
175
+ target: target.node,
176
+ timeStamp: expect.any(Number),
177
+ type: 'keyboard:click',
178
+ }),
179
+ );
180
+ });
181
+
182
+ // e.g., "Spacebar" on button
183
+ test('keyboard click is after key events', () => {
184
+ const target = createEventTarget(ref.current);
185
+ target.keydown({key: 'Enter'});
186
+ target.keyup({key: 'Enter'});
187
+ target.virtualclick();
188
+ expect(onClick).toHaveBeenCalledTimes(1);
189
+ expect(onClick).toHaveBeenCalledWith(
190
+ expect.objectContaining({
191
+ altKey: false,
192
+ ctrlKey: false,
193
+ defaultPrevented: false,
194
+ metaKey: false,
195
+ pointerType: 'keyboard',
196
+ shiftKey: false,
197
+ target: target.node,
198
+ timeStamp: expect.any(Number),
199
+ type: 'keyboard:click',
200
+ }),
201
+ );
202
+ });
203
+ });
204
+
205
describe('onKeyDown', () => {
206
let onKeyDown, ref;
207
@@ -271,7 +345,7 @@ describe('Keyboard responder', () => {
345
346
const target = createEventTarget(ref.current);
347
target.keydown({key: 'Tab', preventDefault});
274
- target.click({preventDefault: preventDefaultClick});
348
+ target.virtualclick({preventDefault: preventDefaultClick});
349
350
expect(onKeyDown).toHaveBeenCalledTimes(1);
351
expect(preventDefault).toBeCalled();
@@ -293,7 +367,10 @@ describe('Keyboard responder', () => {
367
368
const target = createEventTarget(ref.current);
369
target.keydown({key: 'Tab', preventDefault, shiftKey: true});
296
- target.click({preventDefault: preventDefaultClick, shiftKey: true});
370
+ target.virtualclick({
371
+ preventDefault: preventDefaultClick,
372
+ shiftKey: true,
373
+ });
374
375
expect(onKeyDown).toHaveBeenCalledTimes(1);
376
expect(preventDefault).toBeCalled();
@@ -316,7 +393,10 @@ describe('Keyboard responder', () => {
393
394
const target = createEventTarget(ref.current);
395
target.keydown({key: 'Tab', preventDefault, shiftKey: false});
319
- target.click({preventDefault: preventDefaultClick, shiftKey: false});
396
+ target.virtualclick({
397
+ preventDefault: preventDefaultClick,
398
+ shiftKey: false,
399
+ });
400
401
expect(onKeyDown).toHaveBeenCalledTimes(1);
402
expect(preventDefault).not.toBeCalled();
packages/react-events/src/dom/shared/index.js
+14
@@ -70,3 +70,17 @@ export function hasModifierKey(event: ReactDOMResponderEvent): boolean {
70
altKey === true || ctrlKey === true || metaKey === true || shiftKey === true
71
);
72
}
73
+
74
+// Keyboards, Assitive Technologies, and element.click() all produce "virtual"
75
+// clicks that do not include coordinates and "detail" is always 0 (where
76
+// pointer clicks are > 0).
77
+export function isVirtualClick(event: ReactDOMResponderEvent): boolean {
78
+ const nativeEvent: any = event.nativeEvent;
79
+ return (
80
+ nativeEvent.detail === 0 &&
81
+ nativeEvent.screenX === 0 &&
82
+ nativeEvent.screenY === 0 &&
83
+ nativeEvent.clientX === 0 &&
84
+ nativeEvent.clientY === 0
85
+ );
86
+}