Skip to content

Commit

Permalink
[react-events] Focus/FocusWithin responders with fallbacks (#16343)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
necolas committed Aug 12, 2019
1 parent 7a7e792 commit 107521a
Show file tree
Hide file tree
Showing 6 changed files with 405 additions and 392 deletions.
107 changes: 57 additions & 50 deletions packages/react-events/src/dom/Focus.js
Expand Up @@ -33,6 +33,7 @@ type FocusState = {
isFocused: boolean,
isFocusVisible: boolean,
pointerType: PointerType,
isEmulatingMouseEvents: boolean,
};

type FocusProps = {
Expand Down Expand Up @@ -66,25 +67,12 @@ const isMac =

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

const rootEventTypes = [
'keydown',
'keyup',
'pointermove',
'pointerdown',
'pointerup',
];

// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
rootEventTypes.push(
'mousemove',
'mousedown',
'mouseup',
'touchmove',
'touchstart',
'touchend',
);
}
const hasPointerEvents =
typeof window !== 'undefined' && window.PointerEvent != null;

const rootEventTypes = hasPointerEvents
? ['keydown', 'keyup', 'pointermove', 'pointerdown', 'pointerup']
: ['keydown', 'keyup', 'mousedown', 'touchmove', 'touchstart', 'touchend'];

function isFunction(obj): boolean {
return typeof obj === 'function';
Expand All @@ -110,21 +98,15 @@ function handleRootPointerEvent(
state: FocusState,
callback: boolean => void,
): void {
const {type, target} = event;
// Ignore a Safari quirks where 'mousemove' is dispatched on the 'html'
// element when the window blurs.
if (type === 'mousemove' && target.nodeName === 'HTML') {
return;
}

const {type} = event;
isGlobalFocusVisible = false;

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

switch (type) {
case 'mousemove':
case 'mousedown':
case 'mouseup': {
state.pointerType = 'mouse';
handleRootPointerEvent(event, context, state, callback);
break;
}
case 'pointermove':
case 'pointerdown':
case 'pointerup': {
Expand All @@ -156,27 +131,45 @@ function handleRootEvent(
handleRootPointerEvent(event, context, state, callback);
break;
}

case 'keydown':
case 'keyup': {
const nativeEvent = event.nativeEvent;
const focusTarget = state.focusTarget;
const {key, metaKey, altKey, ctrlKey} = (nativeEvent: any);
const validKey =
key === 'Enter' ||
key === ' ' ||
(key === 'Tab' && !(metaKey || (!isMac && altKey) || ctrlKey));

if (validKey) {
state.pointerType = 'keyboard';
isGlobalFocusVisible = true;
if (
focusTarget !== null &&
context.isTargetWithinResponderScope(focusTarget)
) {
callback(true);
}
}
break;
}

// fallbacks for no PointerEvent support
case 'touchmove':
case 'touchstart':
case 'touchend': {
state.pointerType = 'touch';
state.isEmulatingMouseEvents = true;
handleRootPointerEvent(event, context, state, callback);
break;
}

case 'keydown':
case 'keyup': {
const nativeEvent = event.nativeEvent;
if (
nativeEvent.key === 'Tab' &&
!(
nativeEvent.metaKey ||
(!isMac && nativeEvent.altKey) ||
nativeEvent.ctrlKey
)
) {
state.pointerType = 'keyboard';
isGlobalFocusVisible = true;
case 'mousedown': {
if (!state.isEmulatingMouseEvents) {
state.pointerType = 'mouse';
handleRootPointerEvent(event, context, state, callback);
} else {
state.isEmulatingMouseEvents = false;
}
break;
}
Expand Down Expand Up @@ -271,6 +264,7 @@ const focusResponderImpl = {
getInitialState(): FocusState {
return {
focusTarget: null,
isEmulatingMouseEvents: false,
isFocused: false,
isFocusVisible: false,
pointerType: '',
Expand Down Expand Up @@ -303,6 +297,7 @@ const focusResponderImpl = {
state.isFocusVisible = isGlobalFocusVisible;
dispatchFocusEvents(context, props, state);
}
state.isEmulatingMouseEvents = false;
break;
}
case 'blur': {
Expand All @@ -311,6 +306,17 @@ const focusResponderImpl = {
state.isFocusVisible = isGlobalFocusVisible;
state.isFocused = false;
}
// This covers situations where focus is lost to another document in
// the same window (e.g., iframes). Any action that restores focus to
// the document (e.g., touch or click) first causes 'focus' to be
// dispatched, which means the 'pointerType' we provide is stale
// (it reflects the *previous* pointer). We cannot determine the
// 'pointerType' in this case, so a blur with no
// relatedTarget is used as a signal to reset the 'pointerType'.
if (event.nativeEvent.relatedTarget == null) {
state.pointerType = '';
}
state.isEmulatingMouseEvents = false;
break;
}
}
Expand All @@ -322,7 +328,7 @@ const focusResponderImpl = {
state: FocusState,
): void {
handleRootEvent(event, context, state, isFocusVisible => {
if (state.isFocusVisible !== isFocusVisible) {
if (state.isFocused && state.isFocusVisible !== isFocusVisible) {
state.isFocusVisible = isFocusVisible;
dispatchFocusVisibleChangeEvent(context, props, isFocusVisible);
}
Expand Down Expand Up @@ -402,6 +408,7 @@ const focusWithinResponderImpl = {
getInitialState(): FocusState {
return {
focusTarget: null,
isEmulatingMouseEvents: false,
isFocused: false,
isFocusVisible: false,
pointerType: '',
Expand Down Expand Up @@ -460,7 +467,7 @@ const focusWithinResponderImpl = {
state: FocusState,
): void {
handleRootEvent(event, context, state, isFocusVisible => {
if (state.isFocusVisible !== isFocusVisible) {
if (state.isFocused && state.isFocusVisible !== isFocusVisible) {
state.isFocusVisible = isFocusVisible;
dispatchFocusWithinVisibleChangeEvent(
context,
Expand Down
Expand Up @@ -9,7 +9,13 @@

'use strict';

import {createEvent, platform, setPointerEvent} from '../test-utils';
import {
dispatchLongPressContextMenu,
dispatchRightClickContextMenu,
dispatchModifiedClickContextMenu,
platform,
setPointerEvent,
} from '../test-utils';

let React;
let ReactFeatureFlags;
Expand All @@ -27,44 +33,6 @@ function initializeModules(hasPointerEvents) {
.useContextMenuResponder;
}

function dispatchContextMenuEvents(ref, options) {
const preventDefault = options.preventDefault || function() {};
const variant = (options.variant: 'mouse' | 'touch' | 'modified');
const dispatchEvent = arg => ref.current.dispatchEvent(arg);

if (variant === 'mouse') {
// right-click
dispatchEvent(
createEvent('pointerdown', {pointerType: 'mouse', button: 2}),
);
dispatchEvent(createEvent('mousedown', {button: 2}));
dispatchEvent(createEvent('contextmenu', {button: 2, preventDefault}));
} else if (variant === 'modified') {
// left-click + ctrl
dispatchEvent(
createEvent('pointerdown', {pointerType: 'mouse', button: 0}),
);
dispatchEvent(createEvent('mousedown', {button: 0}));
if (platform.get() === 'mac') {
dispatchEvent(
createEvent('contextmenu', {button: 0, ctrlKey: true, preventDefault}),
);
}
} else if (variant === 'touch') {
// long-press
dispatchEvent(
createEvent('pointerdown', {pointerType: 'touch', button: 0}),
);
dispatchEvent(
createEvent('touchstart', {
changedTouches: [],
targetTouches: [],
}),
);
dispatchEvent(createEvent('contextmenu', {button: 0, preventDefault}));
}
}

const forcePointerEvents = true;
const table = [[forcePointerEvents], [!forcePointerEvents]];

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

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

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

dispatchContextMenuEvents(ref, 'mouse');
dispatchRightClickContextMenu(ref.current);
expect(onContextMenu).toHaveBeenCalledTimes(0);
});

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

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

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

dispatchContextMenuEvents(ref, {variant: 'modified'});
dispatchModifiedClickContextMenu(ref.current);
expect(onContextMenu).toHaveBeenCalledTimes(0);
});
});
Expand Down

0 comments on commit 107521a

Please sign in to comment.