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

Refactor withFocusOutside to hook #27369

Open
wants to merge 17 commits into
base: master
from

Conversation

@talldan
Copy link
Contributor

@talldan talldan commented Nov 30, 2020

Description

Refactors withFocusOutside to a react hook useFocusOutside. The hook is now used within withFocusOutside to provide back compat.

The hook has the following API:

const eventHandlers = useFocusOutside( onFocusOutside )

return (
    <div { ...eventHandlers }>
        // ... children
    </div>
);

How has this been tested?

Existing tests for withFocusOutside still pass.
Add matching tests for useFocusOutside.

Manual testing.

  1. Interact with popovers, modals and the block library.
  2. Each of these should close when focus moves outside these elements.

Types of changes

Non-breaking refactor.

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • I've updated all React Native files affected by any refactorings/renamings in this PR.
@github-actions
Copy link

@github-actions github-actions bot commented Nov 30, 2020

Size Change: -128 B (0%)

Total Size: 1.19 MB

Filename Size Change
build/annotations/index.js 3.8 kB +1 B
build/block-directory/index.js 8.72 kB +2 B (0%)
build/block-editor/index.js 128 kB -4 B (0%)
build/block-library/editor-rtl.css 8.88 kB -6 B (0%)
build/block-library/editor.css 8.88 kB -6 B (0%)
build/block-library/index.js 148 kB +13 B (0%)
build/block-library/style-rtl.css 8.34 kB +37 B (0%)
build/block-library/style.css 8.34 kB +37 B (0%)
build/blocks/index.js 48.1 kB -3 B (0%)
build/components/index.js 172 kB -104 B (0%)
build/data/index.js 8.98 kB +1 B
build/dom/index.js 4.95 kB -1 B
build/edit-navigation/index.js 11.1 kB -2 B (0%)
build/edit-post/index.js 306 kB -1 B
build/edit-site/index.js 24.1 kB -8 B (0%)
build/edit-widgets/index.js 26.3 kB +1 B
build/editor/index.js 43.2 kB -75 B (0%)
build/editor/style-rtl.css 3.85 kB -2 B (0%)
build/editor/style.css 3.85 kB +2 B (0%)
build/element/index.js 4.62 kB -3 B (0%)
build/format-library/index.js 6.86 kB +2 B (0%)
build/keyboard-shortcuts/index.js 2.54 kB -1 B
build/keycodes/index.js 1.93 kB -1 B
build/media-utils/index.js 5.31 kB -3 B (0%)
build/nux/index.js 3.42 kB -1 B
build/plugins/index.js 2.56 kB -1 B
build/rich-text/index.js 13.4 kB -1 B
build/server-side-render/index.js 2.77 kB +1 B
build/viewport/index.js 1.86 kB -2 B (0%)
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/api-fetch/index.js 3.42 kB 0 B
build/autop/index.js 2.84 kB 0 B
build/blob/index.js 665 B 0 B
build/block-directory/style-rtl.css 943 B 0 B
build/block-directory/style.css 942 B 0 B
build/block-editor/style-rtl.css 11.2 kB 0 B
build/block-editor/style.css 11.2 kB 0 B
build/block-library/theme-rtl.css 789 B 0 B
build/block-library/theme.css 790 B 0 B
build/block-serialization-default-parser/index.js 1.88 kB 0 B
build/block-serialization-spec-parser/index.js 3.06 kB 0 B
build/components/style-rtl.css 15.3 kB 0 B
build/components/style.css 15.3 kB 0 B
build/compose/index.js 9.95 kB 0 B
build/core-data/index.js 14.8 kB 0 B
build/data-controls/index.js 827 B 0 B
build/date/index.js 11.2 kB 0 B
build/deprecated/index.js 768 B 0 B
build/dom-ready/index.js 571 B 0 B
build/edit-navigation/style-rtl.css 881 B 0 B
build/edit-navigation/style.css 885 B 0 B
build/edit-post/style-rtl.css 6.42 kB 0 B
build/edit-post/style.css 6.4 kB 0 B
build/edit-site/style-rtl.css 4.06 kB 0 B
build/edit-site/style.css 4.06 kB 0 B
build/edit-widgets/style-rtl.css 3.13 kB 0 B
build/edit-widgets/style.css 3.13 kB 0 B
build/editor/editor-styles-rtl.css 476 B 0 B
build/editor/editor-styles.css 478 B 0 B
build/escape-html/index.js 735 B 0 B
build/format-library/style-rtl.css 547 B 0 B
build/format-library/style.css 548 B 0 B
build/hooks/index.js 2.27 kB 0 B
build/html-entities/index.js 623 B 0 B
build/i18n/index.js 3.56 kB 0 B
build/is-shallow-equal/index.js 698 B 0 B
build/list-reusable-blocks/index.js 3.1 kB 0 B
build/list-reusable-blocks/style-rtl.css 476 B 0 B
build/list-reusable-blocks/style.css 476 B 0 B
build/notices/index.js 1.82 kB 0 B
build/nux/style-rtl.css 671 B 0 B
build/nux/style.css 668 B 0 B
build/primitives/index.js 1.43 kB 0 B
build/priority-queue/index.js 791 B 0 B
build/redux-routine/index.js 2.84 kB 0 B
build/reusable-blocks/index.js 2.92 kB 0 B
build/shortcode/index.js 1.69 kB 0 B
build/token-list/index.js 1.27 kB 0 B
build/url/index.js 2.84 kB 0 B
build/warning/index.js 1.14 kB 0 B
build/wordcount/index.js 1.22 kB 0 B

