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
Changes from 1 commit
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
88 changes: 81 additions & 7 deletions packages/compose/src/hooks/use-focus-outside/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* 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 @@ -58,6 +58,10 @@ type UseFocusOutsideReturn = {
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 @@ -80,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 );
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.

setPollingData( null );
} 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 );
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.

} else if ( pollingIntervalId.current ) {
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 Down Expand Up @@ -134,6 +187,10 @@ export default function useFocusOutside(
// 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 @@ -157,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