From dafead36afeb2bb9d3a8fbfa8ed72cf9cca24289 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 26 Oct 2022 15:22:25 +0200
Subject: [PATCH 1/6] Rewrite unit tests using modern RTL and user event APIs
---
.../src/hooks/use-focus-outside/test/index.js | 169 ++++++++++--------
1 file changed, 99 insertions(+), 70 deletions(-)
diff --git a/packages/compose/src/hooks/use-focus-outside/test/index.js b/packages/compose/src/hooks/use-focus-outside/test/index.js
index 093bbb09ed1b3..c0b01e60ed387 100644
--- a/packages/compose/src/hooks/use-focus-outside/test/index.js
+++ b/packages/compose/src/hooks/use-focus-outside/test/index.js
@@ -4,120 +4,149 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-/**
- * WordPress dependencies
- */
-import { Component } from '@wordpress/element';
-
/**
* Internal dependencies
*/
import useFocusOutside from '../';
-let onFocusOutside;
-
-describe( 'useFocusOutside', () => {
- let origHasFocus;
-
- const FocusOutsideComponent = ( { onFocusOutside: callback } ) => (
+const FocusOutsideComponent = ( { onFocusOutside: callback } ) => (
+
+);
- 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();
- } );
-
- afterEach( () => {
- document.hasFocus = origHasFocus;
- } );
-
- it( 'should not call handler if focus shifts to element within component', () => {
- render( );
+describe( 'useFocusOutside', () => {
+ it( 'should not call handler if focus shifts to element within component', async () => {
+ const mockOnFocusOutside = jest.fn();
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
- const input = screen.getByRole( 'textbox' );
- const button = screen.getByRole( 'button' );
+ render(
+
+ );
- input.focus();
- input.blur();
- button.focus();
+ // Tab through the interactive elements inside the wrapper,
+ // causing multiple focus/blur events.
+ await user.tab();
+ expect( screen.getByRole( 'textbox' ) ).toHaveFocus();
- jest.runAllTimers();
+ await user.tab();
+ expect(
+ screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
+ ).toHaveFocus();
- expect( onFocusOutside ).not.toHaveBeenCalled();
+ expect( mockOnFocusOutside ).not.toHaveBeenCalled();
} );
it( 'should not call handler if focus transitions via click to button', async () => {
+ const mockOnFocusOutside = jest.fn();
const user = userEvent.setup( {
advanceTimers: jest.advanceTimersByTime,
} );
- render( );
-
- const input = screen.getByRole( 'textbox' );
- const button = screen.getByRole( 'button' );
- input.focus();
- await user.click( button );
+ render(
+
+ );
- jest.runAllTimers();
+ // Click the input and the button, causing multiple focus/blur events.
+ await user.click( screen.getByRole( 'textbox' ) );
+ await user.click(
+ screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
+ );
- expect( onFocusOutside ).not.toHaveBeenCalled();
+ expect( mockOnFocusOutside ).not.toHaveBeenCalled();
} );
- it( 'should call handler if focus doesn’t shift to element within component', () => {
- render( );
+ it( "should call handler if focus doesn't shift to element within component", async () => {
+ const mockOnFocusOutside = jest.fn();
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render(
+
+ );
- const input = screen.getByRole( 'textbox' );
- input.focus();
- input.blur();
+ // Click and focus button inside the wrapper
+ await user.click(
+ screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
+ );
- jest.runAllTimers();
+ expect( mockOnFocusOutside ).not.toHaveBeenCalled();
- expect( onFocusOutside ).toHaveBeenCalled();
- } );
+ // Click and focus button outside the wrapper
+ await user.click(
+ screen.getByRole( 'button', { name: 'Button outside the wrapper' } )
+ );
- it( 'should not call handler if focus shifts outside the component when the document does not have focus', () => {
- render( );
+ expect( mockOnFocusOutside ).toHaveBeenCalled();
+ } );
+ it( 'should not call handler if focus shifts outside the component when the document does not have focus', async () => {
// 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;
+ const mockedDocumentHasFocus = jest
+ .spyOn( document, 'hasFocus' )
+ .mockImplementation( () => false );
+ const mockOnFocusOutside = jest.fn();
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
- const input = screen.getByRole( 'textbox' );
- input.focus();
- input.blur();
+ render(
+
+ );
- jest.runAllTimers();
+ // Click and focus button inside the wrapper, then click and focus
+ // a button outside the wrapper.
+ await user.click(
+ screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
+ );
+ await user.click(
+ screen.getByRole( 'button', { name: 'Button outside the wrapper' } )
+ );
+
+ // The handler is not called thanks to the mocked return value of
+ // `document.hasFocus()`
+ expect( mockOnFocusOutside ).not.toHaveBeenCalled();
- expect( onFocusOutside ).not.toHaveBeenCalled();
+ // Restore the `document.hasFocus()` function to its original implementation.
+ mockedDocumentHasFocus.mockRestore();
} );
- it( 'should cancel check when unmounting while queued', () => {
+ it( 'should cancel check when unmounting while queued', async () => {
+ const mockOnFocusOutside = jest.fn();
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
const { rerender } = render(
-
+
);
- const input = screen.getByRole( 'textbox' );
- input.focus();
- input.blur();
+ // Click and focus button inside the wrapper.
+ const button = screen.getByRole( 'button', {
+ name: 'Button inside the wrapper',
+ } );
+ await user.click( button );
+ // Simulate a blur event and the wrapper unmounting while the blur event
+ // handler is queued
+ button.blur();
rerender( );
jest.runAllTimers();
- expect( onFocusOutside ).not.toHaveBeenCalled();
+ expect( mockOnFocusOutside ).not.toHaveBeenCalled();
} );
} );
From f40bdad4a4fbe3845c92d4b0b086fa1d11bd80e7 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 26 Oct 2022 20:17:39 +0200
Subject: [PATCH 2/6] Rename file to TypeScript
---
.../compose/src/hooks/use-focus-outside/{index.js => index.ts} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename packages/compose/src/hooks/use-focus-outside/{index.js => index.ts} (100%)
diff --git a/packages/compose/src/hooks/use-focus-outside/index.js b/packages/compose/src/hooks/use-focus-outside/index.ts
similarity index 100%
rename from packages/compose/src/hooks/use-focus-outside/index.js
rename to packages/compose/src/hooks/use-focus-outside/index.ts
From 1f298d958c47207621fc4d903ea6f932b09c347d Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Wed, 26 Oct 2022 22:42:27 +0200
Subject: [PATCH 3/6] Fix TypeScript errors
---
.../compose/src/hooks/use-dialog/index.ts | 3 +-
.../src/hooks/use-focus-outside/index.ts | 150 ++++++++----------
2 files changed, 66 insertions(+), 87 deletions(-)
diff --git a/packages/compose/src/hooks/use-dialog/index.ts b/packages/compose/src/hooks/use-dialog/index.ts
index b8ec91a5f206a..bac94602489e6 100644
--- a/packages/compose/src/hooks/use-dialog/index.ts
+++ b/packages/compose/src/hooks/use-dialog/index.ts
@@ -17,7 +17,6 @@ import useFocusOnMount from '../use-focus-on-mount';
import useFocusReturn from '../use-focus-return';
import useFocusOutside from '../use-focus-outside';
import useMergeRefs from '../use-merge-refs';
-import type { FocusOutsideReturnValue } from '../use-focus-outside';
type DialogOptions = {
focusOnMount?: Parameters< typeof useFocusOnMount >[ 0 ];
@@ -35,7 +34,7 @@ type DialogOptions = {
type useDialogReturn = [
RefCallback< HTMLElement >,
- FocusOutsideReturnValue & Pick< HTMLElement, 'tabIndex' >
+ ReturnType< typeof useFocusOutside > & Pick< HTMLElement, 'tabIndex' >
];
/**
diff --git a/packages/compose/src/hooks/use-focus-outside/index.ts b/packages/compose/src/hooks/use-focus-outside/index.ts
index 045c946bbcfec..f296bc07e495b 100644
--- a/packages/compose/src/hooks/use-focus-outside/index.ts
+++ b/packages/compose/src/hooks/use-focus-outside/index.ts
@@ -1,3 +1,16 @@
+/**
+ * External dependencies
+ */
+import type {
+ FocusEventHandler,
+ EventHandler,
+ MouseEventHandler,
+ TouchEventHandler,
+ FocusEvent,
+ MouseEvent,
+ TouchEvent,
+} from 'react';
+
/**
* WordPress dependencies
*/
@@ -6,28 +19,27 @@ 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
- */
+type FocusNormalizedButton =
+ | HTMLButtonElement
+ | HTMLLinkElement
+ | HTMLInputElement;
-// 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.
+ * @param eventTarget The target from a mouse or touch event.
*
- * @return {eventTarget is FocusNormalizedButton} Whether element is a button.
+ * @return Whether element is a button.
*/
-function isFocusNormalizedButton( eventTarget ) {
+function isFocusNormalizedButton(
+ eventTarget: EventTarget
+): eventTarget is FocusNormalizedButton {
if ( ! ( eventTarget instanceof window.HTMLElement ) ) {
return false;
}
@@ -38,54 +50,35 @@ function isFocusNormalizedButton( eventTarget ) {
case 'INPUT':
return INPUT_BUTTON_TYPES.includes(
- /** @type {HTMLInputElement} */ ( eventTarget ).type
+ ( eventTarget as HTMLInputElement ).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.
- */
+type UseFocusOutsideReturn = {
+ onFocus: FocusEventHandler;
+ onMouseDown: MouseEventHandler;
+ onMouseUp: MouseEventHandler;
+ onTouchStart: TouchEventHandler;
+ onTouchEnd: TouchEventHandler;
+ onBlur: FocusEventHandler;
+};
/**
* 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.
+ * @param 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.
+ * @return 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 ) {
+export default function useFocusOutside(
+ onFocusOutside: ( event: FocusEvent ) => void
+): UseFocusOutsideReturn {
const currentOnFocusOutside = useRef( onFocusOutside );
useEffect( () => {
currentOnFocusOutside.current = onFocusOutside;
@@ -93,10 +86,7 @@ export default function useFocusOutside( onFocusOutside ) {
const preventBlurCheck = useRef( false );
- /**
- * @type {import('react').MutableRefObject}
- */
- const blurCheckTimeoutId = useRef();
+ const blurCheckTimeoutId = useRef< number | undefined >();
/**
* Cancel a blur check timeout.
@@ -124,13 +114,11 @@ export default function useFocusOutside( onFocusOutside ) {
* button elements when clicked, while others do. The logic here
* intends to normalize this as treating click on buttons as focus.
*
+ * @param event
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*/
- const normalizeButtonFocus = useCallback(
- /**
- * @param {SyntheticEvent} event Event for mousedown or mouseup.
- */
- ( event ) => {
+ const normalizeButtonFocus: EventHandler< MouseEvent | TouchEvent > =
+ useCallback( ( event ) => {
const { type, target } = event;
const isInteractionEnd = [ 'mouseup', 'touchend' ].includes( type );
@@ -139,9 +127,7 @@ export default function useFocusOutside( onFocusOutside ) {
} else if ( isFocusNormalizedButton( target ) ) {
preventBlurCheck.current = true;
}
- },
- []
- );
+ }, [] );
/**
* A callback triggered when a blur event occurs on the element the handler
@@ -150,37 +136,31 @@ export default function useFocusOutside( onFocusOutside ) {
* Calls the `onFocusOutside` callback in an immediate timeout if focus has
* move outside the bound element and is still within the document.
*/
- const queueBlurCheck = useCallback(
- /**
- * @param {SyntheticEvent} event Blur event.
- */
- ( 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 ) {
+ const queueBlurCheck: FocusEventHandler = 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;
}
- 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 );
- },
- []
- );
+ if ( 'function' === typeof currentOnFocusOutside.current ) {
+ currentOnFocusOutside.current( event );
+ }
+ }, 0 );
+ }, [] );
return {
onFocus: cancelBlurCheck,
From b952b3bc07b89874da948461ebfeec211e49883a Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Thu, 27 Oct 2022 15:24:32 +0200
Subject: [PATCH 4/6] CHANGELOG
---
packages/compose/CHANGELOG.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/compose/CHANGELOG.md b/packages/compose/CHANGELOG.md
index 042758eda8484..945dd2120fcee 100644
--- a/packages/compose/CHANGELOG.md
+++ b/packages/compose/CHANGELOG.md
@@ -4,7 +4,8 @@
### Internal
-- `useDisabled`: Refactor the component to rely on the HTML `inert` attribute.
+- `useDisabled`: Refactor the component to rely on the HTML `inert` attribute ([#44865](https://github.com/WordPress/gutenberg/pull/44865)).
+- `useFocusOutside`: Refactor the hook to TypeScript, rewrite tests using modern RTL and jest features ([#45317](https://github.com/WordPress/gutenberg/pull/45317)).
## 5.18.0 (2022-10-19)
From 3470234a17496365e1a8c2b9d5f986c72cb91cef Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Thu, 27 Oct 2022 15:27:20 +0200
Subject: [PATCH 5/6] Add ref to the return value of useFocusOutside, use it to
check if active element is in the wrapper
---
packages/components/src/modal/index.tsx | 4 +++-
.../compose/src/hooks/use-dialog/index.ts | 22 ++++++++++++-------
.../src/hooks/use-focus-outside/index.ts | 13 ++++++++++-
3 files changed, 29 insertions(+), 10 deletions(-)
diff --git a/packages/components/src/modal/index.tsx b/packages/components/src/modal/index.tsx
index 8c4f3a8535704..7478a6f177f60 100644
--- a/packages/components/src/modal/index.tsx
+++ b/packages/components/src/modal/index.tsx
@@ -75,7 +75,8 @@ function UnforwardedModal(
const focusOnMountRef = useFocusOnMount( focusOnMount );
const constrainedTabbingRef = useConstrainedTabbing();
const focusReturnRef = useFocusReturn();
- const focusOutsideProps = useFocusOutside( onRequestClose );
+ const { ref: focusOutsideRef, ...focusOutsideProps } =
+ useFocusOutside( onRequestClose );
const [ hasScrolledContent, setHasScrolledContent ] = useState( false );
@@ -147,6 +148,7 @@ function UnforwardedModal(
constrainedTabbingRef,
focusReturnRef,
focusOnMountRef,
+ focusOutsideRef,
] ) }
role={ role }
aria-label={ contentLabel }
diff --git a/packages/compose/src/hooks/use-dialog/index.ts b/packages/compose/src/hooks/use-dialog/index.ts
index bac94602489e6..3b27f74f479aa 100644
--- a/packages/compose/src/hooks/use-dialog/index.ts
+++ b/packages/compose/src/hooks/use-dialog/index.ts
@@ -54,15 +54,20 @@ function useDialog( options: DialogOptions ): useDialogReturn {
const constrainedTabbingRef = useConstrainedTabbing();
const focusOnMountRef = useFocusOnMount( options.focusOnMount );
const focusReturnRef = useFocusReturn();
- const focusOutsideProps = useFocusOutside( ( event ) => {
- // This unstable prop is here only to manage backward compatibility
- // for the Popover component otherwise, the onClose should be enough.
- if ( currentOptions.current?.__unstableOnClose ) {
- currentOptions.current.__unstableOnClose( 'focus-outside', event );
- } else if ( currentOptions.current?.onClose ) {
- currentOptions.current.onClose();
+ const { ref: focusOutsideRef, ...focusOutsideProps } = useFocusOutside(
+ ( event ) => {
+ // This unstable prop is here only to manage backward compatibility
+ // for the Popover component otherwise, the onClose should be enough.
+ if ( currentOptions.current?.__unstableOnClose ) {
+ currentOptions.current.__unstableOnClose(
+ 'focus-outside',
+ event
+ );
+ } else if ( currentOptions.current?.onClose ) {
+ currentOptions.current.onClose();
+ }
}
- } );
+ );
const closeOnEscapeRef = useCallback( ( node: HTMLElement ) => {
if ( ! node ) {
return;
@@ -87,6 +92,7 @@ function useDialog( options: DialogOptions ): useDialogReturn {
options.focusOnMount !== false ? focusReturnRef : null,
options.focusOnMount !== false ? focusOnMountRef : null,
closeOnEscapeRef,
+ focusOutsideRef,
] ),
{
...focusOutsideProps,
diff --git a/packages/compose/src/hooks/use-focus-outside/index.ts b/packages/compose/src/hooks/use-focus-outside/index.ts
index f296bc07e495b..2748b90c94322 100644
--- a/packages/compose/src/hooks/use-focus-outside/index.ts
+++ b/packages/compose/src/hooks/use-focus-outside/index.ts
@@ -9,6 +9,7 @@ import type {
FocusEvent,
MouseEvent,
TouchEvent,
+ MutableRefObject,
} from 'react';
/**
@@ -64,6 +65,7 @@ type UseFocusOutsideReturn = {
onTouchStart: TouchEventHandler;
onTouchEnd: TouchEventHandler;
onBlur: FocusEventHandler;
+ ref: MutableRefObject< HTMLElement | null >;
};
/**
@@ -79,6 +81,7 @@ type UseFocusOutsideReturn = {
export default function useFocusOutside(
onFocusOutside: ( event: FocusEvent ) => void
): UseFocusOutsideReturn {
+ const wrapperRef = useRef< HTMLElement | null >( null );
const currentOnFocusOutside = useRef( onFocusOutside );
useEffect( () => {
currentOnFocusOutside.current = onFocusOutside;
@@ -147,11 +150,18 @@ export default function useFocusOutside(
}
blurCheckTimeoutId.current = setTimeout( () => {
+ const documentLostFocus = ! document.hasFocus();
+ const wrapperEl = wrapperRef.current;
+ const activeElement =
+ wrapperEl?.ownerDocument.activeElement ?? null;
+ const activeElementIsInWrapper =
+ wrapperEl?.contains( activeElement );
+
// 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() ) {
+ if ( documentLostFocus || activeElementIsInWrapper ) {
event.preventDefault();
return;
}
@@ -169,5 +179,6 @@ export default function useFocusOutside(
onTouchStart: normalizeButtonFocus,
onTouchEnd: normalizeButtonFocus,
onBlur: queueBlurCheck,
+ ref: wrapperRef,
};
}
From b09fc7b9861f2f2264be5338a94118e00036fc70 Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Thu, 27 Oct 2022 15:27:35 +0200
Subject: [PATCH 6/6] WIP
---
.../components/src/modal/stories/index.tsx | 38 +++++
.../src/hooks/use-focus-outside/index.ts | 16 +++
.../src/hooks/use-focus-outside/test/index.js | 132 +++++++++++++-----
3 files changed, 153 insertions(+), 33 deletions(-)
diff --git a/packages/components/src/modal/stories/index.tsx b/packages/components/src/modal/stories/index.tsx
index 42b7874b1eeaa..f70d950edabdb 100644
--- a/packages/components/src/modal/stories/index.tsx
+++ b/packages/components/src/modal/stories/index.tsx
@@ -2,6 +2,8 @@
* External dependencies
*/
import type { ComponentStory, ComponentMeta } from '@storybook/react';
+import type { FC } from 'react';
+import { createPortal } from 'react-dom';
/**
* WordPress dependencies
@@ -41,6 +43,29 @@ const meta: ComponentMeta< typeof Modal > = {
};
export default meta;
+const IFrame: FC< { title?: string; width: number; height: number } > = ( {
+ children,
+ ...props
+} ) => {
+ const [ contentRef, setContentRef ] = useState< HTMLIFrameElement | null >(
+ null
+ );
+ const mountNode = contentRef?.contentWindow?.document?.body;
+
+ return (
+
+ );
+};
+
const Template: ComponentStory< typeof Modal > = ( {
onRequestClose,
...args
@@ -75,6 +100,19 @@ const Template: ComponentStory< typeof Modal > = ( {
anim id est laborum.
+
+
+
+
+
+
diff --git a/packages/compose/src/hooks/use-focus-outside/index.ts b/packages/compose/src/hooks/use-focus-outside/index.ts
index 2748b90c94322..76525ff65b907 100644
--- a/packages/compose/src/hooks/use-focus-outside/index.ts
+++ b/packages/compose/src/hooks/use-focus-outside/index.ts
@@ -144,11 +144,20 @@ export default function useFocusOutside(
// due to recycling behavior, except when explicitly persisted.
event.persist();
+ console.log( `${ event.target } blurred!` );
+
// Skip blur check if clicking button. See `normalizeButtonFocus`.
if ( preventBlurCheck.current ) {
return;
}
+ // console.log( {
+ // docAE: document.activeElement,
+ // wrapperAE: wrapperRef.current?.ownerDocument.activeElement,
+ // eventAE: event.target.ownerDocument.activeElement,
+ // relatedTarget: event.relatedTarget,
+ // } );
+
blurCheckTimeoutId.current = setTimeout( () => {
const documentLostFocus = ! document.hasFocus();
const wrapperEl = wrapperRef.current;
@@ -157,6 +166,13 @@ export default function useFocusOutside(
const activeElementIsInWrapper =
wrapperEl?.contains( activeElement );
+ console.log( {
+ documentLostFocus,
+ activeElementIsInWrapper,
+ wrapperEl,
+ activeElement,
+ } );
+
// 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.
diff --git a/packages/compose/src/hooks/use-focus-outside/test/index.js b/packages/compose/src/hooks/use-focus-outside/test/index.js
index c0b01e60ed387..c9dd83e53fc66 100644
--- a/packages/compose/src/hooks/use-focus-outside/test/index.js
+++ b/packages/compose/src/hooks/use-focus-outside/test/index.js
@@ -3,21 +3,38 @@
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { createPortal } from 'react-dom';
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import useFocusOutside from '../';
+const IFrame = ( { children, ...props } ) => {
+ const [ contentRef, setContentRef ] = useState( null );
+ const mountNode = contentRef?.contentWindow?.document?.body;
+
+ return (
+
+ );
+};
+
const FocusOutsideComponent = ( { onFocusOutside: callback } ) => (
{ /* Wrapper */ }
-
@@ -25,6 +42,20 @@ const FocusOutsideComponent = ( { onFocusOutside: callback } ) => (
);
describe( 'useFocusOutside', () => {
+ let mockedDocumentHasFocus;
+
+ beforeAll( () => {
+ // 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.
+ mockedDocumentHasFocus = jest
+ .spyOn( document, 'hasFocus' )
+ .mockImplementation( () => true );
+ } );
+
+ afterAll( () => {
+ mockedDocumentHasFocus.mockRestore();
+ } );
+
it( 'should not call handler if focus shifts to element within component', async () => {
const mockOnFocusOutside = jest.fn();
const user = userEvent.setup( {
@@ -92,37 +123,37 @@ describe( 'useFocusOutside', () => {
expect( mockOnFocusOutside ).toHaveBeenCalled();
} );
- it( 'should not call handler if focus shifts outside the component when the document does not have focus', async () => {
- // 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.
- const mockedDocumentHasFocus = jest
- .spyOn( document, 'hasFocus' )
- .mockImplementation( () => false );
- const mockOnFocusOutside = jest.fn();
- const user = userEvent.setup( {
- advanceTimers: jest.advanceTimersByTime,
- } );
-
- render(
-
- );
-
- // Click and focus button inside the wrapper, then click and focus
- // a button outside the wrapper.
- await user.click(
- screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
- );
- await user.click(
- screen.getByRole( 'button', { name: 'Button outside the wrapper' } )
- );
-
- // The handler is not called thanks to the mocked return value of
- // `document.hasFocus()`
- expect( mockOnFocusOutside ).not.toHaveBeenCalled();
-
- // Restore the `document.hasFocus()` function to its original implementation.
- mockedDocumentHasFocus.mockRestore();
- } );
+ // it.skip( 'should not call handler if focus shifts outside the component when the document does not have focus', async () => {
+ // // 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.
+ // const mockedDocumentHasFocus = jest
+ // .spyOn( document, 'hasFocus' )
+ // .mockImplementation( () => false );
+ // const mockOnFocusOutside = jest.fn();
+ // const user = userEvent.setup( {
+ // advanceTimers: jest.advanceTimersByTime,
+ // } );
+
+ // render(
+ //
+ // );
+
+ // // Click and focus button inside the wrapper, then click and focus
+ // // a button outside the wrapper.
+ // await user.click(
+ // screen.getByRole( 'button', { name: 'Button inside the wrapper' } )
+ // );
+ // await user.click(
+ // screen.getByRole( 'button', { name: 'Button outside the wrapper' } )
+ // );
+
+ // // The handler is not called thanks to the mocked return value of
+ // // `document.hasFocus()`
+ // expect( mockOnFocusOutside ).not.toHaveBeenCalled();
+
+ // // Restore the `document.hasFocus()` function to its original implementation.
+ // mockedDocumentHasFocus.mockRestore();
+ // } );
it( 'should cancel check when unmounting while queued', async () => {
const mockOnFocusOutside = jest.fn();
@@ -149,4 +180,39 @@ describe( 'useFocusOutside', () => {
expect( mockOnFocusOutside ).not.toHaveBeenCalled();
} );
+
+ it.only( 'should not call handler when focusing inside an iframe within component', async () => {
+ const mockOnFocusOutside = jest.fn();
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render(
+
+ );
+
+ // Click and focus button inside the wrapper
+ await user.click(
+ screen.getByRole( 'button', {
+ name: 'Button inside the wrapper',
+ } )
+ );
+
+ // console.log(
+ // screen
+ // .getByTitle( 'test-iframe' )
+ // .contentWindow.document.querySelector( 'button' )
+ // );
+
+ // // Click and focus button outside the wrapper
+ await user.click(
+ screen
+ .getByTitle( 'test-iframe' )
+ .contentWindow.document.querySelector( 'button' )
+ );
+
+ await user.click( screen.getByTitle( 'test-iframe' ) );
+
+ expect( mockOnFocusOutside ).not.toHaveBeenCalled();
+ } );
} );