-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
useFocusOutside: rewrite hook to TypeScript, rewrite tests to model RTL and user-event #45317
Changes from all commits
6e584f8
5f7404e
ae3ef82
5e1662a
b4b5737
9f5884e
c9cade6
6309e63
ba97cff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,178 @@ | ||||||
/** | ||||||
* External dependencies | ||||||
*/ | ||||||
import type { | ||||||
FocusEventHandler, | ||||||
EventHandler, | ||||||
MouseEventHandler, | ||||||
TouchEventHandler, | ||||||
FocusEvent, | ||||||
MouseEvent, | ||||||
TouchEvent, | ||||||
} from 'react'; | ||||||
|
||||||
/** | ||||||
* WordPress dependencies | ||||||
*/ | ||||||
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. | ||||||
*/ | ||||||
const INPUT_BUTTON_TYPES = [ 'button', 'submit' ]; | ||||||
|
||||||
/** | ||||||
* List of HTML button elements subject to focus normalization | ||||||
* | ||||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus | ||||||
*/ | ||||||
type FocusNormalizedButton = | ||||||
ciampo marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| HTMLButtonElement | ||||||
| HTMLLinkElement | ||||||
| HTMLInputElement; | ||||||
|
||||||
/** | ||||||
* 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 The target from a mouse or touch event. | ||||||
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. Missing the param type?
Suggested change
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. In the past, I've usually avoided having types in JSDocs in TypeScript files, as the code itself is already typed. The only exceptions have been for when the JSDoc types where necessary for automatic doc generation. Adding types here would also require us to add back a lot of JSDoc extra type def (see the code deletions in this file) 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. I generally agree, just wanted to pick your thoughts 👍 Happy to keep it as you suggested. |
||||||
* | ||||||
* @return Whether the element is a button element subject to focus normalization. | ||||||
*/ | ||||||
function isFocusNormalizedButton( | ||||||
eventTarget: EventTarget | ||||||
): eventTarget is FocusNormalizedButton { | ||||||
if ( ! ( eventTarget instanceof window.HTMLElement ) ) { | ||||||
return false; | ||||||
} | ||||||
switch ( eventTarget.nodeName ) { | ||||||
case 'A': | ||||||
case 'BUTTON': | ||||||
return true; | ||||||
|
||||||
case 'INPUT': | ||||||
return INPUT_BUTTON_TYPES.includes( | ||||||
( eventTarget as HTMLInputElement ).type | ||||||
); | ||||||
} | ||||||
|
||||||
return false; | ||||||
} | ||||||
|
||||||
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 onFocusOutside A callback triggered when focus moves outside | ||||||
* the element the event handlers are bound to. | ||||||
* | ||||||
* @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: ( event: FocusEvent ) => void | ||||||
): UseFocusOutsideReturn { | ||||||
const currentOnFocusOutside = useRef( onFocusOutside ); | ||||||
useEffect( () => { | ||||||
currentOnFocusOutside.current = onFocusOutside; | ||||||
}, [ onFocusOutside ] ); | ||||||
|
||||||
const preventBlurCheck = useRef( false ); | ||||||
|
||||||
const blurCheckTimeoutId = useRef< number | undefined >(); | ||||||
|
||||||
/** | ||||||
* Cancel a blur check timeout. | ||||||
*/ | ||||||
const cancelBlurCheck = useCallback( () => { | ||||||
clearTimeout( blurCheckTimeoutId.current ); | ||||||
}, [] ); | ||||||
|
||||||
// Cancel blur checks on unmount. | ||||||
useEffect( () => { | ||||||
return () => cancelBlurCheck(); | ||||||
}, [] ); | ||||||
|
||||||
// Cancel a blur check if the callback or ref is no longer provided. | ||||||
useEffect( () => { | ||||||
if ( ! onFocusOutside ) { | ||||||
cancelBlurCheck(); | ||||||
} | ||||||
}, [ onFocusOutside, cancelBlurCheck ] ); | ||||||
|
||||||
/** | ||||||
* Handles a mousedown or mouseup event to respectively assign and | ||||||
* unassign a flag for preventing blur check on button elements. Some | ||||||
* browsers, namely Firefox and Safari, do not emit a focus event on | ||||||
* 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: 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; | ||||||
} | ||||||
}, [] ); | ||||||
|
||||||
/** | ||||||
* A callback triggered when a blur event occurs on the element the handler | ||||||
* is bound to. | ||||||
* | ||||||
* 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 ) => { | ||||||
// 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; | ||||||
} | ||||||
|
||||||
if ( 'function' === typeof currentOnFocusOutside.current ) { | ||||||
currentOnFocusOutside.current( event ); | ||||||
} | ||||||
}, 0 ); | ||||||
}, [] ); | ||||||
|
||||||
return { | ||||||
onFocus: cancelBlurCheck, | ||||||
onMouseDown: normalizeButtonFocus, | ||||||
onMouseUp: normalizeButtonFocus, | ||||||
onTouchStart: normalizeButtonFocus, | ||||||
onTouchEnd: normalizeButtonFocus, | ||||||
onBlur: queueBlurCheck, | ||||||
}; | ||||||
} |
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.
Thanks for adding the missing PR link 👍