@samitouri / QOS-React-2 / commits / 9691eb273a

[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 +}