diff --git a/packages/react-events/docs/FocusWithin.md b/packages/react-events/docs/FocusWithin.md
new file mode 100644
index 00000000000..36bd56c1c61
--- /dev/null
+++ b/packages/react-events/docs/FocusWithin.md
@@ -0,0 +1,57 @@
+# FocusWithin
+
+The `FocusWithin` module responds to focus and blur events on its child. Focus events
+are dispatched for all input types, with the exception of `onFocusVisibleChange`
+which is only dispatched when focusing with a keyboard.
+
+Focus events do not propagate between `FocusWithin` event responders.
+
+```js
+// Example
+const Button = (props) => {
+ const [ focusWithin, updateFocusWithin ] = useState(false);
+ const [ focusWithinVisible, updateFocusWithinVisible ] = useState(false);
+
+ return (
+
+
+ );
+};
+```
+
+## Types
+
+```js
+type FocusEvent = {
+ target: Element,
+ type: 'focuswithinchange' | 'focuswithinvisiblechange'
+}
+```
+
+## Props
+
+### disabled: boolean = false
+
+Disables all `FocusWithin` events.
+
+### onFocusWithinChange: boolean => void
+
+Called once the element or a descendant receives focus, and once focus moves
+outside of the element.
+
+### onFocusWithinVisibleChange: boolean => void
+
+Called once the element or a descendant is focused following keyboard
+navigation, and once focus moves outside of the element. This can be used to
+display focus styles only when the keyboard is being used to focus within the
+element's subtree.
diff --git a/packages/react-events/src/dom/Focus.js b/packages/react-events/src/dom/Focus.js
index d73c2c7ee0c..a11a714b86d 100644
--- a/packages/react-events/src/dom/Focus.js
+++ b/packages/react-events/src/dom/Focus.js
@@ -17,6 +17,24 @@ import type {
import React from 'react';
import {DiscreteEvent} from 'shared/ReactTypes';
+/**
+ * Types
+ */
+
+type FocusEvent = {|
+ target: Element | Document,
+ type: FocusEventType | FocusWithinEventType,
+ pointerType: PointerType,
+ timeStamp: number,
+|};
+
+type FocusState = {
+ focusTarget: null | Element | Document,
+ isFocused: boolean,
+ isFocusVisible: boolean,
+ pointerType: PointerType,
+};
+
type FocusProps = {
disabled: boolean,
onBlur: (e: FocusEvent) => void,
@@ -25,21 +43,21 @@ type FocusProps = {
onFocusVisibleChange: boolean => void,
};
-type FocusState = {
- focusTarget: null | Element | Document,
- isFocused: boolean,
- isLocalFocusVisible: boolean,
- pointerType: PointerType,
+type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
+
+type FocusWithinProps = {
+ disabled: boolean,
+ onFocusWithinChange: boolean => void,
+ onFocusWithinVisibleChange: boolean => void,
};
-type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
+type FocusWithinEventType = 'focuswithinvisiblechange' | 'focuswithinchange';
-type FocusEvent = {|
- target: Element | Document,
- type: FocusEventType,
- pointerType: PointerType,
- timeStamp: number,
-|};
+/**
+ * Shared between Focus and FocusWithin
+ */
+
+let isGlobalFocusVisible = true;
const isMac =
typeof window !== 'undefined' && window.navigator != null
@@ -70,7 +88,7 @@ if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
function createFocusEvent(
context: ReactDOMResponderContext,
- type: FocusEventType,
+ type: FocusEventType | FocusWithinEventType,
target: Element | Document,
pointerType: PointerType,
): FocusEvent {
@@ -82,7 +100,90 @@ function createFocusEvent(
};
}
-function dispatchFocusInEvents(
+function handleRootPointerEvent(
+ event: ReactDOMResponderEvent,
+ context: ReactDOMResponderContext,
+ 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;
+ }
+
+ 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) &&
+ (type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
+ ) {
+ callback(false);
+ }
+}
+
+function handleRootEvent(
+ event: ReactDOMResponderEvent,
+ context: ReactDOMResponderContext,
+ state: FocusState,
+ callback: boolean => void,
+): void {
+ 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': {
+ // $FlowFixMe: Flow doesn't know about PointerEvents
+ const nativeEvent = ((event.nativeEvent: any): PointerEvent);
+ state.pointerType = nativeEvent.pointerType;
+ 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
+ )
+ ) {
+ state.pointerType = 'keyboard';
+ isGlobalFocusVisible = true;
+ }
+ break;
+ }
+ }
+}
+
+/**
+ * Focus Responder
+ */
+
+function dispatchFocusEvents(
context: ReactDOMResponderContext,
props: FocusProps,
state: FocusState,
@@ -110,21 +211,12 @@ function dispatchFocusInEvents(
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
- if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
- const listener = () => {
- props.onFocusVisibleChange(true);
- };
- const syntheticEvent = createFocusEvent(
- context,
- 'focusvisiblechange',
- target,
- pointerType,
- );
- context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
+ if (state.isFocusVisible) {
+ dispatchFocusVisibleChangeEvent(context, props, state, true);
}
}
-function dispatchFocusOutEvents(
+function dispatchBlurEvents(
context: ReactDOMResponderContext,
props: FocusProps,
state: FocusState,
@@ -152,19 +244,22 @@ function dispatchFocusOutEvents(
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
- dispatchFocusVisibleOutEvent(context, props, state);
+ if (state.isFocusVisible) {
+ dispatchFocusVisibleChangeEvent(context, props, state, false);
+ }
}
-function dispatchFocusVisibleOutEvent(
+function dispatchFocusVisibleChangeEvent(
context: ReactDOMResponderContext,
props: FocusProps,
state: FocusState,
+ value: boolean,
) {
const pointerType = state.pointerType;
const target = ((state.focusTarget: any): Element | Document);
- if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
+ if (props.onFocusVisibleChange) {
const listener = () => {
- props.onFocusVisibleChange(false);
+ props.onFocusVisibleChange(value);
};
const syntheticEvent = createFocusEvent(
context,
@@ -173,49 +268,19 @@ function dispatchFocusVisibleOutEvent(
pointerType,
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
- state.isLocalFocusVisible = false;
}
}
-function unmountResponder(
+function unmountFocusResponder(
context: ReactDOMResponderContext,
props: FocusProps,
state: FocusState,
-): void {
+) {
if (state.isFocused) {
- dispatchFocusOutEvents(context, props, state);
- }
-}
-
-function handleRootPointerEvent(
- event: ReactDOMResponderEvent,
- context: ReactDOMResponderContext,
- props: FocusProps,
- state: FocusState,
-): 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;
- }
-
- 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) &&
- (type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
- ) {
- dispatchFocusVisibleOutEvent(context, props, state);
+ dispatchBlurEvents(context, props, state);
}
}
-let isGlobalFocusVisible = true;
-
const FocusResponder: ReactDOMEventResponder = {
displayName: 'Focus',
targetEventTypes,
@@ -224,7 +289,7 @@ const FocusResponder: ReactDOMEventResponder = {
return {
focusTarget: null,
isFocused: false,
- isLocalFocusVisible: false,
+ isFocusVisible: false,
pointerType: '',
};
},
@@ -238,7 +303,7 @@ const FocusResponder: ReactDOMEventResponder = {
if (props.disabled) {
if (state.isFocused) {
- dispatchFocusOutEvents(context, props, state);
+ dispatchBlurEvents(context, props, state);
state.isFocused = false;
state.focusTarget = null;
}
@@ -247,23 +312,21 @@ const FocusResponder: ReactDOMEventResponder = {
switch (type) {
case 'focus': {
- if (!state.isFocused) {
- // Limit focus events to the direct child of the event component.
- // Browser focus is not expected to bubble.
- state.focusTarget = event.responderTarget;
- if (state.focusTarget === target) {
- state.isFocused = true;
- state.isLocalFocusVisible = isGlobalFocusVisible;
- dispatchFocusInEvents(context, props, state);
- }
+ state.focusTarget = event.responderTarget;
+ // Limit focus events to the direct child of the event component.
+ // Browser focus is not expected to bubble.
+ if (!state.isFocused && state.focusTarget === target) {
+ state.isFocused = true;
+ state.isFocusVisible = isGlobalFocusVisible;
+ dispatchFocusEvents(context, props, state);
}
break;
}
case 'blur': {
if (state.isFocused) {
- dispatchFocusOutEvents(context, props, state);
+ dispatchBlurEvents(context, props, state);
+ state.isFocusVisible = isGlobalFocusVisible;
state.isFocused = false;
- state.focusTarget = null;
}
break;
}
@@ -275,69 +338,190 @@ const FocusResponder: ReactDOMEventResponder = {
props: FocusProps,
state: FocusState,
): void {
- const {type} = event;
-
- switch (type) {
- case 'mousemove':
- case 'mousedown':
- case 'mouseup': {
- state.pointerType = 'mouse';
- handleRootPointerEvent(event, context, props, state);
- break;
+ handleRootEvent(event, context, state, isFocusVisible => {
+ if (state.isFocusVisible !== isFocusVisible) {
+ state.isFocusVisible = isFocusVisible;
+ dispatchFocusVisibleChangeEvent(context, props, state, isFocusVisible);
}
- case 'pointermove':
- case 'pointerdown':
- case 'pointerup': {
- // $FlowFixMe: Flow doesn't know about PointerEvents
- const nativeEvent = ((event.nativeEvent: any): PointerEvent);
- state.pointerType = nativeEvent.pointerType;
- handleRootPointerEvent(event, context, props, state);
- break;
+ });
+ },
+ onUnmount(
+ context: ReactDOMResponderContext,
+ props: FocusProps,
+ state: FocusState,
+ ) {
+ unmountFocusResponder(context, props, state);
+ },
+ onOwnershipChange(
+ context: ReactDOMResponderContext,
+ props: FocusProps,
+ state: FocusState,
+ ) {
+ unmountFocusResponder(context, props, state);
+ },
+};
+
+export const Focus = React.unstable_createEvent(FocusResponder);
+
+export function useFocus(props: FocusProps): void {
+ React.unstable_useEvent(Focus, props);
+}
+
+/**
+ * FocusWithin Responder
+ */
+
+function dispatchFocusWithinChangeEvent(
+ context: ReactDOMResponderContext,
+ props: FocusWithinProps,
+ state: FocusState,
+ value: boolean,
+) {
+ const pointerType = state.pointerType;
+ const target = ((state.focusTarget: any): Element | Document);
+ if (props.onFocusWithinChange) {
+ const listener = function() {
+ props.onFocusWithinChange(value);
+ };
+ const syntheticEvent = createFocusEvent(
+ context,
+ 'focuswithinchange',
+ target,
+ pointerType,
+ );
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
+ }
+ if (state.isFocusVisible) {
+ dispatchFocusWithinVisibleChangeEvent(context, props, state, value);
+ }
+}
+
+function dispatchFocusWithinVisibleChangeEvent(
+ context: ReactDOMResponderContext,
+ props: FocusWithinProps,
+ state: FocusState,
+ value: boolean,
+) {
+ const pointerType = state.pointerType;
+ const target = ((state.focusTarget: any): Element | Document);
+ if (props.onFocusWithinVisibleChange) {
+ const listener = function() {
+ props.onFocusWithinVisibleChange(value);
+ };
+ const syntheticEvent = createFocusEvent(
+ context,
+ 'focuswithinvisiblechange',
+ target,
+ pointerType,
+ );
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
+ }
+}
+
+function unmountFocusWithinResponder(
+ context: ReactDOMResponderContext,
+ props: FocusWithinProps,
+ state: FocusState,
+) {
+ if (state.isFocused) {
+ dispatchFocusWithinChangeEvent(context, props, state, false);
+ }
+}
+
+const FocusWithinResponder: ReactDOMEventResponder = {
+ displayName: 'FocusWithin',
+ targetEventTypes,
+ rootEventTypes,
+ getInitialState(): FocusState {
+ return {
+ focusTarget: null,
+ isFocused: false,
+ isFocusVisible: false,
+ pointerType: '',
+ };
+ },
+ onEvent(
+ event: ReactDOMResponderEvent,
+ context: ReactDOMResponderContext,
+ props: FocusWithinProps,
+ state: FocusState,
+ ): void {
+ const {nativeEvent, type} = event;
+ const relatedTarget = (nativeEvent: any).relatedTarget;
+
+ if (props.disabled) {
+ if (state.isFocused) {
+ dispatchFocusWithinChangeEvent(context, props, state, false);
+ state.isFocused = false;
+ state.focusTarget = null;
}
- case 'touchmove':
- case 'touchstart':
- case 'touchend': {
- state.pointerType = 'touch';
- handleRootPointerEvent(event, context, props, state);
+ return;
+ }
+
+ switch (type) {
+ case 'focus': {
+ state.focusTarget = event.responderTarget;
+ // Limit focus events to the direct child of the event component.
+ // Browser focus is not expected to bubble.
+ if (!state.isFocused) {
+ state.isFocused = true;
+ state.isFocusVisible = isGlobalFocusVisible;
+ dispatchFocusWithinChangeEvent(context, props, state, true);
+ }
+ if (!state.isFocusVisible && isGlobalFocusVisible) {
+ state.isFocusVisible = isGlobalFocusVisible;
+ dispatchFocusWithinVisibleChangeEvent(context, props, state, true);
+ }
break;
}
-
- case 'keydown':
- case 'keyup': {
- const nativeEvent = event.nativeEvent;
+ case 'blur': {
if (
- nativeEvent.key === 'Tab' &&
- !(
- nativeEvent.metaKey ||
- (!isMac && nativeEvent.altKey) ||
- nativeEvent.ctrlKey
- )
+ state.isFocused &&
+ !context.isTargetWithinEventResponderScope(relatedTarget)
) {
- state.pointerType = 'keyboard';
- isGlobalFocusVisible = true;
+ dispatchFocusWithinChangeEvent(context, props, state, false);
+ state.isFocused = false;
}
break;
}
}
},
+ onRootEvent(
+ event: ReactDOMResponderEvent,
+ context: ReactDOMResponderContext,
+ props: FocusWithinProps,
+ state: FocusState,
+ ): void {
+ handleRootEvent(event, context, state, isFocusVisible => {
+ if (state.isFocusVisible !== isFocusVisible) {
+ state.isFocusVisible = isFocusVisible;
+ dispatchFocusWithinVisibleChangeEvent(
+ context,
+ props,
+ state,
+ isFocusVisible,
+ );
+ }
+ });
+ },
onUnmount(
context: ReactDOMResponderContext,
- props: FocusProps,
+ props: FocusWithinProps,
state: FocusState,
) {
- unmountResponder(context, props, state);
+ unmountFocusWithinResponder(context, props, state);
},
onOwnershipChange(
context: ReactDOMResponderContext,
- props: FocusProps,
+ props: FocusWithinProps,
state: FocusState,
) {
- unmountResponder(context, props, state);
+ unmountFocusWithinResponder(context, props, state);
},
};
-export const Focus = React.unstable_createEvent(FocusResponder);
+export const FocusWithin = React.unstable_createEvent(FocusWithinResponder);
-export function useFocus(props: FocusProps): void {
- React.unstable_useEvent(Focus, props);
+export function useFocusWithin(props: FocusWithinProps): void {
+ React.unstable_useEvent(FocusWithin, props);
}
diff --git a/packages/react-events/src/dom/__tests__/Focus-test.internal.js b/packages/react-events/src/dom/__tests__/Focus-test.internal.js
index ab16fda60c9..57a9ab601ec 100644
--- a/packages/react-events/src/dom/__tests__/Focus-test.internal.js
+++ b/packages/react-events/src/dom/__tests__/Focus-test.internal.js
@@ -13,10 +13,16 @@ let React;
let ReactFeatureFlags;
let ReactDOM;
let Focus;
+let FocusWithin;
-const createFocusEvent = type => {
- const event = document.createEvent('Event');
- event.initEvent(type, true, true);
+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;
};
@@ -28,23 +34,13 @@ const createKeyboardEvent = (type, data) => {
});
};
-const createPointerEvent = (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 modulesInit = () => {
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableFlareAPI = true;
React = require('react');
ReactDOM = require('react-dom');
Focus = require('react-events/focus').Focus;
+ FocusWithin = require('react-events/focus').FocusWithin;
};
describe('Focus event responder', () => {
@@ -80,8 +76,8 @@ describe('Focus event responder', () => {
});
it('prevents custom events being dispatched', () => {
- ref.current.dispatchEvent(createFocusEvent('focus'));
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(createEvent('focus'));
+ ref.current.dispatchEvent(createEvent('blur'));
expect(onFocus).not.toBeCalled();
expect(onBlur).not.toBeCalled();
});
@@ -102,8 +98,8 @@ describe('Focus event responder', () => {
});
it('is called after "blur" event', () => {
- ref.current.dispatchEvent(createFocusEvent('focus'));
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(createEvent('focus'));
+ ref.current.dispatchEvent(createEvent('blur'));
expect(onBlur).toHaveBeenCalledTimes(1);
});
});
@@ -128,50 +124,50 @@ describe('Focus event responder', () => {
beforeEach(componentInit);
it('is called after "focus" event', () => {
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(1);
});
it('is not called if descendants of target receive focus', () => {
const target = innerRef.current;
- target.dispatchEvent(createFocusEvent('focus'));
+ target.dispatchEvent(createEvent('focus'));
expect(onFocus).not.toBeCalled();
});
it('is called with the correct pointerType using pointer events', () => {
// Pointer mouse
ref.current.dispatchEvent(
- createPointerEvent('pointerdown', {
+ createEvent('pointerdown', {
pointerType: 'mouse',
}),
);
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'mouse'}),
);
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(createEvent('blur'));
// Pointer touch
ref.current.dispatchEvent(
- createPointerEvent('pointerdown', {
+ createEvent('pointerdown', {
pointerType: 'touch',
}),
);
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(2);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'touch'}),
);
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(createEvent('blur'));
// Pointer pen
ref.current.dispatchEvent(
- createPointerEvent('pointerdown', {
+ createEvent('pointerdown', {
pointerType: 'pen',
}),
);
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(3);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'pen'}),
@@ -180,17 +176,17 @@ describe('Focus event responder', () => {
it('is called with the correct pointerType without pointer events', () => {
// Mouse
- ref.current.dispatchEvent(createPointerEvent('mousedown'));
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('mousedown'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'mouse'}),
);
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(createEvent('blur'));
// Touch
- ref.current.dispatchEvent(createPointerEvent('touchstart'));
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('touchstart'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(2);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'touch'}),
@@ -200,11 +196,11 @@ describe('Focus event responder', () => {
it('is called with the correct pointerType using a keyboard', () => {
// Keyboard tab
ref.current.dispatchEvent(
- createPointerEvent('keydown', {
+ createEvent('keydown', {
key: 'Tab',
}),
);
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({pointerType: 'keyboard'}),
@@ -219,12 +215,12 @@ describe('Focus event responder', () => {
componentInit();
ref.current.dispatchEvent(
- createPointerEvent('keydown', {
+ createEvent('keydown', {
key: 'Tab',
altKey: true,
}),
);
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({
@@ -237,38 +233,51 @@ describe('Focus event responder', () => {
});
describe('onFocusChange', () => {
- let onFocusChange, ref;
+ let onFocusChange, ref, innerRef;
beforeEach(() => {
onFocusChange = jest.fn();
ref = React.createRef();
+ innerRef = React.createRef();
const element = (
-
+
);
ReactDOM.render(element, container);
});
it('is called after "blur" and "focus" events', () => {
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocusChange).toHaveBeenCalledTimes(1);
expect(onFocusChange).toHaveBeenCalledWith(true);
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(createEvent('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'));
+ expect(onFocusChange).toHaveBeenCalledTimes(0);
+ innerRef.current.dispatchEvent(createEvent('blur'));
+ expect(onFocusChange).toHaveBeenCalledTimes(0);
+ });
});
describe('onFocusVisibleChange', () => {
- let onFocusVisibleChange, ref;
+ let onFocusVisibleChange, ref, innerRef;
beforeEach(() => {
onFocusVisibleChange = jest.fn();
ref = React.createRef();
+ innerRef = React.createRef();
const element = (
-
+
);
ReactDOM.render(element, container);
@@ -277,10 +286,12 @@ describe('Focus event responder', () => {
it('is called after "focus" and "blur" if keyboard navigation is active', () => {
// use keyboard first
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(
+ createEvent('blur', {relatedTarget: container}),
+ );
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
});
@@ -288,23 +299,37 @@ describe('Focus event responder', () => {
it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => {
// use keyboard first
container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
- ref.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createEvent('focus'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
// then use pointer on the target, focus should no longer be visible
- ref.current.dispatchEvent(createPointerEvent('pointerdown'));
+ ref.current.dispatchEvent(createEvent('pointerdown'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
// onFocusVisibleChange should not be called again
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(
+ createEvent('blur', {relatedTarget: container}),
+ );
expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
});
it('is not called after "focus" and "blur" events without keyboard', () => {
- ref.current.dispatchEvent(createPointerEvent('pointerdown'));
- ref.current.dispatchEvent(createFocusEvent('focus'));
- container.dispatchEvent(createPointerEvent('pointerdown'));
- ref.current.dispatchEvent(createFocusEvent('blur'));
+ ref.current.dispatchEvent(createEvent('pointerdown'));
+ ref.current.dispatchEvent(createEvent('focus'));
+ container.dispatchEvent(createEvent('pointerdown'));
+ ref.current.dispatchEvent(
+ createEvent('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'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
+ innerRef.current.dispatchEvent(
+ createEvent('blur', {relatedTarget: container}),
+ );
expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
});
});
@@ -336,10 +361,10 @@ describe('Focus event responder', () => {
ReactDOM.render(element, container);
- outerRef.current.dispatchEvent(createFocusEvent('focus'));
- outerRef.current.dispatchEvent(createFocusEvent('blur'));
- innerRef.current.dispatchEvent(createFocusEvent('focus'));
- innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ outerRef.current.dispatchEvent(createEvent('focus'));
+ outerRef.current.dispatchEvent(createEvent('blur'));
+ innerRef.current.dispatchEvent(createEvent('focus'));
+ innerRef.current.dispatchEvent(createEvent('blur'));
expect(events).toEqual([
'outer: onFocus',
'outer: onFocusChange',
@@ -357,3 +382,240 @@ describe('Focus event responder', () => {
expect(Focus.responder.displayName).toBe('Focus');
});
});
+
+describe('FocusWithin event responder', () => {
+ let container;
+
+ beforeEach(() => {
+ jest.resetModules();
+ modulesInit();
+
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ ReactDOM.render(null, container);
+ document.body.removeChild(container);
+ container = null;
+ });
+
+ describe('disabled', () => {
+ let onFocusWithinChange, onFocusWithinVisibleChange, ref;
+
+ beforeEach(() => {
+ onFocusWithinChange = jest.fn();
+ onFocusWithinVisibleChange = jest.fn();
+ ref = React.createRef();
+ const element = (
+
+
+
+ );
+ ReactDOM.render(element, container);
+ });
+
+ it('prevents custom events being dispatched', () => {
+ ref.current.dispatchEvent(createEvent('focus'));
+ ref.current.dispatchEvent(createEvent('blur'));
+ expect(onFocusWithinChange).not.toBeCalled();
+ expect(onFocusWithinVisibleChange).not.toBeCalled();
+ });
+ });
+
+ describe('onFocusWithinChange', () => {
+ let onFocusWithinChange, ref, innerRef, innerRef2;
+
+ beforeEach(() => {
+ onFocusWithinChange = jest.fn();
+ ref = React.createRef();
+ innerRef = React.createRef();
+ innerRef2 = React.createRef();
+ const element = (
+
+
+
+ );
+ ReactDOM.render(element, container);
+ });
+
+ it('is called after "blur" and "focus" events on focus target', () => {
+ ref.current.dispatchEvent(createEvent('focus'));
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(true);
+ ref.current.dispatchEvent(
+ createEvent('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'));
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(true);
+ innerRef.current.dispatchEvent(
+ createEvent('blur', {relatedTarget: container}),
+ );
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(false);
+ });
+
+ it('is only called once when focus moves within and outside the subtree', () => {
+ // focus shifts into subtree
+ innerRef.current.dispatchEvent(createEvent('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}),
+ );
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
+ // focus shifts outside subtree
+ innerRef.current.dispatchEvent(
+ createEvent('blur', {relatedTarget: container}),
+ );
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('onFocusWithinVisibleChange', () => {
+ let onFocusWithinVisibleChange, ref, innerRef, innerRef2;
+
+ beforeEach(() => {
+ onFocusWithinVisibleChange = jest.fn();
+ ref = React.createRef();
+ innerRef = React.createRef();
+ innerRef2 = React.createRef();
+ const element = (
+
+
+
+ );
+ ReactDOM.render(element, container);
+ });
+
+ it('is called after "focus" and "blur" on focus target if keyboard was used', () => {
+ // use keyboard first
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
+ ref.current.dispatchEvent(createEvent('focus'));
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
+ ref.current.dispatchEvent(
+ createEvent('blur', {relatedTarget: container}),
+ );
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
+ });
+
+ it('is called after "focus" and "blur" on descendants if keyboard was used', () => {
+ // use keyboard first
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
+ innerRef.current.dispatchEvent(createEvent('focus'));
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(true);
+ innerRef.current.dispatchEvent(
+ createEvent('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', () => {
+ // 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'));
+ 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'));
+ 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'));
+ 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'));
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(4);
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
+ // onFocusVisibleChange should not be called again
+ innerRef.current.dispatchEvent(
+ createEvent('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}),
+ );
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(0);
+ });
+
+ it('is only called once when focus moves within and outside the subtree', () => {
+ // focus shifts into subtree
+ innerRef.current.dispatchEvent(createEvent('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}),
+ );
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(1);
+ // focus shifts outside subtree
+ innerRef.current.dispatchEvent(
+ createEvent('blur', {relatedTarget: container}),
+ );
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(2);
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ it('expect displayName to show up for event component', () => {
+ expect(FocusWithin.responder.displayName).toBe('FocusWithin');
+ });
+});