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 } ) => ( +
+ { /* Wrapper */ }
- + +
- ); - class TestComponent extends Component { - render() { - return ; - } - } + +
+); - 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(); + } ); } );