Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Try/use focus outside iframe #52040

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 33 additions & 20 deletions packages/components/src/confirm-dialog/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import {
render,
screen,
fireEvent,
waitForElementToBeRemoved,
waitFor,
} from '@testing-library/react';
Expand All @@ -18,6 +17,18 @@ import { ConfirmDialog } from '..';
const noop = () => {};

describe( 'Confirm', () => {
let mockedDocumentHasFocus;

beforeEach( () => {
mockedDocumentHasFocus = jest
.spyOn( document, 'hasFocus' )
.mockImplementation( () => true );
} );

afterEach( () => {
mockedDocumentHasFocus.mockRestore();
} );

describe( 'Confirm component', () => {
describe( 'Structure', () => {
it( 'should render correctly', () => {
Expand Down Expand Up @@ -137,24 +148,27 @@ describe( 'Confirm', () => {
} );

it( 'should not render if dialog is closed by clicking the overlay, and the `onCancel` callback should be called', async () => {
const user = userEvent.setup();
const onCancel = jest.fn().mockName( 'onCancel()' );

render(
<ConfirmDialog onConfirm={ noop } onCancel={ onCancel }>
Are you sure?
</ConfirmDialog>
<div aria-label="Under the overlay">
<ConfirmDialog onConfirm={ noop } onCancel={ onCancel }>
Are you sure?
</ConfirmDialog>
</div>
);

const confirmDialog = screen.getByRole( 'dialog' );

//The overlay click is handled by detecting an onBlur from the modal frame.
// TODO: replace with `@testing-library/user-event`
fireEvent.blur( confirmDialog );
await user.click(
screen.getByLabelText( 'Under the overlay' )
);

await waitForElementToBeRemoved( confirmDialog );

expect( confirmDialog ).not.toBeInTheDocument();
expect( onCancel ).toHaveBeenCalled();
await waitFor( () => expect( onCancel ).toHaveBeenCalled() );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Due to the extra logic and changes in timeout, a few more callbacks related to onFocusOutside will fire with a bigger delay

} );

it( 'should not render if dialog is closed by pressing `Escape`, and the `onCancel` callback should be called', async () => {
Expand Down Expand Up @@ -315,23 +329,22 @@ describe( 'Confirm', () => {
} );

it( 'should call the `onCancel` callback if the overlay is clicked', async () => {
const user = userEvent.setup();
const onCancel = jest.fn().mockName( 'onCancel()' );

render(
<ConfirmDialog
isOpen={ true }
onConfirm={ noop }
onCancel={ onCancel }
>
Are you sure?
</ConfirmDialog>
<div aria-label="Under the overlay">
<ConfirmDialog
isOpen={ true }
onConfirm={ noop }
onCancel={ onCancel }
>
Are you sure?
</ConfirmDialog>
</div>
);

const confirmDialog = screen.getByRole( 'dialog' );

//The overlay click is handled by detecting an onBlur from the modal frame.
// TODO: replace with `@testing-library/user-event`
fireEvent.blur( confirmDialog );
await user.click( screen.getByLabelText( 'Under the overlay' ) );

// Wait for a DOM side effect here, so that the `queueBlurCheck` in the
// `useFocusOutside` hook properly executes its timeout task.
Expand Down
9 changes: 9 additions & 0 deletions packages/components/src/modal/stories/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ const Template: ComponentStory< typeof Modal > = ( {

<InputControl style={ { marginBottom: '20px' } } />

<button>Button outside iframe</button>

<iframe
title="Example 1"
width="300"
height="200"
src="https://www.openstreetmap.org/export/embed.html?bbox=-0.004017949104309083%2C51.47612752641776%2C0.00030577182769775396%2C51.478569861898606&layer=mapnik"
/>

<Button variant="secondary" onClick={ closeModal }>
Close Modal
</Button>
Expand Down
142 changes: 102 additions & 40 deletions packages/compose/src/hooks/use-focus-outside/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
/**
* External dependencies
*/
import type {
FocusEventHandler,
EventHandler,
MouseEventHandler,
TouchEventHandler,
FocusEvent,
MouseEvent,
TouchEvent,
} from 'react';

/**
* WordPress dependencies
*/
import { useCallback, useEffect, useRef } from '@wordpress/element';
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';

/**
* Input types which are classified as button types, for use in considering
Expand Down Expand Up @@ -63,14 +50,18 @@ function isFocusNormalizedButton(
}

type UseFocusOutsideReturn = {
onFocus: FocusEventHandler;
onMouseDown: MouseEventHandler;
onMouseUp: MouseEventHandler;
onTouchStart: TouchEventHandler;
onTouchEnd: TouchEventHandler;
onBlur: FocusEventHandler;
onFocus: React.FocusEventHandler;
onMouseDown: React.MouseEventHandler;
onMouseUp: React.MouseEventHandler;
onTouchStart: React.TouchEventHandler;
onTouchEnd: React.TouchEventHandler;
onBlur: React.FocusEventHandler;
};

function isIframe( element?: Element | null ): element is HTMLIFrameElement {
return element?.tagName === 'IFRAME';
}

/**
* A react hook that can be used to check whether focus has moved outside the
* element the event handlers are bound to.
Expand All @@ -82,7 +73,7 @@ type UseFocusOutsideReturn = {
* wrapping element element to capture when focus moves outside that element.
*/
export default function useFocusOutside(
onFocusOutside: ( event: FocusEvent ) => void
onFocusOutside: ( event: React.FocusEvent ) => void
): UseFocusOutsideReturn {
const currentOnFocusOutside = useRef( onFocusOutside );
useEffect( () => {
Expand All @@ -93,6 +84,55 @@ export default function useFocusOutside(

const blurCheckTimeoutId = useRef< number | undefined >();

const [ pollingData, setPollingData ] = useState< {
event: React.FocusEvent< Element >;
wrapperEl: Element;
} | null >( null );
const pollingIntervalId = useRef< number | undefined >();

// Thoughts:
// - it needs to always stop when component unmounted
// - it needs to work when resuming focus from another doc and clicking
// immediately on the backdrop

// Sometimes the blur event is not reliable, for example when focus moves
// to an iframe inside the wrapper. In these scenarios, we resort to polling,
// and we explicitly check if focus has indeed moved outside the wrapper.
useEffect( () => {
if ( pollingData ) {
const { wrapperEl, event } = pollingData;

pollingIntervalId.current = window.setInterval( () => {
const focusedElement = wrapperEl.ownerDocument.activeElement;

if (
! wrapperEl.contains( focusedElement ) &&
wrapperEl.ownerDocument.hasFocus()
) {
// If focus is not inside the wrapper (but the document is in focus),
// we can fire the `onFocusOutside` callback and stop polling.
currentOnFocusOutside.current( event );
setPollingData( null );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's maybe odd to pass the event stored from the last blur that caused polling to begin. I don't know that it matters for any current use case but it's not actually the event that causes this invocation of the callback.

} else if ( ! isIframe( focusedElement ) ) {
// If focus is still inside the wrapper, but an iframe is not the
// element currently focused, we can stop polling, because the regular
// blur events will fire as expected.
setPollingData( null );
}
}, 50 );
} else if ( pollingIntervalId.current ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 50ms here because it's a sweet spot between not running the internal too frequently, but running it frequently enough that it still feels responsive to users.

window.clearInterval( pollingIntervalId.current );
pollingIntervalId.current = undefined;
}

return () => {
if ( pollingIntervalId.current ) {
window.clearInterval( pollingIntervalId.current );
pollingIntervalId.current = undefined;
}
};
}, [ pollingData ] );

/**
* Cancel a blur check timeout.
*/
Expand All @@ -103,7 +143,7 @@ export default function useFocusOutside(
// Cancel blur checks on unmount.
useEffect( () => {
return () => cancelBlurCheck();
}, [] );
}, [ cancelBlurCheck ] );

// Cancel a blur check if the callback or ref is no longer provided.
useEffect( () => {
Expand All @@ -122,17 +162,18 @@ export default function useFocusOutside(
* @param event
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*/
const normalizeButtonFocus: EventHandler< MouseEvent | TouchEvent > =
useCallback( ( event ) => {
const { type, target } = event;
const isInteractionEnd = [ 'mouseup', 'touchend' ].includes( type );

if ( isInteractionEnd ) {
preventBlurCheck.current = false;
} else if ( isFocusNormalizedButton( target ) ) {
preventBlurCheck.current = true;
}
}, [] );
const normalizeButtonFocus: React.EventHandler<
React.MouseEvent | React.TouchEvent
> = useCallback( ( event ) => {
const { type, target } = event;
const isInteractionEnd = [ 'mouseup', 'touchend' ].includes( 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
Expand All @@ -141,11 +182,15 @@ export default function useFocusOutside(
* Calls the `onFocusOutside` callback in an immediate timeout if focus has
* move outside the bound element and is still within the document.
*/
const queueBlurCheck: FocusEventHandler = useCallback( ( event ) => {
const queueBlurCheck: React.FocusEventHandler = useCallback( ( event ) => {
// React does not allow using an event reference asynchronously
// due to recycling behavior, except when explicitly persisted.
event.persist();

// Grab currentTarget immediately,
// otherwise it will change as the event bubbles up.
const wrapperEl = event.currentTarget;

// Skip blur check if clicking button. See `normalizeButtonFocus`.
if ( preventBlurCheck.current ) {
return;
Expand All @@ -169,19 +214,36 @@ export default function useFocusOutside(
}

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() ) {
const activeElement = wrapperEl.ownerDocument.activeElement;

// On blur events, the onFocusOutside prop should not be called:
// 1. If document is not focused
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus.
// 2. If the focus was moved to an element inside the wrapper component
// (this would be the case, for example, of an iframe)
if (
! wrapperEl.ownerDocument.hasFocus() ||
( activeElement && wrapperEl.contains( activeElement ) )
) {
event.preventDefault();

// If focus is moved to an iframe inside the wrapper, start manually
// polling to check for correct focus outside events. See the useEffect
// above for more information.
if ( isIframe( activeElement ) ) {
setPollingData( { wrapperEl, event } );
}

return;
}

if ( 'function' === typeof currentOnFocusOutside.current ) {
currentOnFocusOutside.current( event );
}
}, 0 );
// the timeout delay is necessary to wait for browser's focus event to
// fire after the blur event, and therefore for this callback to be able
// to retrieve the correct document.activeElement.
}, 50 );
}, [] );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit hacky, but I didn't have any easy solution for it (and didn't want to spend a lot of time trying to find one).


return {
Expand Down
Loading
Loading