[react-events] Keyboard calls preventDefault on 'click' events (#16779)
Make sure to call preventDefault for any 'click' events that follow a 'keydown' event that matches 'preventKeys'
Nicolas Gallagher committed
Sep 13, 2019 at 13:19 UTC
87eaa90ef8dabd3d557085586d524016cd946d30
4 files changed
+237
-223
packages/react-events/src/dom/Keyboard.js
+34
-12
@@ -16,26 +16,32 @@ import React from 'react';
16
import {DiscreteEvent} from 'shared/ReactTypes';
17
import type {ReactEventResponderListener} from 'shared/ReactTypes';
18
19
-type KeyboardEventType = 'keydown' | 'keyup';
19
+type KeyboardEventType = 'keyboard:keydown' | 'keyboard:keyup';
20
21
-type KeyboardProps = {
21
+type KeyboardProps = {|
22
disabled?: boolean,
23
onKeyDown?: (e: KeyboardEvent) => ?boolean,
24
onKeyUp?: (e: KeyboardEvent) => ?boolean,
25
preventKeys?: PreventKeysArray,
26
-};
26
+|};
27
+
28
+type KeyboardState = {|
29
+ defaultPrevented: boolean,
30
+ isActive: boolean,
31
+|};
32
33
export type KeyboardEvent = {|
34
altKey: boolean,
35
ctrlKey: boolean,
36
+ defaultPrevented: boolean,
37
isComposing: boolean,
38
key: string,
39
metaKey: boolean,
40
+ pointerType: 'keyboard',
41
shiftKey: boolean,
42
target: Element | Document,
43
type: KeyboardEventType,
44
timeStamp: number,
38
- defaultPrevented: boolean,
45
|};
46
47
type ModifiersObject = {|
@@ -48,7 +54,7 @@ type ModifiersObject = {|
54
type PreventKeysArray = Array<string | Array<string | ModifiersObject>>;
55
56
const isArray = Array.isArray;
51
-const targetEventTypes = ['keydown_active', 'keyup'];
57
+const targetEventTypes = ['click_active', 'keydown_active', 'keyup'];
58
const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'];
59
60
/**
@@ -150,6 +156,7 @@ function createKeyboardEvent(
156
isComposing,
157
key: getEventKey(nativeEvent),
158
metaKey,
159
+ pointerType: 'keyboard',
160
shiftKey,
161
target: event.target,
162
timeStamp: context.getTimeStamp(),
@@ -182,10 +189,17 @@ function dispatchKeyboardEvent(
189
190
const keyboardResponderImpl = {
191
targetEventTypes,
192
+ getInitialState(): KeyboardState {
193
+ return {
194
+ defaultPrevented: false,
195
+ isActive: false,
196
+ };
197
+ },
198
onEvent(
199
event: ReactDOMResponderEvent,
200
context: ReactDOMResponderContext,
201
props: KeyboardProps,
202
+ state: KeyboardState,
203
): void {
204
const {type} = event;
205
const nativeEvent: any = event.nativeEvent;
@@ -193,10 +207,12 @@ const keyboardResponderImpl = {
207
if (props.disabled) {
208
return;
209
}
196
- let defaultPrevented = nativeEvent.defaultPrevented === true;
210
+
211
if (type === 'keydown') {
212
+ state.defaultPrevented = nativeEvent.defaultPrevented === true;
213
+
214
const preventKeys = ((props.preventKeys: any): PreventKeysArray);
199
- if (!defaultPrevented && isArray(preventKeys)) {
215
+ if (!state.defaultPrevented && isArray(preventKeys)) {
216
preventKeyLoop: for (let i = 0; i < preventKeys.length; i++) {
217
const preventKey = preventKeys[i];
218
let key = preventKey;
@@ -216,32 +232,38 @@ const keyboardResponderImpl = {
232
}
233
}
234
}
235
+
236
if (key === getEventKey(nativeEvent)) {
220
- defaultPrevented = true;
237
+ state.defaultPrevented = true;
238
nativeEvent.preventDefault();
239
break;
240
}
241
}
242
}
243
+ state.isActive = true;
244
const onKeyDown = props.onKeyDown;
245
if (isFunction(onKeyDown)) {
246
dispatchKeyboardEvent(
247
event,
248
((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
249
context,
232
- 'keydown',
233
- defaultPrevented,
250
+ 'keyboard:keydown',
251
+ state.defaultPrevented,
252
);
253
}
254
+ } else if (type === 'click' && state.isActive && state.defaultPrevented) {
255
+ // 'click' occurs before 'keyup' and may need native behavior prevented
256
+ nativeEvent.preventDefault();
257
} else if (type === 'keyup') {
258
+ state.isActive = false;
259
const onKeyUp = props.onKeyUp;
260
if (isFunction(onKeyUp)) {
261
dispatchKeyboardEvent(
262
event,
263
((onKeyUp: any): (e: KeyboardEvent) => ?boolean),
264
context,
243
- 'keyup',
244
- defaultPrevented,
265
+ 'keyboard:keyup',
266
+ state.defaultPrevented,
267
);
268
}
269
}
packages/react-events/src/dom/__tests__/Keyboard-test.internal.js
+201
-205
@@ -25,7 +25,7 @@ function initializeModules(hasPointerEvents) {
25
useKeyboard = require('react-events/keyboard').useKeyboard;
26
}
27
28
-describe('Keyboard event responder', () => {
28
+describe('Keyboard responder', () => {
29
let container;
30
31
beforeEach(() => {
@@ -40,6 +40,71 @@ describe('Keyboard event responder', () => {
40
container = null;
41
});
42
43
+ function renderPropagationTest(propagates) {
44
+ const onKeyDownInner = jest.fn(() => propagates);
45
+ const onKeyDownOuter = jest.fn();
46
+ const onKeyUpInner = jest.fn(() => propagates);
47
+ const onKeyUpOuter = jest.fn();
48
+ const ref = React.createRef();
49
+ const Component = () => {
50
+ const listenerInner = useKeyboard({
51
+ onKeyDown: onKeyDownInner,
52
+ onKeyUp: onKeyUpInner,
53
+ });
54
+ const listenerOuter = useKeyboard({
55
+ onKeyDown: onKeyDownOuter,
56
+ onKeyUp: onKeyUpOuter,
57
+ });
58
+ return (
59
+ <div listeners={listenerOuter}>
60
+ <div ref={ref} listeners={listenerInner} />
61
+ </div>
62
+ );
63
+ };
64
+ ReactDOM.render(<Component />, container);
65
+ return {
66
+ onKeyDownInner,
67
+ onKeyDownOuter,
68
+ onKeyUpInner,
69
+ onKeyUpOuter,
70
+ ref,
71
+ };
72
+ }
73
+
74
+ test('propagates event when a callback returns true', () => {
75
+ const {
76
+ onKeyDownInner,
77
+ onKeyDownOuter,
78
+ onKeyUpInner,
79
+ onKeyUpOuter,
80
+ ref,
81
+ } = renderPropagationTest(true);
82
+ const target = createEventTarget(ref.current);
83
+ target.keydown();
84
+ expect(onKeyDownInner).toBeCalled();
85
+ expect(onKeyDownOuter).toBeCalled();
86
+ target.keyup();
87
+ expect(onKeyUpInner).toBeCalled();
88
+ expect(onKeyUpOuter).toBeCalled();
89
+ });
90
+
91
+ test('does not propagate event when a callback returns false', () => {
92
+ const {
93
+ onKeyDownInner,
94
+ onKeyDownOuter,
95
+ onKeyUpInner,
96
+ onKeyUpOuter,
97
+ ref,
98
+ } = renderPropagationTest(false);
99
+ const target = createEventTarget(ref.current);
100
+ target.keydown();
101
+ expect(onKeyDownInner).toBeCalled();
102
+ expect(onKeyDownOuter).not.toBeCalled();
103
+ target.keyup();
104
+ expect(onKeyUpInner).toBeCalled();
105
+ expect(onKeyUpOuter).not.toBeCalled();
106
+ });
107
+
108
describe('disabled', () => {
109
let onKeyDown, onKeyUp, ref;
110
@@ -48,17 +113,13 @@ describe('Keyboard event responder', () => {
113
onKeyUp = jest.fn();
114
ref = React.createRef();
115
const Component = () => {
51
- const listener = useKeyboard({
52
- disabled: true,
53
- onKeyDown,
54
- onKeyUp,
55
- });
116
+ const listener = useKeyboard({disabled: true, onKeyDown, onKeyUp});
117
return <div ref={ref} listeners={listener} />;
118
};
119
ReactDOM.render(<Component />, container);
120
});
121
61
- it('prevents custom events being dispatched', () => {
122
+ test('does not call callbacks', () => {
123
const target = createEventTarget(ref.current);
124
target.keydown();
125
target.keyup();
@@ -74,265 +135,200 @@ describe('Keyboard event responder', () => {
135
onKeyDown = jest.fn();
136
ref = React.createRef();
137
const Component = () => {
77
- const listener = useKeyboard({
78
- onKeyDown,
79
- });
138
+ const listener = useKeyboard({onKeyDown});
139
return <div ref={ref} listeners={listener} />;
140
};
141
ReactDOM.render(<Component />, container);
142
});
143
85
- it('is called after "keydown" event', () => {
144
+ test('key down', () => {
145
const target = createEventTarget(ref.current);
146
target.keydown({key: 'Q'});
147
expect(onKeyDown).toHaveBeenCalledTimes(1);
148
expect(onKeyDown).toHaveBeenCalledWith(
90
- expect.objectContaining({key: 'Q', type: 'keydown'}),
149
+ expect.objectContaining({
150
+ altKey: false,
151
+ ctrlKey: false,
152
+ defaultPrevented: false,
153
+ isComposing: false,
154
+ key: 'Q',
155
+ metaKey: false,
156
+ pointerType: 'keyboard',
157
+ shiftKey: false,
158
+ target: target.node,
159
+ timeStamp: expect.any(Number),
160
+ type: 'keyboard:keydown',
161
+ }),
162
);
163
});
93
- });
164
95
- describe('preventKeys', () => {
96
- it('onKeyDown is default prevented', () => {
97
- const onKeyDown = jest.fn();
98
- const ref = React.createRef();
99
- const Component = () => {
100
- const listener = useKeyboard({
101
- onKeyDown,
102
- preventKeys: ['Tab'],
103
- });
104
- return <div ref={ref} listeners={listener} />;
105
- };
106
- ReactDOM.render(<Component />, container);
107
-
108
- const preventDefault = jest.fn();
165
+ test('modified key down', () => {
166
const target = createEventTarget(ref.current);
110
- target.keydown({key: 'Tab', preventDefault});
111
- expect(onKeyDown).toHaveBeenCalledTimes(1);
112
- expect(preventDefault).toBeCalled();
167
+ target.keydown({
168
+ key: 'Q',
169
+ altKey: true,
170
+ ctrlKey: true,
171
+ shiftKey: true,
172
+ metaKey: true,
173
+ });
174
expect(onKeyDown).toHaveBeenCalledWith(
175
expect.objectContaining({
115
- key: 'Tab',
116
- type: 'keydown',
117
- defaultPrevented: true,
176
+ altKey: true,
177
+ ctrlKey: true,
178
+ defaultPrevented: false,
179
+ isComposing: false,
180
+ key: 'Q',
181
+ metaKey: true,
182
+ pointerType: 'keyboard',
183
+ shiftKey: true,
184
+ target: target.node,
185
+ timeStamp: expect.any(Number),
186
+ type: 'keyboard:keydown',
187
}),
188
);
189
});
190
+ });
191
122
- it('onKeyDown is default prevented (falsy modifier keys)', () => {
123
- let onKeyDown = jest.fn();
124
- let ref = React.createRef();
125
- let Component = () => {
126
- const listener = useKeyboard({
127
- onKeyDown,
128
- preventKeys: [['Tab', {metaKey: false}]],
129
- });
192
+ describe('onKeyUp', () => {
193
+ let onKeyUp, ref;
194
+
195
+ beforeEach(() => {
196
+ onKeyUp = jest.fn();
197
+ ref = React.createRef();
198
+ const Component = () => {
199
+ const listener = useKeyboard({onKeyUp});
200
return <div ref={ref} listeners={listener} />;
201
};
202
ReactDOM.render(<Component />, container);
203
+ });
204
134
- let preventDefault = jest.fn();
135
- let target = createEventTarget(ref.current);
136
- target.keydown({key: 'Tab', preventDefault, metaKey: true});
137
- expect(onKeyDown).toHaveBeenCalledTimes(1);
138
- expect(preventDefault).not.toBeCalled();
139
- expect(onKeyDown).toHaveBeenCalledWith(
205
+ test('key up', () => {
206
+ const target = createEventTarget(ref.current);
207
+ target.keydown({key: 'Q'});
208
+ target.keyup({key: 'Q'});
209
+ expect(onKeyUp).toHaveBeenCalledTimes(1);
210
+ expect(onKeyUp).toHaveBeenCalledWith(
211
expect.objectContaining({
141
- key: 'Tab',
142
- type: 'keydown',
212
+ altKey: false,
213
+ ctrlKey: false,
214
defaultPrevented: false,
215
+ isComposing: false,
216
+ key: 'Q',
217
+ metaKey: false,
218
+ pointerType: 'keyboard',
219
+ shiftKey: false,
220
+ target: target.node,
221
+ timeStamp: expect.any(Number),
222
+ type: 'keyboard:keyup',
223
}),
224
);
225
+ });
226
147
- onKeyDown = jest.fn();
148
- ref = React.createRef();
149
- Component = () => {
150
- const listener = useKeyboard({
151
- onKeyDown,
152
- preventKeys: [['Tab', {metaKey: true}]],
153
- });
154
- return <div ref={ref} listeners={listener} />;
155
- };
156
- ReactDOM.render(<Component />, container);
157
-
158
- preventDefault = jest.fn();
159
- target = createEventTarget(ref.current);
160
- target.keydown({key: 'Tab', preventDefault, metaKey: false});
161
- expect(onKeyDown).toHaveBeenCalledTimes(1);
162
- expect(preventDefault).not.toBeCalled();
163
- expect(onKeyDown).toHaveBeenCalledWith(
227
+ test('modified key up', () => {
228
+ const target = createEventTarget(ref.current);
229
+ target.keydown({key: 'Q'});
230
+ target.keyup({
231
+ key: 'Q',
232
+ altKey: true,
233
+ ctrlKey: true,
234
+ shiftKey: true,
235
+ metaKey: true,
236
+ });
237
+ expect(onKeyUp).toHaveBeenCalledWith(
238
expect.objectContaining({
165
- key: 'Tab',
166
- type: 'keydown',
239
+ altKey: true,
240
+ ctrlKey: true,
241
defaultPrevented: false,
242
+ isComposing: false,
243
+ key: 'Q',
244
+ metaKey: true,
245
+ pointerType: 'keyboard',
246
+ shiftKey: true,
247
+ target: target.node,
248
+ timeStamp: expect.any(Number),
249
+ type: 'keyboard:keyup',
250
}),
251
);
252
});
253
+ });
254
172
- it('onKeyDown is default prevented (truthy modifier keys)', () => {
173
- let onKeyDown = jest.fn();
174
- let ref = React.createRef();
175
- let Component = () => {
176
- const listener = useKeyboard({
177
- onKeyDown,
178
- preventKeys: [['Tab', {metaKey: true}]],
179
- });
255
+ describe('preventKeys', () => {
256
+ function render(props) {
257
+ const ref = React.createRef();
258
+ const Component = () => {
259
+ const listener = useKeyboard(props);
260
return <div ref={ref} listeners={listener} />;
261
};
262
ReactDOM.render(<Component />, container);
263
+ return ref;
264
+ }
265
+
266
+ test('key config matches', () => {
267
+ const onKeyDown = jest.fn();
268
+ const preventDefault = jest.fn();
269
+ const preventDefaultClick = jest.fn();
270
+ const ref = render({onKeyDown, preventKeys: ['Tab']});
271
+
272
+ const target = createEventTarget(ref.current);
273
+ target.keydown({key: 'Tab', preventDefault});
274
+ target.click({preventDefault: preventDefaultClick});
275
184
- let preventDefault = jest.fn();
185
- let target = createEventTarget(ref.current);
186
- target.keydown({key: 'Tab', preventDefault, metaKey: true});
276
expect(onKeyDown).toHaveBeenCalledTimes(1);
277
expect(preventDefault).toBeCalled();
278
+ expect(preventDefaultClick).toBeCalled();
279
expect(onKeyDown).toHaveBeenCalledWith(
280
expect.objectContaining({
191
- key: 'Tab',
192
- type: 'keydown',
281
defaultPrevented: true,
282
+ key: 'Tab',
283
+ type: 'keyboard:keydown',
284
}),
285
);
286
+ });
287
197
- onKeyDown = jest.fn();
198
- ref = React.createRef();
199
- Component = () => {
200
- const listener = useKeyboard({
201
- onKeyDown,
202
- preventKeys: [['Tab', {metaKey: false}]],
203
- });
204
- return <div ref={ref} listeners={listener} />;
205
- };
206
- ReactDOM.render(<Component />, container);
288
+ test('key config matches (modifier keys)', () => {
289
+ const onKeyDown = jest.fn();
290
+ const preventDefault = jest.fn();
291
+ const preventDefaultClick = jest.fn();
292
+ const ref = render({onKeyDown, preventKeys: [['Tab', {shiftKey: true}]]});
293
+
294
+ const target = createEventTarget(ref.current);
295
+ target.keydown({key: 'Tab', preventDefault, shiftKey: true});
296
+ target.click({preventDefault: preventDefaultClick, shiftKey: true});
297
208
- preventDefault = jest.fn();
209
- target = createEventTarget(ref.current);
210
- target.keydown({key: 'Tab', preventDefault, metaKey: false});
298
expect(onKeyDown).toHaveBeenCalledTimes(1);
299
expect(preventDefault).toBeCalled();
300
+ expect(preventDefaultClick).toBeCalled();
301
expect(onKeyDown).toHaveBeenCalledWith(
302
expect.objectContaining({
215
- key: 'Tab',
216
- type: 'keydown',
303
defaultPrevented: true,
304
+ key: 'Tab',
305
+ shiftKey: true,
306
+ type: 'keyboard:keydown',
307
}),
308
);
309
});
221
- });
310
223
- describe('onKeyUp', () => {
224
- let onKeyDown, onKeyUp, ref;
225
-
226
- beforeEach(() => {
227
- onKeyDown = jest.fn();
228
- onKeyUp = jest.fn();
229
- ref = React.createRef();
230
- const Component = () => {
231
- const listener = useKeyboard({
232
- onKeyDown,
233
- onKeyUp,
234
- });
235
- return <div ref={ref} listeners={listener} />;
236
- };
237
- ReactDOM.render(<Component />, container);
238
- });
311
+ test('key config does not match (modifier keys)', () => {
312
+ const onKeyDown = jest.fn();
313
+ const preventDefault = jest.fn();
314
+ const preventDefaultClick = jest.fn();
315
+ const ref = render({onKeyDown, preventKeys: [['Tab', {shiftKey: true}]]});
316
240
- it('is called after "keydown" event', () => {
317
const target = createEventTarget(ref.current);
242
- target.keydown({key: 'Q'});
243
- target.keyup({key: 'Q'});
318
+ target.keydown({key: 'Tab', preventDefault, shiftKey: false});
319
+ target.click({preventDefault: preventDefaultClick, shiftKey: false});
320
+
321
expect(onKeyDown).toHaveBeenCalledTimes(1);
322
+ expect(preventDefault).not.toBeCalled();
323
+ expect(preventDefaultClick).not.toBeCalled();
324
expect(onKeyDown).toHaveBeenCalledWith(
246
- expect.objectContaining({key: 'Q', type: 'keydown'}),
247
- );
248
- expect(onKeyUp).toHaveBeenCalledTimes(1);
249
- expect(onKeyUp).toHaveBeenCalledWith(
250
- expect.objectContaining({key: 'Q', type: 'keyup'}),
325
+ expect.objectContaining({
326
+ defaultPrevented: false,
327
+ key: 'Tab',
328
+ shiftKey: false,
329
+ type: 'keyboard:keydown',
330
+ }),
331
);
332
});
333
});
254
-
255
- describe('correctly handles responder propagation', () => {
256
- describe('onKeyDown', () => {
257
- let onKeyDownInner, onKeyDownOuter, ref;
258
-
259
- function renderPropagationTest(propagates) {
260
- onKeyDownInner = jest.fn(() => propagates);
261
- onKeyDownOuter = jest.fn();
262
- ref = React.createRef();
263
- const Component = () => {
264
- const listenerInner = useKeyboard({
265
- onKeyDown: onKeyDownInner,
266
- });
267
- const listenerOuter = useKeyboard({
268
- onKeyDown: onKeyDownOuter,
269
- });
270
- return (
271
- <div listeners={listenerOuter}>
272
- <div ref={ref} listeners={listenerInner} />
273
- </div>
274
- );
275
- };
276
- ReactDOM.render(<Component />, container);
277
- }
278
-
279
- it('propagates when cb returns true', () => {
280
- renderPropagationTest(true);
281
- const target = createEventTarget(ref.current);
282
- target.keydown();
283
- expect(onKeyDownInner).toBeCalled();
284
- expect(onKeyDownOuter).toBeCalled();
285
- });
286
-
287
- it('does not propagate when cb returns false', () => {
288
- renderPropagationTest(false);
289
- const target = createEventTarget(ref.current);
290
- target.keydown();
291
- expect(onKeyDownInner).toBeCalled();
292
- expect(onKeyDownOuter).not.toBeCalled();
293
- });
294
- });
295
-
296
- describe('onKeyUp', () => {
297
- let onKeyUpInner, onKeyUpOuter, ref;
298
-
299
- function renderPropagationTest(propagates) {
300
- onKeyUpInner = jest.fn(() => propagates);
301
- onKeyUpOuter = jest.fn();
302
- ref = React.createRef();
303
- const Component = () => {
304
- const listenerInner = useKeyboard({
305
- onKeyUp: onKeyUpInner,
306
- });
307
- const listenerOuter = useKeyboard({
308
- onKeyUp: onKeyUpOuter,
309
- });
310
- return (
311
- <div listeners={listenerOuter}>
312
- <div ref={ref} listeners={listenerInner} />
313
- </div>
314
- );
315
- };
316
- ReactDOM.render(<Component />, container);
317
- }
318
-
319
- it('propagates when cb returns true', () => {
320
- renderPropagationTest(true);
321
- const target = createEventTarget(ref.current);
322
- target.keydown();
323
- target.keyup();
324
- expect(onKeyUpInner).toBeCalled();
325
- expect(onKeyUpOuter).toBeCalled();
326
- });
327
-
328
- it('does not propagate when cb returns false', () => {
329
- renderPropagationTest(false);
330
- const target = createEventTarget(ref.current);
331
- target.keydown();
332
- target.keyup();
333
- expect(onKeyUpInner).toBeCalled();
334
- expect(onKeyUpOuter).not.toBeCalled();
335
- });
336
- });
337
- });
334
});
packages/react-events/src/dom/testing-library/domEvents.js
+2
@@ -137,6 +137,7 @@ function createKeyboardEvent(
137
{
138
altKey = false,
139
ctrlKey = false,
140
+ isComposing = false,
141
key = '',
142
metaKey = false,
143
preventDefault = emptyFunction,
@@ -151,6 +152,7 @@ function createKeyboardEvent(
152
getModifierState(keyArg) {
153
createGetModifierState(keyArg, modifierState);
154
},
155
+ isComposing,
156
key,
157
metaKey,
158
preventDefault,
packages/react-reconciler/src/__tests__/ReactScope-test.internal.js
-6
@@ -220,9 +220,6 @@ describe('ReactScope', () => {
220
let target = createEventTarget(ref.current);
221
target.keydown({key: 'Q'});
222
expect(onKeyDown).toHaveBeenCalledTimes(1);
223
- expect(onKeyDown).toHaveBeenCalledWith(
224
- expect.objectContaining({key: 'Q', type: 'keydown'}),
225
- );
223
224
onKeyDown = jest.fn();
225
Component = () => {
@@ -242,9 +239,6 @@ describe('ReactScope', () => {
239
target = createEventTarget(ref.current);
240
target.keydown({key: 'Q'});
241
expect(onKeyDown).toHaveBeenCalledTimes(1);
245
- expect(onKeyDown).toHaveBeenCalledWith(
246
- expect.objectContaining({key: 'Q', type: 'keydown'}),
247
- );
242
});
243
});
244