compressed-size-action

if ( isInteractionEnd ) {
preventBlurCheck.current = false;
} else if (
isFocusNormalizedButton( /** @type {HTMLElement} */ ( target ) )

This comment has been minimized.

@sirreal

sirreal Nov 30, 2020
Member

Should this be currentTarget?

The currentTarget read-only property of the Event interface identifies the current target for the event, as the event traverses the DOM. It always refers to the element to which the event handler has been attached, as opposed to Event.target, which identifies the element on which the event occurred and which may be its descendant.

Take this with a grain of salt, I'm not especially familiar with how this is intended to be used.

The default type arguments for SyntheticEvent suggest this, where currentTarget defaults to type Element (which extends Node). Synthetic event (with default type arguments) here resolves to something like:

interface SyntheticEvent {
  currentTarget: EventTarget & Element;
  target: EventTarget;
  // …
}

Note that SyntheticEvent doesn't give us access to pass parameters to target, which suggests that we can't reason much about it.

This comment has been minimized.

@talldan

talldan Dec 2, 2020
Author Contributor

I always have to look at this code closely to re-understand it!

I don't think it should be currentTarget, as mentioned that would be the element the handler is bound to (usually some wrapping div), but what this function is trying to determine is whether a child input, link or button had some interaction.

This comment has been minimized.

@sirreal

sirreal Dec 2, 2020
Member

Nice, that's really helpful 👍

I took a look at why these types might be like this and found this MDN on EventTarget:

Element, Document, and Window are the most common event targets, but other objects can be event targets, too. For example XMLHttpRequest, AudioNode, AudioContext, and others.

It seems like the safest thing to do may be to make isFocusNormalizedButton accept an EventTarget.

Diff to accept `EventTarget`
diff --git a/packages/components/src/utils/hooks/use-focus-outside/index.js b/packages/components/src/utils/hooks/use-focus-outside/index.js
index b04efbf10d..690acd0a15 100644
--- a/packages/components/src/utils/hooks/use-focus-outside/index.js
+++ b/packages/components/src/utils/hooks/use-focus-outside/index.js
@@ -16,18 +16,22 @@ import { useEffect, useRef } from '@wordpress/element';
  */
 const INPUT_BUTTON_TYPES = [ 'button', 'submit' ];
 
+/* eslint-disable jsdoc/valid-types */
 /**
  * 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 {HTMLElement} element Element to test.
+ * @param {EventTarget} eventTarget Element to test.
  *
- * @return {boolean} Whether element is a button.
+ * @return {EventTarget is HTMLElement} Whether element is a button.
  */
-function isFocusNormalizedButton( element ) {
-	switch ( element.nodeName ) {
+function isFocusNormalizedButton( eventTarget ) {
+	if ( ! ( eventTarget instanceof window.HTMLElement ) ) {
+		return false;
+	}
+	switch ( eventTarget.nodeName ) {
 		case 'A':
 		case 'BUTTON':
 			return true;
@@ -35,12 +39,13 @@ function isFocusNormalizedButton( element ) {
 		case 'INPUT':
 			return includes(
 				INPUT_BUTTON_TYPES,
-				/** @type {HTMLInputElement} */ ( element ).type
+				/** @type {HTMLInputElement} */ ( eventTarget ).type
 			);
 	}
 
 	return false;
 }
+/* eslint-enable jsdoc/valid-types */
 
 /**
  * @typedef {import('react').SyntheticEvent} SyntheticEvent
@@ -102,9 +107,7 @@ export default function useFocusOutside( onFocusOutside, __unstableNodeRef ) {
 
 		if ( isInteractionEnd ) {
 			preventBlurCheck.current = false;
-		} else if (
-			isFocusNormalizedButton( /** @type {HTMLElement} */ ( target ) )
-		) {
+		} else if ( isFocusNormalizedButton( target ) ) {
 			preventBlurCheck.current = true;
 		}
 	};

This comment has been minimized.

@talldan

talldan Dec 3, 2020
Author Contributor

Awesome, thanks for the suggestion, that does feel better!

Does something like the following also work for the return value of isFocusNormalizedButton?

/**
 * @typedef {HTMLButtonElement | HTMLLinkElement | HTMLInputElement} FocusNormalizedButton
 *
 * // ...
 *
 * @return {eventTarget is FocusNormalizedButton} Whether element is a button.
 */

Was wondering if that'd be more accurate (since the return could also be a link or a button)?

This comment has been minimized.

@sirreal

sirreal Dec 3, 2020
Member

Nice work 😎 That's more accurate, but it may not be very different because we'll only be able to use the common parts of that type union. For example, you can access href on something of type HTMLLinkElement, but not HTMLLinkElement | HTMLButtonElement because href doesn't exist on HTMLButtonElement.

👍 This bit is to go either way and should be safer.

@talldan talldan force-pushed the refactor/with-focus-outside-to-hook branch from 26dfffe to c6717b4 Dec 2, 2020
@talldan talldan self-assigned this Dec 2, 2020
@talldan talldan force-pushed the refactor/with-focus-outside-to-hook branch from 201074a to adca380 Dec 2, 2020
/**
* @typedef {Object} FocusOutsideReactElement
* @property {EventCallback} handleFocusOutside callback for a focus outside event.
*/
Comment on lines +64 to +67

This comment has been minimized.

@talldan

talldan Dec 3, 2020
Author Contributor

I've typed this instance of a react class component as an {Object} with the method a @property, which works, but I'm not sure if there's a better option like declaring it as an interface. I couldn't figure out how to do that with JSDoc!

Couldn't see any suggestions online, so

This comment has been minimized.

@sirreal

sirreal Dec 3, 2020
Member

We want something that satisfies an interface where we have a handleFocusOutside function, which is what you've described here. It doesn't look like we need to care about whether it's a Component or something else as long as it satisfies our interface.

FYI: you can omit {Object} in this case where we have @property in the typedef.

@talldan talldan marked this pull request as ready for review Dec 3, 2020
@talldan talldan requested review from ajitbohra and chrisvanpatten as code owners Dec 3, 2020
/**
* @typedef {import('react').SyntheticEvent} SyntheticEvent
*/

/**
* @callback EventCallback
* @param {SyntheticEvent} event input related event.
*/
Comment on lines +55 to +62

This comment has been minimized.

@sirreal

sirreal Dec 3, 2020
Member

This highlights one of the limitations of JSDoc compared to TypeScript. Default type arguments cannot be expressed, and we have to provide type parameters on the imported types. If we want to rely on default type arguments, we either cannot do a single import like /** @typedef {import('react').SyntheticEvent} SyntheticEvent */ or we have to do imports for each of the default types. I'll try to clarify

// 👇 SyntheticEvent accepts type parameters, but now we can't pass them. This is "fixed" as `SyntheticEvent<Element, Event>`.
/**
 * @typedef {import('react').SyntheticEvent} SyntheticEvent
 */

// Now, although later we can reason about different event types we can't pass them into synthetic event 😞
/**
 * @callback EventCallback
 * @param {SyntheticEvent} event input related event.
 */

// If we want to wrap an imported type but keep (some of) the type parameters, we have to import here:
/**
 * @template {Event} E
 * @callback EventCallback
 * @param {SyntheticEvent<Element, E>} event input related event.
 * @return void
 */

// Now, for e.g. `handleFocusOutside` we can be very specific that it's a _focus_ event handler
/**
 * @typedef FocusOutsideReactElement
 * @property {EventCallback<FocusEvent>} handleFocusOutside callback for a focus outside event.
 */

This is a tradeoff, if we want an EventCallback for Event, we need to type it as EventCallback<Event> or include another typedef without a type parameter.

We don't need to consider any of these tradeoffs in TypeScript syntax, it's better:

import type { SyntheticEvent } from 'react'
type SyntheticEventHandler<E extends Event = Event> = (e: SyntheticEvent<Element, E>) => void;
interface HandleFocusOutside {
    handleFocusOutside: SyntheticEventHandler<FocusEvent>;
    handleAnyEvent: SyntheticEventHandler; // No type params, handles `Event` (for demonstration)
}
/**
* @typedef {Object} FocusOutsideReactElement
* @property {EventCallback} handleFocusOutside callback for a focus outside event.
*/

This comment has been minimized.

@sirreal

sirreal Dec 3, 2020
Member

We want something that satisfies an interface where we have a handleFocusOutside function, which is what you've described here. It doesn't look like we need to care about whether it's a Component or something else as long as it satisfies our interface.

FYI: you can omit {Object} in this case where we have @property in the typedef.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked issues

Successfully merging this pull request may close these issues.

None yet

2 participants
You can’t perform that action at this time.