[react-ui] Add preventDefault+stopPropagation to Keyboard + update Focus components (#16833)
Dominic Gannaway committed
Sep 20, 2019 at 11:21 UTC
57a5805a9f07cbda3fea99b7cca59bd3b2bb8476
6 files changed
+124
-141
packages/react-ui/accessibility/src/FocusTable.js
+6
-6
@@ -146,7 +146,7 @@ export function createFocusTable(): Array<React.Component> {
146
function Cell({children}): FocusCellProps {
147
const scopeRef = useRef(null);
148
const keyboard = useKeyboard({
149
- onKeyDown(event: KeyboardEvent): boolean {
149
+ onKeyDown(event: KeyboardEvent): void {
150
const currentCell = scopeRef.current;
151
switch (event.key) {
152
case 'UpArrow': {
@@ -162,7 +162,7 @@ export function createFocusTable(): Array<React.Component> {
162
}
163
}
164
}
165
- return false;
165
+ return;
166
}
167
case 'DownArrow': {
168
const [cells, rowIndex] = getRowCells(currentCell);
@@ -179,7 +179,7 @@ export function createFocusTable(): Array<React.Component> {
179
}
180
}
181
}
182
- return false;
182
+ return;
183
}
184
case 'LeftArrow': {
185
const [cells, rowIndex] = getRowCells(currentCell);
@@ -190,7 +190,7 @@ export function createFocusTable(): Array<React.Component> {
190
triggerNavigateOut(currentCell, 'left');
191
}
192
}
193
- return false;
193
+ return;
194
}
195
case 'RightArrow': {
196
const [cells, rowIndex] = getRowCells(currentCell);
@@ -203,10 +203,10 @@ export function createFocusTable(): Array<React.Component> {
203
}
204
}
205
}
206
- return false;
206
+ return;
207
}
208
}
209
- return true;
209
+ event.continuePropagation();
210
},
211
});
212
return (
packages/react-ui/accessibility/src/TabFocus.js
+43
-20
@@ -53,10 +53,11 @@ function focusElem(elem: null | HTMLElement): void {
53
}
54
}
55
56
-export function focusNext(
56
+function internalFocusNext(
57
scope: ReactScopeMethods,
58
+ event?: KeyboardEvent,
59
contain?: boolean,
59
-): boolean {
60
+): void {
61
const [
62
tabbableNodes,
63
firstTabbableElem,
@@ -66,23 +67,31 @@ export function focusNext(
67
] = getTabbableNodes(scope);
68
69
if (focusedElement === null) {
69
- focusElem(firstTabbableElem);
70
+ if (event) {
71
+ event.continuePropagation();
72
+ }
73
} else if (focusedElement === lastTabbableElem) {
71
- if (contain === true) {
74
+ if (contain) {
75
focusElem(firstTabbableElem);
73
- } else {
74
- return true;
76
+ if (event) {
77
+ event.preventDefault();
78
+ }
79
+ } else if (event) {
80
+ event.continuePropagation();
81
}
82
} else {
83
focusElem((tabbableNodes: any)[currentIndex + 1]);
84
+ if (event) {
85
+ event.preventDefault();
86
+ }
87
}
79
- return false;
88
}
89
82
-export function focusPrevious(
90
+function internalFocusPrevious(
91
scope: ReactScopeMethods,
92
+ event?: KeyboardEvent,
93
contain?: boolean,
85
-): boolean {
94
+): void {
95
const [
96
tabbableNodes,
97
firstTabbableElem,
@@ -92,17 +101,32 @@ export function focusPrevious(
101
] = getTabbableNodes(scope);
102
103
if (focusedElement === null) {
95
- focusElem(firstTabbableElem);
104
+ if (event) {
105
+ event.continuePropagation();
106
+ }
107
} else if (focusedElement === firstTabbableElem) {
97
- if (contain === true) {
108
+ if (contain) {
109
focusElem(lastTabbableElem);
99
- } else {
100
- return true;
110
+ if (event) {
111
+ event.preventDefault();
112
+ }
113
+ } else if (event) {
114
+ event.continuePropagation();
115
}
116
} else {
117
focusElem((tabbableNodes: any)[currentIndex - 1]);
118
+ if (event) {
119
+ event.preventDefault();
120
+ }
121
}
105
- return false;
122
+}
123
+
124
+export function focusPrevious(scope: ReactScopeMethods): void {
125
+ internalFocusPrevious(scope);
126
+}
127
+
128
+export function focusNext(scope: ReactScopeMethods): void {
129
+ internalFocusNext(scope);
130
}
131
132
export function getNextController(
@@ -137,21 +161,20 @@ export const TabFocusController = React.forwardRef(
161
({children, contain}: TabFocusControllerProps, ref): React.Node => {
162
const scopeRef = useRef(null);
163
const keyboard = useKeyboard({
140
- onKeyDown(event: KeyboardEvent): boolean {
164
+ onKeyDown(event: KeyboardEvent): void {
165
if (event.key !== 'Tab') {
142
- return true;
166
+ event.continuePropagation();
167
+ return;
168
}
169
const scope = scopeRef.current;
170
if (scope !== null) {
171
if (event.shiftKey) {
147
- return focusPrevious(scope, contain);
172
+ internalFocusPrevious(scope, event, contain);
173
} else {
149
- return focusNext(scope, contain);
174
+ internalFocusNext(scope, event, contain);
175
}
176
}
152
- return true;
177
},
154
- preventKeys: ['Tab', ['Tab', {shiftKey: true}]],
178
});
179
180
return (
packages/react-ui/accessibility/src/__tests__/TabFocus-test.internal.js
+2
-2
@@ -255,14 +255,14 @@ describe('TabFocusController', () => {
255
firstFocusController,
256
);
257
expect(nextController).toBe(secondFocusController);
258
- ReactTabFocus.focusNext(nextController);
258
+ ReactTabFocus.focusFirst(nextController);
259
expect(document.activeElement).toBe(divRef.current);
260
261
const previousController = ReactTabFocus.getPreviousController(
262
nextController,
263
);
264
expect(previousController).toBe(firstFocusController);
265
- ReactTabFocus.focusNext(previousController);
265
+ ReactTabFocus.focusFirst(previousController);
266
expect(document.activeElement).toBe(buttonRef.current);
267
});
268
});
packages/react-ui/events/src/dom/Keyboard.js
+24
-85
@@ -24,15 +24,12 @@ type KeyboardEventType =
24
25
type KeyboardProps = {|
26
disabled?: boolean,
27
- onClick?: (e: KeyboardEvent) => ?boolean,
28
- onKeyDown?: (e: KeyboardEvent) => ?boolean,
29
- onKeyUp?: (e: KeyboardEvent) => ?boolean,
30
- preventClick?: boolean,
31
- preventKeys?: PreventKeysArray,
27
+ onClick?: (e: KeyboardEvent) => void,
28
+ onKeyDown?: (e: KeyboardEvent) => void,
29
+ onKeyUp?: (e: KeyboardEvent) => void,
30
|};
31
32
type KeyboardState = {|
35
- defaultPrevented: boolean,
33
isActive: boolean,
34
|};
35
@@ -48,20 +45,11 @@ export type KeyboardEvent = {|
45
target: Element | Document,
46
type: KeyboardEventType,
47
timeStamp: number,
48
+ continuePropagation: () => void,
49
+ preventDefault: () => void,
50
|};
51
53
-type ModifiersObject = {|
54
- altKey?: boolean,
55
- ctrlKey?: boolean,
56
- metaKey?: boolean,
57
- shiftKey?: boolean,
58
-|};
59
-
60
-type PreventKeysArray = Array<string | Array<string | ModifiersObject>>;
61
-
62
-const isArray = Array.isArray;
52
const targetEventTypes = ['click_active', 'keydown_active', 'keyup'];
64
-const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'];
53
54
/**
55
* Normalization of deprecated HTML5 `key` values
@@ -146,20 +134,31 @@ function createKeyboardEvent(
134
event: ReactDOMResponderEvent,
135
context: ReactDOMResponderContext,
136
type: KeyboardEventType,
149
- defaultPrevented: boolean,
137
): KeyboardEvent {
138
const nativeEvent = (event: any).nativeEvent;
139
const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
140
let keyboardEvent = {
141
altKey,
142
ctrlKey,
156
- defaultPrevented,
143
+ defaultPrevented: nativeEvent.defaultPrevented === true,
144
metaKey,
145
pointerType: 'keyboard',
146
shiftKey,
147
target: event.target,
148
timeStamp: context.getTimeStamp(),
149
type,
150
+ // We don't use stopPropagation, as the default behavior
151
+ // is to not propagate. Plus, there might be confusion
152
+ // using stopPropagation as we don't actually stop
153
+ // native propagation from working, but instead only
154
+ // allow propagation to the others keyboard responders.
155
+ continuePropagation() {
156
+ context.continuePropagation();
157
+ },
158
+ preventDefault() {
159
+ keyboardEvent.defaultPrevented = true;
160
+ nativeEvent.preventDefault();
161
+ },
162
};
163
if (type !== 'keyboard:click') {
164
const key = getEventKey(nativeEvent);
@@ -171,32 +170,18 @@ function createKeyboardEvent(
170
171
function dispatchKeyboardEvent(
172
event: ReactDOMResponderEvent,
174
- listener: KeyboardEvent => ?boolean,
173
+ listener: KeyboardEvent => void,
174
context: ReactDOMResponderContext,
175
type: KeyboardEventType,
177
- defaultPrevented: boolean,
176
): void {
179
- const syntheticEvent = createKeyboardEvent(
180
- event,
181
- context,
182
- type,
183
- defaultPrevented,
184
- );
185
- let shouldPropagate;
186
- const listenerWithReturnValue = e => {
187
- shouldPropagate = listener(e);
188
- };
189
- context.dispatchEvent(syntheticEvent, listenerWithReturnValue, DiscreteEvent);
190
- if (shouldPropagate) {
191
- context.continuePropagation();
192
- }
177
+ const syntheticEvent = createKeyboardEvent(event, context, type);
178
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
179
}
180
181
const keyboardResponderImpl = {
182
targetEventTypes,
183
getInitialState(): KeyboardState {
184
return {
199
- defaultPrevented: false,
185
isActive: false,
186
};
187
},
@@ -207,71 +192,26 @@ const keyboardResponderImpl = {
192
state: KeyboardState,
193
): void {
194
const {type} = event;
210
- const nativeEvent: any = event.nativeEvent;
195
196
if (props.disabled) {
197
return;
198
}
199
200
if (type === 'keydown') {
217
- state.defaultPrevented = nativeEvent.defaultPrevented === true;
218
-
219
- const preventKeys = ((props.preventKeys: any): PreventKeysArray);
220
- if (!state.defaultPrevented && isArray(preventKeys)) {
221
- preventKeyLoop: for (let i = 0; i < preventKeys.length; i++) {
222
- const preventKey = preventKeys[i];
223
- let key = preventKey;
224
-
225
- if (isArray(preventKey)) {
226
- key = preventKey[0];
227
- const config = ((preventKey[1]: any): Object);
228
- for (let s = 0; s < modifiers.length; s++) {
229
- const modifier = modifiers[s];
230
- const configModifier = config[modifier];
231
- const eventModifier = nativeEvent[modifier];
232
- if (
233
- (configModifier && !eventModifier) ||
234
- (!configModifier && eventModifier)
235
- ) {
236
- continue preventKeyLoop;
237
- }
238
- }
239
- }
240
-
241
- if (key === getEventKey(nativeEvent)) {
242
- state.defaultPrevented = true;
243
- nativeEvent.preventDefault();
244
- break;
245
- }
246
- }
247
- }
201
state.isActive = true;
202
const onKeyDown = props.onKeyDown;
203
if (onKeyDown != null) {
204
dispatchKeyboardEvent(
205
event,
253
- ((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
206
+ ((onKeyDown: any): (e: KeyboardEvent) => void),
207
context,
208
'keyboard:keydown',
256
- state.defaultPrevented,
209
);
210
}
211
} else if (type === 'click' && isVirtualClick(event)) {
260
- if (props.preventClick !== false) {
261
- // 'click' occurs before or after 'keyup', and may need native
262
- // behavior prevented
263
- nativeEvent.preventDefault();
264
- state.defaultPrevented = true;
265
- }
212
const onClick = props.onClick;
213
if (onClick != null) {
268
- dispatchKeyboardEvent(
269
- event,
270
- onClick,
271
- context,
272
- 'keyboard:click',
273
- state.defaultPrevented,
274
- );
214
+ dispatchKeyboardEvent(event, onClick, context, 'keyboard:click');
215
}
216
} else if (type === 'keyup') {
217
state.isActive = false;
@@ -279,10 +219,9 @@ const keyboardResponderImpl = {
219
if (onKeyUp != null) {
220
dispatchKeyboardEvent(
221
event,
282
- ((onKeyUp: any): (e: KeyboardEvent) => ?boolean),
222
+ ((onKeyUp: any): (e: KeyboardEvent) => void),
223
context,
224
'keyboard:keyup',
285
- state.defaultPrevented,
225
);
226
}
227
}
packages/react-ui/events/src/dom/Press.js
+13
-6
@@ -81,6 +81,13 @@ function isValidKey(e): boolean {
81
);
82
}
83
84
+function handlePreventDefault(preventDefault: ?boolean, e: any): void {
85
+ const key = e.key;
86
+ if (preventDefault !== false && (key === ' ' || key === 'Enter')) {
87
+ e.preventDefault();
88
+ }
89
+}
90
+
91
/**
92
* The lack of built-in composition for gesture responders means we have to
93
* selectively ignore callbacks from useKeyboard or useTap if the other is
@@ -155,28 +162,30 @@ export function usePress(props: PressProps) {
162
163
const keyboard = useKeyboard({
164
disabled: disabled || active === 'tap',
158
- preventClick: preventDefault !== false,
159
- preventKeys: preventDefault !== false ? [' ', 'Enter'] : [],
165
onClick(e) {
166
+ if (preventDefault !== false) {
167
+ e.preventDefault();
168
+ }
169
if (active == null && onPress != null) {
170
onPress(createGestureState(e, 'press'));
171
}
172
},
173
onKeyDown(e) {
174
if (active == null && isValidKey(e)) {
175
+ handlePreventDefault(preventDefault, e);
176
updateActive('keyboard');
177
+
178
if (onPressStart != null) {
179
onPressStart(createGestureState(e, 'pressstart'));
180
}
181
if (onPressChange != null) {
182
onPressChange(true);
183
}
174
- // stop propagation
175
- return false;
184
}
185
},
186
onKeyUp(e) {
187
if (active === 'keyboard' && isValidKey(e)) {
188
+ handlePreventDefault(preventDefault, e);
189
if (onPressChange != null) {
190
onPressChange(false);
191
}
@@ -187,8 +196,6 @@ export function usePress(props: PressProps) {
196
onPress(createGestureState(e, 'press'));
197
}
198
updateActive(null);
190
- // stop propagation
191
- return false;
199
}
200
},
201
});
packages/react-ui/events/src/dom/__tests__/Keyboard-test.internal.js
+36
-22
@@ -41,9 +41,9 @@ describe('Keyboard responder', () => {
41
});
42
43
function renderPropagationTest(propagates) {
44
- const onClickInner = jest.fn(() => propagates);
45
- const onKeyDownInner = jest.fn(() => propagates);
46
- const onKeyUpInner = jest.fn(() => propagates);
44
+ const onClickInner = jest.fn(e => propagates && e.continuePropagation());
45
+ const onKeyDownInner = jest.fn(e => propagates && e.continuePropagation());
46
+ const onKeyUpInner = jest.fn(e => propagates && e.continuePropagation());
47
const onClickOuter = jest.fn();
48
const onKeyDownOuter = jest.fn();
49
const onKeyUpOuter = jest.fn();
@@ -77,7 +77,7 @@ describe('Keyboard responder', () => {
77
};
78
}
79
80
- test('propagates key event when a callback returns true', () => {
80
+ test('propagates key event when a continuePropagation() is used', () => {
81
const {
82
onClickInner,
83
onKeyDownInner,
@@ -99,7 +99,7 @@ describe('Keyboard responder', () => {
99
expect(onClickOuter).toBeCalled();
100
});
101
102
- test('does not propagate key event when a callback returns false', () => {
102
+ test('does not propagate key event by default', () => {
103
const {
104
onClickInner,
105
onKeyDownInner,
@@ -148,7 +148,9 @@ describe('Keyboard responder', () => {
148
let onClick, ref;
149
150
beforeEach(() => {
151
- onClick = jest.fn();
151
+ onClick = jest.fn(e => {
152
+ e.preventDefault();
153
+ });
154
ref = React.createRef();
155
const Component = () => {
156
const listener = useKeyboard({onClick});
@@ -346,7 +348,7 @@ describe('Keyboard responder', () => {
348
});
349
});
350
349
- describe('preventClick', () => {
351
+ describe('preventDefault for onClick', () => {
352
function render(props) {
353
const ref = React.createRef();
354
const Component = () => {
@@ -357,7 +359,7 @@ describe('Keyboard responder', () => {
359
return ref;
360
}
361
360
- test('prevents native click by default', () => {
362
+ test('does not prevent native click by default', () => {
363
const onClick = jest.fn();
364
const preventDefault = jest.fn();
365
const ref = render({onClick});
@@ -365,33 +367,33 @@ describe('Keyboard responder', () => {
367
const target = createEventTarget(ref.current);
368
target.virtualclick({preventDefault});
369
368
- expect(preventDefault).toBeCalled();
370
+ expect(preventDefault).not.toBeCalled();
371
expect(onClick).toHaveBeenCalledTimes(1);
372
expect(onClick).toHaveBeenCalledWith(
373
expect.objectContaining({
372
- defaultPrevented: true,
374
+ defaultPrevented: false,
375
}),
376
);
377
});
378
377
- test('allows native behaviour if false', () => {
378
- const onClick = jest.fn();
379
+ test('prevents native behaviour with preventDefault', () => {
380
+ const onClick = jest.fn(e => e.preventDefault());
381
const preventDefault = jest.fn();
380
- const ref = render({onClick, preventClick: false});
382
+ const ref = render({onClick});
383
384
const target = createEventTarget(ref.current);
385
target.virtualclick({preventDefault});
384
- expect(preventDefault).not.toBeCalled();
386
+ expect(preventDefault).toBeCalled();
387
expect(onClick).toHaveBeenCalledTimes(1);
388
expect(onClick).toHaveBeenCalledWith(
389
expect.objectContaining({
388
- defaultPrevented: false,
390
+ defaultPrevented: true,
391
}),
392
);
393
});
394
});
395
394
- describe('preventKeys', () => {
396
+ describe('preventDefault for onKeyDown', () => {
397
function render(props) {
398
const ref = React.createRef();
399
const Component = () => {
@@ -403,10 +405,14 @@ describe('Keyboard responder', () => {
405
}
406
407
test('key config matches', () => {
406
- const onKeyDown = jest.fn();
408
+ const onKeyDown = jest.fn(e => {
409
+ if (e.key === 'Tab') {
410
+ e.preventDefault();
411
+ }
412
+ });
413
const preventDefault = jest.fn();
414
const preventDefaultClick = jest.fn();
409
- const ref = render({onKeyDown, preventKeys: ['Tab']});
415
+ const ref = render({onKeyDown});
416
417
const target = createEventTarget(ref.current);
418
target.keydown({key: 'Tab', preventDefault});
@@ -424,9 +430,13 @@ describe('Keyboard responder', () => {
430
});
431
432
test('key config matches (modifier keys)', () => {
427
- const onKeyDown = jest.fn();
433
+ const onKeyDown = jest.fn(e => {
434
+ if (e.key === 'Tab' && e.shiftKey) {
435
+ e.preventDefault();
436
+ }
437
+ });
438
const preventDefault = jest.fn();
429
- const ref = render({onKeyDown, preventKeys: [['Tab', {shiftKey: true}]]});
439
+ const ref = render({onKeyDown});
440
441
const target = createEventTarget(ref.current);
442
target.keydown({key: 'Tab', preventDefault, shiftKey: true});
@@ -443,9 +453,13 @@ describe('Keyboard responder', () => {
453
});
454
455
test('key config does not match (modifier keys)', () => {
446
- const onKeyDown = jest.fn();
456
+ const onKeyDown = jest.fn(e => {
457
+ if (e.key === 'Tab' && e.shiftKey) {
458
+ e.preventDefault();
459
+ }
460
+ });
461
const preventDefault = jest.fn();
448
- const ref = render({onKeyDown, preventKeys: [['Tab', {shiftKey: true}]]});
462
+ const ref = render({onKeyDown});
463
464
const target = createEventTarget(ref.current);
465
target.keydown({key: 'Tab', preventDefault, shiftKey: false});