Skip to content

Commit

Permalink
[react-events] Focus/FocusWithin responders with fallbacks
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 9, 2019
1 parent 5b00757 commit 2e42846
Show file tree
Hide file tree
Showing 6 changed files with 396 additions and 391 deletions.
97 changes: 48 additions & 49 deletions packages/react-events/src/dom/Focus.js
Original file line number Diff line number Diff line change
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,37 @@ function handleRootEvent(
handleRootPointerEvent(event, context, state, callback);
break;
}
case 'touchmove':
case 'touchstart':
case 'touchend': {
state.pointerType = 'touch';
handleRootPointerEvent(event, context, state, callback);
break;
}

case 'keydown':
case 'keyup': {
const nativeEvent = event.nativeEvent;
const focusTarget = state.focusTarget;
if (
nativeEvent.key === 'Tab' &&
!(
nativeEvent.metaKey ||
(!isMac && nativeEvent.altKey) ||
nativeEvent.ctrlKey
)
focusTarget !== null &&
context.isTargetWithinResponderScope(focusTarget)
) {
state.pointerType = 'keyboard';
isGlobalFocusVisible = true;
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 'mousedown': {
if (!state.isEmulatingMouseEvents) {
state.pointerType = 'mouse';
handleRootPointerEvent(event, context, state, callback);
} else {
state.isEmulatingMouseEvents = false;
}
break;
}
Expand Down Expand Up @@ -271,6 +256,7 @@ const focusResponderImpl = {
getInitialState(): FocusState {
return {
focusTarget: null,
isEmulatingMouseEvents: false,
isFocused: false,
isFocusVisible: false,
pointerType: '',
Expand Down Expand Up @@ -303,6 +289,7 @@ const focusResponderImpl = {
state.isFocusVisible = isGlobalFocusVisible;
dispatchFocusEvents(context, props, state);
}
state.isEmulatingMouseEvents = false;
break;
}
case 'blur': {
Expand All @@ -311,6 +298,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 +320,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 +400,7 @@ const focusWithinResponderImpl = {
getInitialState(): FocusState {
return {
focusTarget: null,
isEmulatingMouseEvents: false,
isFocused: false,
isFocusVisible: false,
pointerType: '',
Expand Down Expand Up @@ -460,7 +459,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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit 2e42846

Please sign in to comment.