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

useFocusOutside: rewrite hook to TypeScript, rewrite tests to model RTL and user-event #45317

Merged
merged 9 commits into from
Oct 31, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion packages/compose/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Copy link
Member

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 👍

- `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)

Expand Down
3 changes: 1 addition & 2 deletions packages/compose/src/hooks/use-dialog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
Expand All @@ -35,7 +34,7 @@ type DialogOptions = {

type useDialogReturn = [
RefCallback< HTMLElement >,
FocusOutsideReturnValue & Pick< HTMLElement, 'tabIndex' >
ReturnType< typeof useFocusOutside > & Pick< HTMLElement, 'tabIndex' >
];

/**
Expand Down
193 changes: 0 additions & 193 deletions packages/compose/src/hooks/use-focus-outside/index.js

This file was deleted.

178 changes: 178 additions & 0 deletions packages/compose/src/hooks/use-focus-outside/index.ts
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.
Copy link
Member

Choose a reason for hiding this comment

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

Missing the param type?

Suggested change
* @param eventTarget The target from a mouse or touch event.
* @param {EventTarget} eventTarget The target from a mouse or touch event.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)

Copy link
Member

Choose a reason for hiding this comment

The 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,
};
}
Loading