diff --git a/packages/components/src/higher-order/with-focus-outside/index.js b/packages/components/src/higher-order/with-focus-outside/index.js
index ca966eb048af37..c83de77f009060 100644
--- a/packages/components/src/higher-order/with-focus-outside/index.js
+++ b/packages/components/src/higher-order/with-focus-outside/index.js
@@ -1,142 +1,33 @@
-/**
- * External dependencies
- */
-import { includes } from 'lodash';
-
/**
* WordPress dependencies
*/
-import { Component } from '@wordpress/element';
-import { createHigherOrderComponent } from '@wordpress/compose';
-
-/**
- * Input types which are classified as button types, for use in considering
- * whether element is a (focus-normalized) button.
- *
- * @type {string[]}
- */
-const INPUT_BUTTON_TYPES = [ 'button', 'submit' ];
-
-/**
- * Returns true if the given element is a button element subject to focus
- * normalization, or false otherwise.
- *
- * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
- *
- * @param {Element} element Element to test.
- *
- * @return {boolean} Whether element is a button.
- */
-function isFocusNormalizedButton( element ) {
- switch ( element.nodeName ) {
- case 'A':
- case 'BUTTON':
- return true;
-
- case 'INPUT':
- return includes( INPUT_BUTTON_TYPES, element.type );
- }
-
- return false;
-}
-
-export default createHigherOrderComponent( ( WrappedComponent ) => {
- return class extends Component {
- constructor() {
- super( ...arguments );
-
- this.bindNode = this.bindNode.bind( this );
- this.cancelBlurCheck = this.cancelBlurCheck.bind( this );
- this.queueBlurCheck = this.queueBlurCheck.bind( this );
- this.normalizeButtonFocus = this.normalizeButtonFocus.bind( this );
- }
-
- componentWillUnmount() {
- this.cancelBlurCheck();
- }
-
- bindNode( node ) {
- if ( node ) {
- this.node = node;
- } else {
- delete this.node;
- this.cancelBlurCheck();
- }
- }
-
- queueBlurCheck( event ) {
- // React does not allow using an event reference asynchronously
- // due to recycling behavior, except when explicitly persisted.
- event.persist();
-
- // Skip blur check if clicking button. See `normalizeButtonFocus`.
- if ( this.preventBlurCheck ) {
- return;
- }
-
- this.blurCheckTimeout = setTimeout( () => {
- // If document is not focused then focus should remain
- // inside the wrapped component and therefore we cancel
- // this blur event thereby leaving focus in place.
- // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus.
- if ( ! document.hasFocus() ) {
- event.preventDefault();
- return;
- }
- if ( 'function' === typeof this.node.handleFocusOutside ) {
- this.node.handleFocusOutside( event );
- }
- }, 0 );
- }
-
- cancelBlurCheck() {
- clearTimeout( this.blurCheckTimeout );
- }
-
- /**
- * Handles a mousedown or mouseup event to respectively assign and
- * unassign a flag for preventing blur check on button elements. Some
- * browsers, namely Firefox and Safari, do not emit a focus event on
- * button elements when clicked, while others do. The logic here
- * intends to normalize this as treating click on buttons as focus.
- *
- * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
- *
- * @param {MouseEvent} event Event for mousedown or mouseup.
- */
- normalizeButtonFocus( event ) {
- const { type, target } = event;
-
- const isInteractionEnd = includes(
- [ 'mouseup', 'touchend' ],
- type
- );
-
- if ( isInteractionEnd ) {
- this.preventBlurCheck = false;
- } else if ( isFocusNormalizedButton( target ) ) {
- this.preventBlurCheck = true;
- }
- }
-
- render() {
- // Disable reason: See `normalizeButtonFocus` for browser-specific
- // focus event normalization.
-
- /* eslint-disable jsx-a11y/no-static-element-interactions */
- return (
-
-
-
- );
- /* eslint-enable jsx-a11y/no-static-element-interactions */
- }
- };
-}, 'withFocusOutside' );
+import { useCallback, useState } from '@wordpress/element';
+import {
+ createHigherOrderComponent,
+ __experimentalUseFocusOutside as useFocusOutside,
+} from '@wordpress/compose';
+
+export default createHigherOrderComponent(
+ ( WrappedComponent ) => ( props ) => {
+ const [ handleFocusOutside, setHandleFocusOutside ] = useState();
+ const bindFocusOutsideHandler = useCallback(
+ ( node ) =>
+ setHandleFocusOutside( () =>
+ node?.handleFocusOutside
+ ? node.handleFocusOutside.bind( node )
+ : undefined
+ ),
+ []
+ );
+
+ return (
+
+
+
+ );
+ },
+ 'withFocusOutside'
+);
diff --git a/packages/components/src/higher-order/with-focus-outside/index.native.js b/packages/components/src/higher-order/with-focus-outside/index.native.js
index 98249c09fbdd22..e23ae14b0b52de 100644
--- a/packages/components/src/higher-order/with-focus-outside/index.native.js
+++ b/packages/components/src/higher-order/with-focus-outside/index.native.js
@@ -1,133 +1,37 @@
/**
* External dependencies
*/
-import { includes } from 'lodash';
import { View } from 'react-native';
-
/**
* WordPress dependencies
*/
-import { Component } from '@wordpress/element';
-import { createHigherOrderComponent } from '@wordpress/compose';
-
-/**
- * Input types which are classified as button types, for use in considering
- * whether element is a (focus-normalized) button.
- *
- * @type {string[]}
- */
-const INPUT_BUTTON_TYPES = [ 'button', 'submit' ];
-
-/**
- * Returns true if the given element is a button element subject to focus
- * normalization, or false otherwise.
- *
- * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
- *
- * @param {Element} element Element to test.
- *
- * @return {boolean} Whether element is a button.
- */
-function isFocusNormalizedButton( element ) {
- switch ( element.nodeName ) {
- case 'A':
- case 'BUTTON':
- return true;
-
- case 'INPUT':
- return includes( INPUT_BUTTON_TYPES, element.type );
- }
-
- return false;
-}
-
-export default createHigherOrderComponent( ( WrappedComponent ) => {
- return class extends Component {
- constructor() {
- super( ...arguments );
-
- this.bindNode = this.bindNode.bind( this );
- this.cancelBlurCheck = this.cancelBlurCheck.bind( this );
- this.queueBlurCheck = this.queueBlurCheck.bind( this );
- this.normalizeButtonFocus = this.normalizeButtonFocus.bind( this );
- }
-
- componentWillUnmount() {
- this.cancelBlurCheck();
- }
-
- bindNode( node ) {
- if ( node ) {
- this.node = node;
- } else {
- delete this.node;
- this.cancelBlurCheck();
- }
- }
-
- queueBlurCheck( event ) {
- // React does not allow using an event reference asynchronously
- // due to recycling behavior, except when explicitly persisted.
- event.persist();
-
- // Skip blur check if clicking button. See `normalizeButtonFocus`.
- if ( this.preventBlurCheck ) {
- return;
- }
-
- this.blurCheckTimeout = setTimeout( () => {
- if ( 'function' === typeof this.node.handleFocusOutside ) {
- this.node.handleFocusOutside( event );
- }
- }, 0 );
- }
-
- cancelBlurCheck() {
- clearTimeout( this.blurCheckTimeout );
- }
-
- /**
- * Handles a mousedown or mouseup event to respectively assign and
- * unassign a flag for preventing blur check on button elements. Some
- * browsers, namely Firefox and Safari, do not emit a focus event on
- * button elements when clicked, while others do. The logic here
- * intends to normalize this as treating click on buttons as focus.
- *
- * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
- *
- * @param {MouseEvent} event Event for mousedown or mouseup.
- */
- normalizeButtonFocus( event ) {
- const { type, target } = event;
-
- const isInteractionEnd = includes(
- [ 'mouseup', 'touchend' ],
- type
- );
-
- if ( isInteractionEnd ) {
- this.preventBlurCheck = false;
- } else if ( isFocusNormalizedButton( target ) ) {
- this.preventBlurCheck = true;
- }
- }
-
- render() {
- // Disable reason: See `normalizeButtonFocus` for browser-specific
- // focus event normalization.
-
- return (
-
-
-
- );
- }
- };
-}, 'withFocusOutside' );
+import { useCallback, useState } from '@wordpress/element';
+import {
+ createHigherOrderComponent,
+ __experimentalUseFocusOutside as useFocusOutside,
+} from '@wordpress/compose';
+
+export default createHigherOrderComponent(
+ ( WrappedComponent ) => ( props ) => {
+ const [ handleFocusOutside, setHandleFocusOutside ] = useState();
+ const bindFocusOutsideHandler = useCallback(
+ ( node ) =>
+ setHandleFocusOutside( () =>
+ node?.handleFocusOutside
+ ? node.handleFocusOutside.bind( node )
+ : undefined
+ ),
+ []
+ );
+
+ return (
+
+
+
+ );
+ },
+ 'withFocusOutside'
+);
diff --git a/packages/compose/src/hooks/use-focus-outside/index.js b/packages/compose/src/hooks/use-focus-outside/index.js
new file mode 100644
index 00000000000000..2169d4fd83c371
--- /dev/null
+++ b/packages/compose/src/hooks/use-focus-outside/index.js
@@ -0,0 +1,191 @@
+/**
+ * External dependencies
+ */
+import { includes } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { useCallback, useEffect, useRef } from '@wordpress/element';
+
+/**
+ * Input types which are classified as button types, for use in considering
+ * whether element is a (focus-normalized) button.
+ *
+ * @type {string[]}
+ */
+const INPUT_BUTTON_TYPES = [ 'button', 'submit' ];
+
+/**
+ * @typedef {HTMLButtonElement | HTMLLinkElement | HTMLInputElement} FocusNormalizedButton
+ */
+
+// Disable reason: Rule doesn't support predicate return types
+/* eslint-disable jsdoc/valid-types */
+/**
+ * Returns true if the given element is a button element subject to focus
+ * normalization, or false otherwise.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
+ *
+ * @param {EventTarget} eventTarget The target from a mouse or touch event.
+ *
+ * @return {eventTarget is FocusNormalizedButton} Whether element is a button.
+ */
+function isFocusNormalizedButton( eventTarget ) {
+ if ( ! ( eventTarget instanceof window.HTMLElement ) ) {
+ return false;
+ }
+ switch ( eventTarget.nodeName ) {
+ case 'A':
+ case 'BUTTON':
+ return true;
+
+ case 'INPUT':
+ return includes(
+ INPUT_BUTTON_TYPES,
+ /** @type {HTMLInputElement} */ ( eventTarget ).type
+ );
+ }
+
+ return false;
+}
+/* eslint-enable jsdoc/valid-types */
+
+/**
+ * @typedef {import('react').SyntheticEvent} SyntheticEvent
+ */
+
+/**
+ * @callback EventCallback
+ * @param {SyntheticEvent} event input related event.
+ */
+
+/**
+ * @typedef FocusOutsideReactElement
+ * @property {EventCallback} handleFocusOutside callback for a focus outside event.
+ */
+
+/**
+ * @typedef {import('react').MutableRefObject} FocusOutsideRef
+ */
+
+/**
+ * @typedef {Object} FocusOutsideReturnValue
+ * @property {EventCallback} onFocus An event handler for focus events.
+ * @property {EventCallback} onBlur An event handler for blur events.
+ * @property {EventCallback} onMouseDown An event handler for mouse down events.
+ * @property {EventCallback} onMouseUp An event handler for mouse up events.
+ * @property {EventCallback} onTouchStart An event handler for touch start events.
+ * @property {EventCallback} onTouchEnd An event handler for touch end events.
+ */
+
+/**
+ * A react hook that can be used to check whether focus has moved outside the
+ * element the event handlers are bound to.
+ *
+ * @param {EventCallback} onFocusOutside A callback triggered when focus moves outside
+ * the element the event handlers are bound to.
+ *
+ * @return {FocusOutsideReturnValue} An object containing event handlers. Bind the event handlers
+ * to a wrapping element element to capture when focus moves
+ * outside that element.
+ */
+export default function useFocusOutside( onFocusOutside ) {
+ const currentOnFocusOutside = useRef( onFocusOutside );
+ useEffect( () => {
+ currentOnFocusOutside.current = onFocusOutside;
+ }, [ onFocusOutside ] );
+
+ const preventBlurCheck = useRef( false );
+
+ /**
+ * @type {import('react').MutableRefObject}
+ */
+ const blurCheckTimeoutId = useRef();
+
+ /**
+ * Cancel a blur check timeout.
+ */
+ const cancelBlurCheck = useCallback( () => {
+ clearTimeout( blurCheckTimeoutId.current );
+ }, [] );
+
+ // Cancel blur checks on unmount.
+ useEffect( () => {
+ return () => cancelBlurCheck();
+ }, [] );
+
+ // Cancel a blur check if the callback or ref is no longer provided.
+ useEffect( () => {
+ if ( ! onFocusOutside ) {
+ cancelBlurCheck();
+ }
+ }, [ onFocusOutside, cancelBlurCheck ] );
+
+ /**
+ * Handles a mousedown or mouseup event to respectively assign and
+ * unassign a flag for preventing blur check on button elements. Some
+ * browsers, namely Firefox and Safari, do not emit a focus event on
+ * button elements when clicked, while others do. The logic here
+ * intends to normalize this as treating click on buttons as focus.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
+ *
+ * @param {SyntheticEvent} event Event for mousedown or mouseup.
+ */
+ const normalizeButtonFocus = useCallback( ( event ) => {
+ const { type, target } = event;
+ const isInteractionEnd = includes( [ 'mouseup', 'touchend' ], type );
+
+ if ( isInteractionEnd ) {
+ preventBlurCheck.current = false;
+ } else if ( isFocusNormalizedButton( target ) ) {
+ preventBlurCheck.current = true;
+ }
+ }, [] );
+
+ /**
+ * A callback triggered when a blur event occurs on the element the handler
+ * is bound to.
+ *
+ * Calls the `onFocusOutside` callback in an immediate timeout if focus has
+ * move outside the bound element and is still within the document.
+ *
+ * @param {SyntheticEvent} event Blur event.
+ */
+ const queueBlurCheck = useCallback( ( event ) => {
+ // React does not allow using an event reference asynchronously
+ // due to recycling behavior, except when explicitly persisted.
+ event.persist();
+
+ // Skip blur check if clicking button. See `normalizeButtonFocus`.
+ if ( preventBlurCheck.current ) {
+ return;
+ }
+
+ blurCheckTimeoutId.current = setTimeout( () => {
+ // If document is not focused then focus should remain
+ // inside the wrapped component and therefore we cancel
+ // this blur event thereby leaving focus in place.
+ // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus.
+ if ( ! document.hasFocus() ) {
+ event.preventDefault();
+ return;
+ }
+
+ if ( 'function' === typeof currentOnFocusOutside.current ) {
+ currentOnFocusOutside.current( event );
+ }
+ }, 0 );
+ }, [] );
+
+ return {
+ onFocus: cancelBlurCheck,
+ onMouseDown: normalizeButtonFocus,
+ onMouseUp: normalizeButtonFocus,
+ onTouchStart: normalizeButtonFocus,
+ onTouchEnd: normalizeButtonFocus,
+ onBlur: queueBlurCheck,
+ };
+}
diff --git a/packages/compose/src/hooks/use-focus-outside/index.native.js b/packages/compose/src/hooks/use-focus-outside/index.native.js
new file mode 100644
index 00000000000000..6c5c35766379f9
--- /dev/null
+++ b/packages/compose/src/hooks/use-focus-outside/index.native.js
@@ -0,0 +1,179 @@
+/**
+ * External dependencies
+ */
+import { includes } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { useCallback, useEffect, useRef } from '@wordpress/element';
+
+/**
+ * Input types which are classified as button types, for use in considering
+ * whether element is a (focus-normalized) button.
+ *
+ * @type {string[]}
+ */
+const INPUT_BUTTON_TYPES = [ 'button', 'submit' ];
+
+/**
+ * @typedef {HTMLButtonElement | HTMLLinkElement | HTMLInputElement} FocusNormalizedButton
+ */
+
+// Disable reason: Rule doesn't support predicate return types
+/* eslint-disable jsdoc/valid-types */
+/**
+ * Returns true if the given element is a button element subject to focus
+ * normalization, or false otherwise.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
+ *
+ * @param {EventTarget} eventTarget The target from a mouse or touch event.
+ *
+ * @return {eventTarget is FocusNormalizedButton} Whether element is a button.
+ */
+function isFocusNormalizedButton( eventTarget ) {
+ switch ( eventTarget.nodeName ) {
+ case 'A':
+ case 'BUTTON':
+ return true;
+
+ case 'INPUT':
+ return includes(
+ INPUT_BUTTON_TYPES,
+ /** @type {HTMLInputElement} */ ( eventTarget ).type
+ );
+ }
+
+ return false;
+}
+/* eslint-enable jsdoc/valid-types */
+
+/**
+ * @typedef {import('react').SyntheticEvent} SyntheticEvent
+ */
+
+/**
+ * @callback EventCallback
+ * @param {SyntheticEvent} event input related event.
+ */
+
+/**
+ * @typedef FocusOutsideReactElement
+ * @property {EventCallback} handleFocusOutside callback for a focus outside event.
+ */
+
+/**
+ * @typedef {import('react').MutableRefObject} FocusOutsideRef
+ */
+
+/**
+ * @typedef {Object} FocusOutsideReturnValue
+ * @property {EventCallback} onFocus An event handler for focus events.
+ * @property {EventCallback} onBlur An event handler for blur events.
+ * @property {EventCallback} onMouseDown An event handler for mouse down events.
+ * @property {EventCallback} onMouseUp An event handler for mouse up events.
+ * @property {EventCallback} onTouchStart An event handler for touch start events.
+ * @property {EventCallback} onTouchEnd An event handler for touch end events.
+ */
+
+/**
+ * A react hook that can be used to check whether focus has moved outside the
+ * element the event handlers are bound to.
+ *
+ * @param {EventCallback} onFocusOutside A callback triggered when focus moves outside
+ * the element the event handlers are bound to.
+ *
+ * @return {FocusOutsideReturnValue} An object containing event handlers. Bind the event handlers
+ * to a wrapping element element to capture when focus moves
+ * outside that element.
+ */
+export default function useFocusOutside( onFocusOutside ) {
+ const currentOnFocusOutside = useRef( onFocusOutside );
+ useEffect( () => {
+ currentOnFocusOutside.current = onFocusOutside;
+ }, [ onFocusOutside ] );
+
+ const preventBlurCheck = useRef( false );
+
+ /**
+ * @type {import('react').MutableRefObject}
+ */
+ const blurCheckTimeoutId = useRef();
+
+ /**
+ * Cancel a blur check timeout.
+ */
+ const cancelBlurCheck = useCallback( () => {
+ clearTimeout( blurCheckTimeoutId.current );
+ }, [] );
+
+ // Cancel blur checks on unmount.
+ useEffect( () => {
+ return () => cancelBlurCheck();
+ }, [] );
+
+ // Cancel a blur check if the callback or ref is no longer provided.
+ useEffect( () => {
+ if ( ! onFocusOutside ) {
+ cancelBlurCheck();
+ }
+ }, [ onFocusOutside, cancelBlurCheck ] );
+
+ /**
+ * Handles a mousedown or mouseup event to respectively assign and
+ * unassign a flag for preventing blur check on button elements. Some
+ * browsers, namely Firefox and Safari, do not emit a focus event on
+ * button elements when clicked, while others do. The logic here
+ * intends to normalize this as treating click on buttons as focus.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
+ *
+ * @param {SyntheticEvent} event Event for mousedown or mouseup.
+ */
+ const normalizeButtonFocus = useCallback( ( event ) => {
+ const { type, target } = event;
+ const isInteractionEnd = includes( [ 'mouseup', 'touchend' ], type );
+
+ if ( isInteractionEnd ) {
+ preventBlurCheck.current = false;
+ } else if ( isFocusNormalizedButton( target ) ) {
+ preventBlurCheck.current = true;
+ }
+ }, [] );
+
+ /**
+ * A callback triggered when a blur event occurs on the element the handler
+ * is bound to.
+ *
+ * Calls the `onFocusOutside` callback in an immediate timeout if focus has
+ * move outside the bound element and is still within the document.
+ *
+ * @param {SyntheticEvent} event Blur event.
+ */
+ const queueBlurCheck = useCallback( ( event ) => {
+ // React does not allow using an event reference asynchronously
+ // due to recycling behavior, except when explicitly persisted.
+ event.persist();
+
+ // Skip blur check if clicking button. See `normalizeButtonFocus`.
+ if ( preventBlurCheck.current ) {
+ return;
+ }
+
+ blurCheckTimeoutId.current = setTimeout( () => {
+ if ( 'function' === typeof currentOnFocusOutside.current ) {
+ currentOnFocusOutside.current( event );
+ }
+ }, 0 );
+ }, [] );
+
+ return {
+ onFocus: cancelBlurCheck,
+ onMouseDown: normalizeButtonFocus,
+ onMouseUp: normalizeButtonFocus,
+ onTouchStart: normalizeButtonFocus,
+ onTouchEnd: normalizeButtonFocus,
+ onBlur: queueBlurCheck,
+ };
+}
diff --git a/packages/compose/src/hooks/use-focus-outside/test/index.js b/packages/compose/src/hooks/use-focus-outside/test/index.js
new file mode 100644
index 00000000000000..90a27102f90813
--- /dev/null
+++ b/packages/compose/src/hooks/use-focus-outside/test/index.js
@@ -0,0 +1,124 @@
+/**
+ * External dependencies
+ */
+import TestUtils from 'react-dom/test-utils';
+
+/**
+ * WordPress dependencies
+ */
+import { Component } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import useFocusOutside from '../';
+import ReactDOM from 'react-dom';
+
+let wrapper, onFocusOutside;
+
+describe( 'useFocusOutside', () => {
+ let origHasFocus;
+
+ const FocusOutsideComponent = ( { onFocusOutside: callback } ) => (
+
+
+
+
+ );
+
+ // this is needed because TestUtils does not accept a stateless component.
+ // anything run through a HOC ends up as a stateless component.
+ const getTestComponent = ( WrappedComponent, props ) => {
+ class TestComponent extends Component {
+ render() {
+ return ;
+ }
+ }
+ return ;
+ };
+
+ const simulateEvent = ( event, index = 0 ) => {
+ const element = TestUtils.scryRenderedDOMComponentsWithTag(
+ wrapper,
+ 'input'
+ );
+ TestUtils.Simulate[ event ]( element[ index ] );
+ };
+
+ beforeEach( () => {
+ // Mock document.hasFocus() to always be true for testing
+ // note: we overide this for some tests.
+ origHasFocus = document.hasFocus;
+ document.hasFocus = () => true;
+
+ onFocusOutside = jest.fn();
+ wrapper = TestUtils.renderIntoDocument(
+ getTestComponent( FocusOutsideComponent, { onFocusOutside } )
+ );
+ } );
+
+ afterEach( () => {
+ document.hasFocus = origHasFocus;
+ } );
+
+ it( 'should not call handler if focus shifts to element within component', () => {
+ simulateEvent( 'focus' );
+ simulateEvent( 'blur' );
+ simulateEvent( 'focus', 1 );
+
+ jest.runAllTimers();
+
+ expect( onFocusOutside ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should not call handler if focus transitions via click to button', () => {
+ simulateEvent( 'focus' );
+ simulateEvent( 'mouseDown', 1 );
+ simulateEvent( 'blur' );
+
+ // In most browsers, the input at index 1 would receive a focus event
+ // at this point, but this is not guaranteed, which is the intention of
+ // the normalization behavior tested here.
+ simulateEvent( 'mouseUp', 1 );
+
+ jest.runAllTimers();
+
+ expect( onFocusOutside ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should call handler if focus doesn’t shift to element within component', () => {
+ simulateEvent( 'focus' );
+ simulateEvent( 'blur' );
+
+ jest.runAllTimers();
+
+ expect( onFocusOutside ).toHaveBeenCalled();
+ } );
+
+ it( 'should not call handler if focus shifts outside the component when the document does not have focus', () => {
+ // Force document.hasFocus() to return false to simulate the window/document losing focus
+ // See https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus.
+ document.hasFocus = () => false;
+
+ simulateEvent( 'focus' );
+ simulateEvent( 'blur' );
+
+ jest.runAllTimers();
+
+ expect( onFocusOutside ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should cancel check when unmounting while queued', () => {
+ simulateEvent( 'focus' );
+ simulateEvent( 'input' );
+
+ ReactDOM.unmountComponentAtNode(
+ // eslint-disable-next-line react/no-find-dom-node
+ ReactDOM.findDOMNode( wrapper ).parentNode
+ );
+
+ jest.runAllTimers();
+
+ expect( onFocusOutside ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/packages/compose/src/index.js b/packages/compose/src/index.js
index 2e9d979e4c2fa6..4df6a8391bc3c7 100644
--- a/packages/compose/src/index.js
+++ b/packages/compose/src/index.js
@@ -17,6 +17,7 @@ export { default as useConstrainedTabbing } from './hooks/use-constrained-tabbin
export { default as useCopyOnClick } from './hooks/use-copy-on-click';
export { default as __experimentalUseDragging } from './hooks/use-dragging';
export { default as useFocusOnMount } from './hooks/use-focus-on-mount';
+export { default as __experimentalUseFocusOutside } from './hooks/use-focus-outside';
export { default as useInstanceId } from './hooks/use-instance-id';
export { default as useKeyboardShortcut } from './hooks/use-keyboard-shortcut';
export { default as useMediaQuery } from './hooks/use-media-query';
diff --git a/packages/compose/src/index.native.js b/packages/compose/src/index.native.js
index 8181c262214536..04334739cd4e1f 100644
--- a/packages/compose/src/index.native.js
+++ b/packages/compose/src/index.native.js
@@ -15,6 +15,7 @@ export { default as withState } from './higher-order/with-state';
// Hooks
export { default as useConstrainedTabbing } from './hooks/use-constrained-tabbing';
export { default as __experimentalUseDragging } from './hooks/use-dragging';
+export { default as __experimentalUseFocusOutside } from './hooks/use-focus-outside';
export { default as useInstanceId } from './hooks/use-instance-id';
export { default as useKeyboardShortcut } from './hooks/use-keyboard-shortcut';
export { default as useMediaQuery } from './hooks/use-media-query';