-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Try/use focus outside iframe #52040
Changes from 4 commits
f823c1e
32ec841
271aede
eef6a09
84d0707
fbeed05
6e7c669
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
|
@@ -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. | ||
|
@@ -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( () => { | ||
|
@@ -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 ); | ||
} 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 ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
*/ | ||
|
@@ -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( () => { | ||
|
@@ -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 | ||
|
@@ -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; | ||
|
@@ -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 ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
There was a problem hiding this comment.
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.