Skip to content

Commit 107521a

Browse files
authored
[react-events] Focus/FocusWithin responders with fallbacks (#16343)
Separate the PointerEvent and fallback implementations. Fix the unit tests to cover both PointerEvent and non-PointerEvent environments. Fix the focus-visible related callbacks to get called when keys other than "Tab" are used.
1 parent 7a7e792 commit 107521a

File tree

6 files changed

+405
-392
lines changed

6 files changed

+405
-392
lines changed

packages/react-events/src/dom/Focus.js

Lines changed: 57 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type FocusState = {
3333
isFocused: boolean,
3434
isFocusVisible: boolean,
3535
pointerType: PointerType,
36+
isEmulatingMouseEvents: boolean,
3637
};
3738

3839
type FocusProps = {
@@ -66,25 +67,12 @@ const isMac =
6667

6768
const targetEventTypes = ['focus', 'blur'];
6869

69-
const rootEventTypes = [
70-
'keydown',
71-
'keyup',
72-
'pointermove',
73-
'pointerdown',
74-
'pointerup',
75-
];
76-
77-
// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
78-
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
79-
rootEventTypes.push(
80-
'mousemove',
81-
'mousedown',
82-
'mouseup',
83-
'touchmove',
84-
'touchstart',
85-
'touchend',
86-
);
87-
}
70+
const hasPointerEvents =
71+
typeof window !== 'undefined' && window.PointerEvent != null;
72+
73+
const rootEventTypes = hasPointerEvents
74+
? ['keydown', 'keyup', 'pointermove', 'pointerdown', 'pointerup']
75+
: ['keydown', 'keyup', 'mousedown', 'touchmove', 'touchstart', 'touchend'];
8876

8977
function isFunction(obj): boolean {
9078
return typeof obj === 'function';
@@ -110,21 +98,15 @@ function handleRootPointerEvent(
11098
state: FocusState,
11199
callback: boolean => void,
112100
): void {
113-
const {type, target} = event;
114-
// Ignore a Safari quirks where 'mousemove' is dispatched on the 'html'
115-
// element when the window blurs.
116-
if (type === 'mousemove' && target.nodeName === 'HTML') {
117-
return;
118-
}
119-
101+
const {type} = event;
120102
isGlobalFocusVisible = false;
121103

122104
// Focus should stop being visible if a pointer is used on the element
123105
// after it was focused using a keyboard.
124106
const focusTarget = state.focusTarget;
125107
if (
126108
focusTarget !== null &&
127-
context.isTargetWithinNode(event.target, focusTarget) &&
109+
context.isTargetWithinResponderScope(focusTarget) &&
128110
(type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
129111
) {
130112
callback(false);
@@ -140,13 +122,6 @@ function handleRootEvent(
140122
const {type} = event;
141123

142124
switch (type) {
143-
case 'mousemove':
144-
case 'mousedown':
145-
case 'mouseup': {
146-
state.pointerType = 'mouse';
147-
handleRootPointerEvent(event, context, state, callback);
148-
break;
149-
}
150125
case 'pointermove':
151126
case 'pointerdown':
152127
case 'pointerup': {
@@ -156,27 +131,45 @@ function handleRootEvent(
156131
handleRootPointerEvent(event, context, state, callback);
157132
break;
158133
}
134+
135+
case 'keydown':
136+
case 'keyup': {
137+
const nativeEvent = event.nativeEvent;
138+
const focusTarget = state.focusTarget;
139+
const {key, metaKey, altKey, ctrlKey} = (nativeEvent: any);
140+
const validKey =
141+
key === 'Enter' ||
142+
key === ' ' ||
143+
(key === 'Tab' && !(metaKey || (!isMac && altKey) || ctrlKey));
144+
145+
if (validKey) {
146+
state.pointerType = 'keyboard';
147+
isGlobalFocusVisible = true;
148+
if (
149+
focusTarget !== null &&
150+
context.isTargetWithinResponderScope(focusTarget)
151+
) {
152+
callback(true);
153+
}
154+
}
155+
break;
156+
}
157+
158+
// fallbacks for no PointerEvent support
159159
case 'touchmove':
160160
case 'touchstart':
161161
case 'touchend': {
162162
state.pointerType = 'touch';
163+
state.isEmulatingMouseEvents = true;
163164
handleRootPointerEvent(event, context, state, callback);
164165
break;
165166
}
166-
167-
case 'keydown':
168-
case 'keyup': {
169-
const nativeEvent = event.nativeEvent;
170-
if (
171-
nativeEvent.key === 'Tab' &&
172-
!(
173-
nativeEvent.metaKey ||
174-
(!isMac && nativeEvent.altKey) ||
175-
nativeEvent.ctrlKey
176-
)
177-
) {
178-
state.pointerType = 'keyboard';
179-
isGlobalFocusVisible = true;
167+
case 'mousedown': {
168+
if (!state.isEmulatingMouseEvents) {
169+
state.pointerType = 'mouse';
170+
handleRootPointerEvent(event, context, state, callback);
171+
} else {
172+
state.isEmulatingMouseEvents = false;
180173
}
181174
break;
182175
}
@@ -271,6 +264,7 @@ const focusResponderImpl = {
271264
getInitialState(): FocusState {
272265
return {
273266
focusTarget: null,
267+
isEmulatingMouseEvents: false,
274268
isFocused: false,
275269
isFocusVisible: false,
276270
pointerType: '',
@@ -303,6 +297,7 @@ const focusResponderImpl = {
303297
state.isFocusVisible = isGlobalFocusVisible;
304298
dispatchFocusEvents(context, props, state);
305299
}
300+
state.isEmulatingMouseEvents = false;
306301
break;
307302
}
308303
case 'blur': {
@@ -311,6 +306,17 @@ const focusResponderImpl = {
311306
state.isFocusVisible = isGlobalFocusVisible;
312307
state.isFocused = false;
313308
}
309+
// This covers situations where focus is lost to another document in
310+
// the same window (e.g., iframes). Any action that restores focus to
311+
// the document (e.g., touch or click) first causes 'focus' to be
312+
// dispatched, which means the 'pointerType' we provide is stale
313+
// (it reflects the *previous* pointer). We cannot determine the
314+
// 'pointerType' in this case, so a blur with no
315+
// relatedTarget is used as a signal to reset the 'pointerType'.
316+
if (event.nativeEvent.relatedTarget == null) {
317+
state.pointerType = '';
318+
}
319+
state.isEmulatingMouseEvents = false;
314320
break;
315321
}
316322
}
@@ -322,7 +328,7 @@ const focusResponderImpl = {
322328
state: FocusState,
323329
): void {
324330
handleRootEvent(event, context, state, isFocusVisible => {
325-
if (state.isFocusVisible !== isFocusVisible) {
331+
if (state.isFocused && state.isFocusVisible !== isFocusVisible) {
326332
state.isFocusVisible = isFocusVisible;
327333
dispatchFocusVisibleChangeEvent(context, props, isFocusVisible);
328334
}
@@ -402,6 +408,7 @@ const focusWithinResponderImpl = {
402408
getInitialState(): FocusState {
403409
return {
404410
focusTarget: null,
411+
isEmulatingMouseEvents: false,
405412
isFocused: false,
406413
isFocusVisible: false,
407414
pointerType: '',
@@ -460,7 +467,7 @@ const focusWithinResponderImpl = {
460467
state: FocusState,
461468
): void {
462469
handleRootEvent(event, context, state, isFocusVisible => {
463-
if (state.isFocusVisible !== isFocusVisible) {
470+
if (state.isFocused && state.isFocusVisible !== isFocusVisible) {
464471
state.isFocusVisible = isFocusVisible;
465472
dispatchFocusWithinVisibleChangeEvent(
466473
context,

packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js

Lines changed: 13 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99

1010
'use strict';
1111

12-
import {createEvent, platform, setPointerEvent} from '../test-utils';
12+
import {
13+
dispatchLongPressContextMenu,
14+
dispatchRightClickContextMenu,
15+
dispatchModifiedClickContextMenu,
16+
platform,
17+
setPointerEvent,
18+
} from '../test-utils';
1319

1420
let React;
1521
let ReactFeatureFlags;
@@ -27,44 +33,6 @@ function initializeModules(hasPointerEvents) {
2733
.useContextMenuResponder;
2834
}
2935

30-
function dispatchContextMenuEvents(ref, options) {
31-
const preventDefault = options.preventDefault || function() {};
32-
const variant = (options.variant: 'mouse' | 'touch' | 'modified');
33-
const dispatchEvent = arg => ref.current.dispatchEvent(arg);
34-
35-
if (variant === 'mouse') {
36-
// right-click
37-
dispatchEvent(
38-
createEvent('pointerdown', {pointerType: 'mouse', button: 2}),
39-
);
40-
dispatchEvent(createEvent('mousedown', {button: 2}));
41-
dispatchEvent(createEvent('contextmenu', {button: 2, preventDefault}));
42-
} else if (variant === 'modified') {
43-
// left-click + ctrl
44-
dispatchEvent(
45-
createEvent('pointerdown', {pointerType: 'mouse', button: 0}),
46-
);
47-
dispatchEvent(createEvent('mousedown', {button: 0}));
48-
if (platform.get() === 'mac') {
49-
dispatchEvent(
50-
createEvent('contextmenu', {button: 0, ctrlKey: true, preventDefault}),
51-
);
52-
}
53-
} else if (variant === 'touch') {
54-
// long-press
55-
dispatchEvent(
56-
createEvent('pointerdown', {pointerType: 'touch', button: 0}),
57-
);
58-
dispatchEvent(
59-
createEvent('touchstart', {
60-
changedTouches: [],
61-
targetTouches: [],
62-
}),
63-
);
64-
dispatchEvent(createEvent('contextmenu', {button: 0, preventDefault}));
65-
}
66-
}
67-
6836
const forcePointerEvents = true;
6937
const table = [[forcePointerEvents], [!forcePointerEvents]];
7038

@@ -94,7 +62,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => {
9462
};
9563
ReactDOM.render(<Component />, container);
9664

97-
dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault});
65+
dispatchRightClickContextMenu(ref.current, {preventDefault});
9866
expect(preventDefault).toHaveBeenCalledTimes(1);
9967
expect(onContextMenu).toHaveBeenCalledTimes(1);
10068
expect(onContextMenu).toHaveBeenCalledWith(
@@ -112,7 +80,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => {
11280
};
11381
ReactDOM.render(<Component />, container);
11482

115-
dispatchContextMenuEvents(ref, {variant: 'touch', preventDefault});
83+
dispatchLongPressContextMenu(ref.current, {preventDefault});
11684
expect(preventDefault).toHaveBeenCalledTimes(1);
11785
expect(onContextMenu).toHaveBeenCalledTimes(1);
11886
expect(onContextMenu).toHaveBeenCalledWith(
@@ -132,7 +100,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => {
132100
};
133101
ReactDOM.render(<Component />, container);
134102

135-
dispatchContextMenuEvents(ref, 'mouse');
103+
dispatchRightClickContextMenu(ref.current);
136104
expect(onContextMenu).toHaveBeenCalledTimes(0);
137105
});
138106

@@ -149,7 +117,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => {
149117
};
150118
ReactDOM.render(<Component />, container);
151119

152-
dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault});
120+
dispatchRightClickContextMenu(ref.current, {preventDefault});
153121
expect(preventDefault).toHaveBeenCalledTimes(0);
154122
expect(onContextMenu).toHaveBeenCalledTimes(1);
155123
});
@@ -174,7 +142,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => {
174142
};
175143
ReactDOM.render(<Component />, container);
176144

177-
dispatchContextMenuEvents(ref, {variant: 'modified'});
145+
dispatchModifiedClickContextMenu(ref.current);
178146
expect(onContextMenu).toHaveBeenCalledTimes(1);
179147
expect(onContextMenu).toHaveBeenCalledWith(
180148
expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}),
@@ -201,7 +169,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => {
201169
};
202170
ReactDOM.render(<Component />, container);
203171

204-
dispatchContextMenuEvents(ref, {variant: 'modified'});
172+
dispatchModifiedClickContextMenu(ref.current);
205173
expect(onContextMenu).toHaveBeenCalledTimes(0);
206174
});
207175
});

0 commit comments

Comments
 (0)