[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.
This commit is contained in:
Nicolas Gallagher
2019-08-12 10:10:51 -07:00
committed by GitHub
parent e26e3e2623
commit dde9dedfc8
6 changed files with 405 additions and 392 deletions
+57 -50
View File
@@ -33,6 +33,7 @@ type FocusState = {
isFocused: boolean,
isFocusVisible: boolean,
pointerType: PointerType,
isEmulatingMouseEvents: boolean,
};
type FocusProps = {
@@ -66,25 +67,12 @@ const isMac =
const targetEventTypes = ['focus', 'blur'];
const rootEventTypes = [
'keydown',
'keyup',
'pointermove',
'pointerdown',
'pointerup',
];
const hasPointerEvents =
typeof window !== 'undefined' && window.PointerEvent != null;
// 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 rootEventTypes = hasPointerEvents
? ['keydown', 'keyup', 'pointermove', 'pointerdown', 'pointerup']
: ['keydown', 'keyup', 'mousedown', 'touchmove', 'touchstart', 'touchend'];
function isFunction(obj): boolean {
return typeof obj === 'function';
@@ -110,13 +98,7 @@ 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
@@ -124,7 +106,7 @@ function handleRootPointerEvent(
const focusTarget = state.focusTarget;
if (
focusTarget !== null &&
context.isTargetWithinNode(event.target, focusTarget) &&
context.isTargetWithinResponderScope(focusTarget) &&
(type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
) {
callback(false);
@@ -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': {
@@ -156,27 +131,45 @@ 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;
if (
nativeEvent.key === 'Tab' &&
!(
nativeEvent.metaKey ||
(!isMac && nativeEvent.altKey) ||
nativeEvent.ctrlKey
)
) {
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 'mousedown': {
if (!state.isEmulatingMouseEvents) {
state.pointerType = 'mouse';
handleRootPointerEvent(event, context, state, callback);
} else {
state.isEmulatingMouseEvents = false;
}
break;
}
@@ -271,6 +264,7 @@ const focusResponderImpl = {
getInitialState(): FocusState {
return {
focusTarget: null,
isEmulatingMouseEvents: false,
isFocused: false,
isFocusVisible: false,
pointerType: '',
@@ -303,6 +297,7 @@ const focusResponderImpl = {
state.isFocusVisible = isGlobalFocusVisible;
dispatchFocusEvents(context, props, state);
}
state.isEmulatingMouseEvents = false;
break;
}
case 'blur': {
@@ -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;
}
}
@@ -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);
}
@@ -402,6 +408,7 @@ const focusWithinResponderImpl = {
getInitialState(): FocusState {
return {
focusTarget: null,
isEmulatingMouseEvents: false,
isFocused: false,
isFocusVisible: false,
pointerType: '',
@@ -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,
@@ -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;
@@ -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]];
@@ -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(
@@ -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(
@@ -132,7 +100,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => {
};
ReactDOM.render(<Component />, container);
dispatchContextMenuEvents(ref, 'mouse');
dispatchRightClickContextMenu(ref.current);
expect(onContextMenu).toHaveBeenCalledTimes(0);
});
@@ -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);
});
@@ -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'}),
@@ -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);
});
});
@@ -9,47 +9,41 @@
'use strict';
import {
blur,
focus,
keydown,
setPointerEvent,
platform,
dispatchPointerPressDown,
dispatchPointerPressRelease,
} from '../test-utils';
let React;
let ReactFeatureFlags;
let ReactDOM;
let FocusResponder;
let useFocusResponder;
const createEvent = (type, data) => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent(type, true, true);
if (data != null) {
Object.entries(data).forEach(([key, value]) => {
event[key] = value;
});
}
return event;
};
const createKeyboardEvent = (type, data) => {
return new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
...data,
});
};
const modulesInit = () => {
function initializeModules(hasPointerEvents) {
setPointerEvent(hasPointerEvents);
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableFlareAPI = true;
React = require('react');
ReactDOM = require('react-dom');
FocusResponder = require('react-events/focus').FocusResponder;
useFocusResponder = require('react-events/focus').useFocusResponder;
};
}
describe('Focus event responder', () => {
const forcePointerEvents = true;
const table = [[forcePointerEvents], [!forcePointerEvents]];
describe.each(table)('Focus responder', hasPointerEvents => {
let container;
beforeEach(() => {
jest.resetModules();
modulesInit();
initializeModules(hasPointerEvents);
container = document.createElement('div');
document.body.appendChild(container);
});
@@ -78,9 +72,10 @@ describe('Focus event responder', () => {
ReactDOM.render(<Component />, container);
});
it('prevents custom events being dispatched', () => {
ref.current.dispatchEvent(createEvent('focus'));
ref.current.dispatchEvent(createEvent('blur'));
it('does not call callbacks', () => {
const dispatch = arg => ref.current.dispatchEvent(arg);
dispatch(focus());
dispatch(blur());
expect(onFocus).not.toBeCalled();
expect(onBlur).not.toBeCalled();
});
@@ -102,8 +97,9 @@ describe('Focus event responder', () => {
});
it('is called after "blur" event', () => {
ref.current.dispatchEvent(createEvent('focus'));
ref.current.dispatchEvent(createEvent('blur'));
const dispatch = arg => ref.current.dispatchEvent(arg);
dispatch(focus());
dispatch(blur());
expect(onBlur).toHaveBeenCalledTimes(1);
});
});
@@ -131,83 +127,52 @@ describe('Focus event responder', () => {
beforeEach(componentInit);
it('is called after "focus" event', () => {
ref.current.dispatchEvent(createEvent('focus'));
ref.current.dispatchEvent(focus());
expect(onFocus).toHaveBeenCalledTimes(1);
});
it('is not called if descendants of target receive focus', () => {
const target = innerRef.current;
target.dispatchEvent(createEvent('focus'));
innerRef.current.dispatchEvent(focus());
expect(onFocus).not.toBeCalled();
});
it('is called with the correct pointerType using pointer events', () => {
// Pointer mouse
ref.current.dispatchEvent(
createEvent('pointerdown', {
pointerType: 'mouse',
}),
);
ref.current.dispatchEvent(createEvent('focus'));
it('is called with the correct pointerType: mouse', () => {
const target = ref.current;
dispatchPointerPressDown(target, {pointerType: 'mouse'});
dispatchPointerPressRelease(target, {pointerType: 'mouse'});
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'mouse'}),
);
ref.current.dispatchEvent(createEvent('blur'));
// Pointer touch
ref.current.dispatchEvent(
createEvent('pointerdown', {
pointerType: 'touch',
}),
);
ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(2);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'touch'}),
);
ref.current.dispatchEvent(createEvent('blur'));
// Pointer pen
ref.current.dispatchEvent(
createEvent('pointerdown', {
pointerType: 'pen',
}),
);
ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(3);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'pen'}),
);
});
it('is called with the correct pointerType without pointer events', () => {
// Mouse
ref.current.dispatchEvent(createEvent('mousedown'));
ref.current.dispatchEvent(createEvent('focus'));
it('is called with the correct pointerType: touch', () => {
const target = ref.current;
dispatchPointerPressDown(target, {pointerType: 'touch'});
dispatchPointerPressRelease(target, {pointerType: 'touch'});
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'mouse'}),
);
ref.current.dispatchEvent(createEvent('blur'));
// Touch
ref.current.dispatchEvent(createEvent('touchstart'));
ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(2);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'touch'}),
);
});
if (hasPointerEvents) {
it('is called with the correct pointerType: pen', () => {
const target = ref.current;
dispatchPointerPressDown(target, {pointerType: 'pen'});
dispatchPointerPressRelease(target, {pointerType: 'pen'});
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'pen'}),
);
});
}
it('is called with the correct pointerType using a keyboard', () => {
const target = ref.current;
// Keyboard tab
ref.current.dispatchEvent(
createEvent('keydown', {
key: 'Tab',
}),
);
ref.current.dispatchEvent(createEvent('focus'));
target.dispatchEvent(keydown({key: 'Tab'}));
target.dispatchEvent(focus());
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'keyboard'}),
@@ -215,19 +180,14 @@ describe('Focus event responder', () => {
});
it('is called with the correct pointerType using Tab+altKey on Mac', () => {
platform.set('mac');
jest.resetModules();
const platformGetter = jest.spyOn(global.navigator, 'platform', 'get');
platformGetter.mockReturnValue('MacIntel');
modulesInit();
initializeModules();
componentInit();
const target = ref.current;
ref.current.dispatchEvent(
createEvent('keydown', {
key: 'Tab',
altKey: true,
}),
);
ref.current.dispatchEvent(createEvent('focus'));
target.dispatchEvent(keydown({key: 'Tab', altKey: true}));
target.dispatchEvent(focus());
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({
@@ -235,7 +195,7 @@ describe('Focus event responder', () => {
}),
);
platformGetter.mockClear();
platform.clear();
});
});
@@ -260,18 +220,20 @@ describe('Focus event responder', () => {
});
it('is called after "blur" and "focus" events', () => {
ref.current.dispatchEvent(createEvent('focus'));
const target = ref.current;
target.dispatchEvent(focus());
expect(onFocusChange).toHaveBeenCalledTimes(1);
expect(onFocusChange).toHaveBeenCalledWith(true);
ref.current.dispatchEvent(createEvent('blur'));
target.dispatchEvent(blur());
expect(onFocusChange).toHaveBeenCalledTimes(2);
expect(onFocusChange).toHaveBeenCalledWith(false);
});
it('is not called after "blur" and "focus" events on descendants', () => {
innerRef.current.dispatchEvent(createEvent('focus'));
const target = innerRef.current;
target.dispatchEvent(focus());
expect(onFocusChange).toHaveBeenCalledTimes(0);
innerRef.current.dispatchEvent(createEvent('blur'));
target.dispatchEvent(blur());
expect(onFocusChange).toHaveBeenCalledTimes(0);
});
});
@@ -297,52 +259,48 @@ describe('Focus event responder', () => {
});
it('is called after "focus" and "blur" if keyboard navigation is active', () => {
const target = ref.current;
// use keyboard first
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
ref.current.dispatchEvent(createEvent('focus'));
container.dispatchEvent(keydown({key: 'Tab'}));
target.dispatchEvent(focus());
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
ref.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
target.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
});
it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => {
const target = ref.current;
// use keyboard first
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
ref.current.dispatchEvent(createEvent('focus'));
container.dispatchEvent(keydown({key: 'Tab'}));
target.dispatchEvent(focus());
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
// then use pointer on the target, focus should no longer be visible
ref.current.dispatchEvent(createEvent('pointerdown'));
dispatchPointerPressDown(target);
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
// onFocusVisibleChange should not be called again
ref.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
target.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
});
it('is not called after "focus" and "blur" events without keyboard', () => {
ref.current.dispatchEvent(createEvent('pointerdown'));
ref.current.dispatchEvent(createEvent('focus'));
container.dispatchEvent(createEvent('pointerdown'));
ref.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
const target = ref.current;
dispatchPointerPressDown(target);
dispatchPointerPressRelease(target);
dispatchPointerPressDown(container);
target.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
});
it('is not called after "blur" and "focus" events on descendants', () => {
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
innerRef.current.dispatchEvent(createEvent('focus'));
const target = innerRef.current;
container.dispatchEvent(keydown({key: 'Tab'}));
target.dispatchEvent(focus());
expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
target.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
});
});
@@ -380,10 +338,10 @@ describe('Focus event responder', () => {
ReactDOM.render(<Outer />, container);
outerRef.current.dispatchEvent(createEvent('focus'));
outerRef.current.dispatchEvent(createEvent('blur'));
innerRef.current.dispatchEvent(createEvent('focus'));
innerRef.current.dispatchEvent(createEvent('blur'));
outerRef.current.dispatchEvent(focus());
outerRef.current.dispatchEvent(blur());
innerRef.current.dispatchEvent(focus());
innerRef.current.dispatchEvent(blur());
expect(events).toEqual([
'outer: onFocus',
'outer: onFocusChange',
@@ -9,32 +9,24 @@
'use strict';
import {
blur,
focus,
keydown,
setPointerEvent,
dispatchPointerPressDown,
dispatchPointerPressRelease,
} from '../test-utils';
let React;
let ReactFeatureFlags;
let ReactDOM;
let FocusWithinResponder;
let useFocusWithinResponder;
const createEvent = (type, data) => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent(type, true, true);
if (data != null) {
Object.entries(data).forEach(([key, value]) => {
event[key] = value;
});
}
return event;
};
const createKeyboardEvent = (type, data) => {
return new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
...data,
});
};
const modulesInit = () => {
const initializeModules = hasPointerEvents => {
setPointerEvent(hasPointerEvents);
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableFlareAPI = true;
React = require('react');
@@ -44,13 +36,14 @@ const modulesInit = () => {
.useFocusWithinResponder;
};
describe('FocusWithin event responder', () => {
const forcePointerEvents = true;
const table = [[forcePointerEvents], [!forcePointerEvents]];
describe.each(table)('FocusWithin responder', hasPointerEvents => {
let container;
beforeEach(() => {
jest.resetModules();
modulesInit();
initializeModules();
container = document.createElement('div');
document.body.appendChild(container);
});
@@ -80,8 +73,9 @@ describe('FocusWithin event responder', () => {
});
it('prevents custom events being dispatched', () => {
ref.current.dispatchEvent(createEvent('focus'));
ref.current.dispatchEvent(createEvent('blur'));
const target = ref.current;
target.dispatchEvent(focus());
target.dispatchEvent(blur());
expect(onFocusWithinChange).not.toBeCalled();
expect(onFocusWithinVisibleChange).not.toBeCalled();
});
@@ -110,49 +104,42 @@ describe('FocusWithin event responder', () => {
});
it('is called after "blur" and "focus" events on focus target', () => {
ref.current.dispatchEvent(createEvent('focus'));
const target = ref.current;
target.dispatchEvent(focus());
expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
expect(onFocusWithinChange).toHaveBeenCalledWith(true);
ref.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
target.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
expect(onFocusWithinChange).toHaveBeenCalledWith(false);
});
it('is called after "blur" and "focus" events on descendants', () => {
innerRef.current.dispatchEvent(createEvent('focus'));
const target = innerRef.current;
target.dispatchEvent(focus());
expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
expect(onFocusWithinChange).toHaveBeenCalledWith(true);
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
target.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
expect(onFocusWithinChange).toHaveBeenCalledWith(false);
});
it('is only called once when focus moves within and outside the subtree', () => {
const target = ref.current;
const innerTarget1 = innerRef.current;
const innerTarget2 = innerRef2.current;
// focus shifts into subtree
innerRef.current.dispatchEvent(createEvent('focus'));
innerTarget1.dispatchEvent(focus());
expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
expect(onFocusWithinChange).toHaveBeenCalledWith(true);
// focus moves around subtree
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: innerRef2.current}),
);
innerRef2.current.dispatchEvent(createEvent('focus'));
innerRef2.current.dispatchEvent(
createEvent('blur', {relatedTarget: ref.current}),
);
ref.current.dispatchEvent(createEvent('focus'));
ref.current.dispatchEvent(
createEvent('blur', {relatedTarget: innerRef.current}),
);
innerTarget1.dispatchEvent(blur({relatedTarget: innerTarget2}));
innerTarget2.dispatchEvent(focus());
innerTarget2.dispatchEvent(blur({relatedTarget: target}));
target.dispatchEvent(focus());
target.dispatchEvent(blur({relatedTarget: innerTarget1}));
expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
// focus shifts outside subtree
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
innerTarget1.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
expect(onFocusWithinChange).toHaveBeenCalledWith(false);
});
@@ -181,102 +168,87 @@ describe('FocusWithin event responder', () => {
});
it('is called after "focus" and "blur" on focus target if keyboard was used', () => {
const target = ref.current;
// use keyboard first
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
ref.current.dispatchEvent(createEvent('focus'));
container.dispatchEvent(keydown({key: 'Tab'}));
target.dispatchEvent(focus());
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
ref.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
target.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
});
it('is called after "focus" and "blur" on descendants if keyboard was used', () => {
const innerTarget = innerRef.current;
// use keyboard first
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
innerRef.current.dispatchEvent(createEvent('focus'));
container.dispatchEvent(keydown({key: 'Tab'}));
innerTarget.dispatchEvent(focus());
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
innerTarget.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
});
it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => {
const target = ref.current;
const innerTarget1 = innerRef.current;
const innerTarget2 = innerRef2.current;
// use keyboard first
ref.current.dispatchEvent(createEvent('focus'));
ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
ref.current.dispatchEvent(
createEvent('blur', {relatedTarget: innerRef.current}),
);
innerRef.current.dispatchEvent(createEvent('focus'));
target.dispatchEvent(focus());
target.dispatchEvent(keydown({key: 'Tab'}));
target.dispatchEvent(blur({relatedTarget: innerTarget1}));
innerTarget1.dispatchEvent(focus());
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
// then use pointer on the next target, focus should no longer be visible
innerRef2.current.dispatchEvent(createEvent('pointerdown'));
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: innerRef2.current}),
);
innerRef2.current.dispatchEvent(createEvent('focus'));
dispatchPointerPressDown(innerTarget2);
innerTarget1.dispatchEvent(blur({relatedTarget: innerTarget2}));
innerTarget2.dispatchEvent(focus());
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
// then use keyboard again
innerRef2.current.dispatchEvent(
createKeyboardEvent('keydown', {key: 'Tab', shiftKey: true}),
);
innerRef2.current.dispatchEvent(
createEvent('blur', {relatedTarget: innerRef.current}),
);
innerRef.current.dispatchEvent(createEvent('focus'));
innerTarget2.dispatchEvent(keydown({key: 'Tab', shiftKey: true}));
innerTarget2.dispatchEvent(blur({relatedTarget: innerTarget1}));
innerTarget1.dispatchEvent(focus());
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(3);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
// then use pointer on the target, focus should no longer be visible
innerRef.current.dispatchEvent(createEvent('pointerdown'));
dispatchPointerPressDown(innerTarget1);
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(4);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
// onFocusVisibleChange should not be called again
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
innerTarget1.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(4);
});
it('is not called after "focus" and "blur" events without keyboard', () => {
innerRef.current.dispatchEvent(createEvent('pointerdown'));
innerRef.current.dispatchEvent(createEvent('focus'));
container.dispatchEvent(createEvent('pointerdown'));
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
const innerTarget = innerRef.current;
dispatchPointerPressDown(innerTarget);
dispatchPointerPressRelease(innerTarget);
innerTarget.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(0);
});
it('is only called once when focus moves within and outside the subtree', () => {
const target = ref.current;
const innerTarget1 = innerRef.current;
const innerTarget2 = innerRef2.current;
// focus shifts into subtree
innerRef.current.dispatchEvent(createEvent('focus'));
innerTarget1.dispatchEvent(focus());
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
// focus moves around subtree
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: innerRef2.current}),
);
innerRef2.current.dispatchEvent(createEvent('focus'));
innerRef2.current.dispatchEvent(
createEvent('blur', {relatedTarget: ref.current}),
);
ref.current.dispatchEvent(createEvent('focus'));
ref.current.dispatchEvent(
createEvent('blur', {relatedTarget: innerRef.current}),
);
innerTarget1.dispatchEvent(blur({relatedTarget: innerTarget2}));
innerTarget2.dispatchEvent(focus());
innerTarget2.dispatchEvent(blur({relatedTarget: target}));
target.dispatchEvent(focus());
target.dispatchEvent(blur({relatedTarget: innerTarget1}));
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
// focus shifts outside subtree
innerRef.current.dispatchEvent(
createEvent('blur', {relatedTarget: container}),
);
innerTarget1.dispatchEvent(blur({relatedTarget: container}));
expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
});
@@ -76,9 +76,10 @@ describe.each(table)('Hover responder', hasPointerEvents => {
ReactDOM.render(<Component />, container);
});
it('prevents custom events being dispatched', () => {
dispatchPointerHoverEnter(ref);
dispatchPointerHoverExit(ref);
it('does not call callbacks', () => {
const target = ref.current;
dispatchPointerHoverEnter(target);
dispatchPointerHoverExit(target);
expect(onHoverChange).not.toBeCalled();
expect(onHoverStart).not.toBeCalled();
expect(onHoverMove).not.toBeCalled();
@@ -102,18 +103,21 @@ describe.each(table)('Hover responder', hasPointerEvents => {
});
it('is called for mouse pointers', () => {
dispatchPointerHoverEnter(ref);
const target = ref.current;
dispatchPointerHoverEnter(target);
expect(onHoverStart).toHaveBeenCalledTimes(1);
});
it('is not called for touch pointers', () => {
dispatchTouchTap(ref);
const target = ref.current;
dispatchTouchTap(target);
expect(onHoverStart).not.toBeCalled();
});
it('is called if a mouse pointer is used after a touch pointer', () => {
dispatchTouchTap(ref);
dispatchPointerHoverEnter(ref);
const target = ref.current;
dispatchTouchTap(target);
dispatchPointerHoverEnter(target);
expect(onHoverStart).toHaveBeenCalledTimes(1);
});
});
@@ -134,16 +138,18 @@ describe.each(table)('Hover responder', hasPointerEvents => {
});
it('is called for mouse pointers', () => {
dispatchPointerHoverEnter(ref);
const target = ref.current;
dispatchPointerHoverEnter(target);
expect(onHoverChange).toHaveBeenCalledTimes(1);
expect(onHoverChange).toHaveBeenCalledWith(true);
dispatchPointerHoverExit(ref);
dispatchPointerHoverExit(target);
expect(onHoverChange).toHaveBeenCalledTimes(2);
expect(onHoverChange).toHaveBeenCalledWith(false);
});
it('is not called for touch pointers', () => {
dispatchTouchTap(ref);
const target = ref.current;
dispatchTouchTap(target);
expect(onHoverChange).not.toBeCalled();
});
});
@@ -164,28 +170,31 @@ describe.each(table)('Hover responder', hasPointerEvents => {
});
it('is called for mouse pointers', () => {
dispatchPointerHoverEnter(ref);
dispatchPointerHoverExit(ref);
const target = ref.current;
dispatchPointerHoverEnter(target);
dispatchPointerHoverExit(target);
expect(onHoverEnd).toHaveBeenCalledTimes(1);
});
if (hasPointerEvents) {
it('is called once for cancelled mouse pointers', () => {
dispatchPointerHoverEnter(ref);
dispatchPointerCancel(ref);
const target = ref.current;
dispatchPointerHoverEnter(target);
dispatchPointerCancel(target);
expect(onHoverEnd).toHaveBeenCalledTimes(1);
// only called once if cancel follows exit
onHoverEnd.mockReset();
dispatchPointerHoverEnter(ref);
dispatchPointerHoverExit(ref);
dispatchPointerCancel(ref);
dispatchPointerHoverEnter(target);
dispatchPointerHoverExit(target);
dispatchPointerCancel(target);
expect(onHoverEnd).toHaveBeenCalledTimes(1);
});
}
it('is not called for touch pointers', () => {
dispatchTouchTap(ref);
const target = ref.current;
dispatchTouchTap(target);
expect(onHoverEnd).not.toBeCalled();
});
});
@@ -201,8 +210,10 @@ describe.each(table)('Hover responder', hasPointerEvents => {
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
dispatchPointerHoverEnter(ref);
dispatchPointerHoverMove(ref, {from: {x: 0, y: 0}, to: {x: 1, y: 1}});
const target = ref.current;
dispatchPointerHoverEnter(target);
dispatchPointerHoverMove(target, {from: {x: 0, y: 0}, to: {x: 1, y: 1}});
expect(onHoverMove).toHaveBeenCalledTimes(2);
expect(onHoverMove).toHaveBeenCalledWith(
expect.objectContaining({type: 'hovermove'}),
@@ -242,12 +253,15 @@ describe.each(table)('Hover responder', hasPointerEvents => {
};
ReactDOM.render(<Outer />, container);
dispatchPointerHoverEnter(outerRef, {relatedTarget: container});
dispatchPointerHoverExit(outerRef, {relatedTarget: innerRef.current});
dispatchPointerHoverEnter(innerRef, {relatedTarget: outerRef.current});
dispatchPointerHoverExit(innerRef, {relatedTarget: outerRef.current});
dispatchPointerHoverEnter(outerRef, {relatedTarget: innerRef.current});
dispatchPointerHoverExit(outerRef, {relatedTarget: container});
const innerTarget = innerRef.current;
const outerTarget = outerRef.current;
dispatchPointerHoverEnter(outerTarget, {relatedTarget: container});
dispatchPointerHoverExit(outerTarget, {relatedTarget: innerTarget});
dispatchPointerHoverEnter(innerTarget, {relatedTarget: outerTarget});
dispatchPointerHoverExit(innerTarget, {relatedTarget: outerTarget});
dispatchPointerHoverEnter(outerTarget, {relatedTarget: innerTarget});
dispatchPointerHoverExit(outerTarget, {relatedTarget: container});
expect(events).toEqual([
'outer: onHoverStart',
@@ -300,9 +314,14 @@ describe.each(table)('Hover responder', hasPointerEvents => {
};
ReactDOM.render(<Component />, container);
dispatchPointerHoverEnter(ref, {x: 10, y: 10});
dispatchPointerHoverMove(ref, {from: {x: 10, y: 10}, to: {x: 20, y: 20}});
dispatchPointerHoverExit(ref, {x: 20, y: 20});
const target = ref.current;
dispatchPointerHoverEnter(target, {x: 10, y: 10});
dispatchPointerHoverMove(target, {
from: {x: 10, y: 10},
to: {x: 20, y: 20},
});
dispatchPointerHoverExit(target, {x: 20, y: 20});
expect(eventLog).toEqual([
{
@@ -312,7 +331,7 @@ describe.each(table)('Hover responder', hasPointerEvents => {
pageY: 10,
clientX: 10,
clientY: 10,
target: ref.current,
target,
timeStamp: timeStamps[0],
type: 'hoverstart',
pointerType: 'mouse',
@@ -324,7 +343,7 @@ describe.each(table)('Hover responder', hasPointerEvents => {
pageY: 10,
clientX: 10,
clientY: 10,
target: ref.current,
target,
timeStamp: timeStamps[1],
type: 'hovermove',
pointerType: 'mouse',
@@ -336,7 +355,7 @@ describe.each(table)('Hover responder', hasPointerEvents => {
pageY: 20,
clientX: 20,
clientY: 20,
target: ref.current,
target,
timeStamp: timeStamps[2],
type: 'hovermove',
pointerType: 'mouse',
@@ -348,7 +367,7 @@ describe.each(table)('Hover responder', hasPointerEvents => {
pageY: 20,
clientX: 20,
clientY: 20,
target: ref.current,
target,
timeStamp: timeStamps[3],
type: 'hoverend',
pointerType: 'mouse',
+124 -35
View File
@@ -95,6 +95,10 @@ function click(data) {
return createEvent('click', data);
}
function contextmenu(data) {
return createEvent('contextmenu', data);
}
function dragstart(data) {
return createEvent('dragstart', data);
}
@@ -107,6 +111,14 @@ function gotpointercapture(data) {
return createEvent('gotpointercapture', data);
}
function keydown(data) {
return createKeyboardEvent('keydown', data);
}
function keyup(data) {
return createKeyboardEvent('keyup', data);
}
function lostpointercapture(data) {
return createEvent('lostpointercapture', data);
}
@@ -191,8 +203,52 @@ function touchstart(data, id) {
* Dispatch high-level event sequences
*/
function dispatchPointerHoverEnter(ref, {relatedTarget, x, y} = {}) {
const dispatch = arg => ref.current.dispatchEvent(arg);
function emptyFunction() {}
function dispatchLongPressContextMenu(
target,
{preventDefault = emptyFunction} = {},
) {
const dispatch = arg => target.dispatchEvent(arg);
const button = 0;
if (hasPointerEvent()) {
dispatch(pointerdown({button, pointerType: 'touch'}));
}
dispatch(touchstart());
dispatch(contextmenu({button, preventDefault}));
}
function dispatchRightClickContextMenu(
target,
{preventDefault = emptyFunction} = {},
) {
const dispatch = arg => target.dispatchEvent(arg);
const button = 2;
if (hasPointerEvent()) {
dispatch(pointerdown({button, pointerType: 'mouse'}));
}
dispatch(mousedown({button}));
dispatch(contextmenu({button, preventDefault}));
}
function dispatchModifiedClickContextMenu(
target,
{preventDefault = emptyFunction} = {},
) {
const dispatch = arg => target.dispatchEvent(arg);
const button = 0;
const ctrlKey = true;
if (hasPointerEvent()) {
dispatch(pointerdown({button, ctrlKey, pointerType: 'mouse'}));
}
dispatch(mousedown({button, ctrlKey}));
if (platform.get() === 'mac') {
dispatch(contextmenu({button, ctrlKey, preventDefault}));
}
}
function dispatchPointerHoverEnter(target, {relatedTarget, x, y} = {}) {
const dispatch = arg => target.dispatchEvent(arg);
const button = -1;
const pointerType = 'mouse';
const event = {
@@ -211,8 +267,8 @@ function dispatchPointerHoverEnter(ref, {relatedTarget, x, y} = {}) {
dispatch(mouseover(event));
}
function dispatchPointerHoverMove(ref, {from, to} = {}) {
const dispatch = arg => ref.current.dispatchEvent(arg);
function dispatchPointerHoverMove(target, {from, to} = {}) {
const dispatch = arg => target.dispatchEvent(arg);
const button = -1;
const pointerId = 1;
const pointerType = 'mouse';
@@ -233,8 +289,8 @@ function dispatchPointerHoverMove(ref, {from, to} = {}) {
dispatchMove({x: to.x, y: to.y});
}
function dispatchPointerHoverExit(ref, {relatedTarget, x, y} = {}) {
const dispatch = arg => ref.current.dispatchEvent(arg);
function dispatchPointerHoverExit(target, {relatedTarget, x, y} = {}) {
const dispatch = arg => target.dispatchEvent(arg);
const button = -1;
const pointerType = 'mouse';
const event = {
@@ -253,51 +309,82 @@ function dispatchPointerHoverExit(ref, {relatedTarget, x, y} = {}) {
dispatch(mouseleave(event));
}
function dispatchPointerCancel(ref, options) {
const dispatchEvent = arg => ref.current.dispatchEvent(arg);
function dispatchPointerCancel(target, options) {
const dispatchEvent = arg => target.dispatchEvent(arg);
dispatchEvent(pointercancel({pointerType: 'mouse'}));
dispatchEvent(dragstart({pointerType: 'mouse'}));
}
function dispatchPointerPressDown(ref, {button = 0, pointerType = 'mouse'}) {
const dispatch = arg => ref.current.dispatchEvent(arg);
function dispatchPointerPressDown(
target,
{button = 0, pointerType = 'mouse'} = {},
) {
const dispatch = arg => target.dispatchEvent(arg);
const pointerId = 1;
if (hasPointerEvent()) {
dispatch(pointerover({pointerId, pointerType, button}));
dispatch(pointerenter({pointerId, pointerType, button}));
dispatch(pointerdown({pointerId, pointerType, button}));
}
dispatch(touchstart(null, pointerId));
if (hasPointerEvent()) {
dispatch(gotpointercapture({pointerId, pointerType, button}));
if (pointerType !== 'mouse') {
if (hasPointerEvent()) {
dispatch(pointerover({button, pointerId, pointerType}));
dispatch(pointerenter({button, pointerId, pointerType}));
dispatch(pointerdown({button, pointerId, pointerType}));
}
dispatch(touchstart(null, pointerId));
if (hasPointerEvent()) {
dispatch(gotpointercapture({button, pointerId, pointerType}));
}
} else {
if (hasPointerEvent()) {
dispatch(pointerdown({button, pointerId, pointerType}));
}
dispatch(mousedown({button}));
if (document.activeElement !== target) {
dispatch(focus({button}));
}
}
}
function dispatchPointerPressRelease(ref, {button = 0, pointerType = 'mouse'}) {
const dispatch = arg => ref.current.dispatchEvent(arg);
function dispatchPointerPressRelease(
target,
{button = 0, pointerType = 'mouse'} = {},
) {
const dispatch = arg => target.dispatchEvent(arg);
const pointerId = 1;
if (hasPointerEvent()) {
dispatch(pointerup({pointerId, pointerType, button}));
dispatch(lostpointercapture({pointerId, pointerType, button}));
dispatch(pointerout({pointerId, pointerType, button}));
dispatch(pointerleave({pointerId, pointerType, button}));
if (pointerType !== 'mouse') {
if (hasPointerEvent()) {
dispatch(pointerup({button, pointerId, pointerType}));
dispatch(lostpointercapture({button, pointerId, pointerType}));
dispatch(pointerout({button, pointerId, pointerType}));
dispatch(pointerleave({button, pointerId, pointerType}));
}
dispatch(touchend(null, pointerId));
dispatch(mouseover({button}));
dispatch(mousemove({button}));
dispatch(mousedown({button}));
if (document.activeElement !== target) {
dispatch(focus({button}));
}
dispatch(mouseup({button}));
dispatch(click({button}));
} else {
if (hasPointerEvent()) {
dispatch(pointerup({button, pointerId, pointerType}));
}
dispatch(mouseup({button}));
dispatch(click({button}));
}
dispatch(touchend(null, pointerId));
dispatch(mouseover({button}));
dispatch(mousemove({button}));
dispatch(mousedown({button}));
dispatch(focus({button}));
dispatch(mouseup({button}));
dispatch(click({button}));
}
function dispatchTouchTap(ref) {
dispatchPointerPressDown(ref, {pointerType: 'touch'});
dispatchPointerPressRelease(ref, {pointerType: 'touch'});
function dispatchTouchTap(target) {
dispatchPointerPressDown(target, {pointerType: 'touch'});
dispatchPointerPressRelease(target, {pointerType: 'touch'});
}
module.exports = {
blur,
focus,
createEvent,
dispatchLongPressContextMenu,
dispatchRightClickContextMenu,
dispatchModifiedClickContextMenu,
dispatchPointerCancel,
dispatchPointerHoverEnter,
dispatchPointerHoverExit,
@@ -305,6 +392,8 @@ module.exports = {
dispatchPointerPressDown,
dispatchPointerPressRelease,
dispatchTouchTap,
keydown,
keyup,
platform,
hasPointerEvent,
setPointerEvent,