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

Merged
merged 32 commits into from
Dec 10, 2020
Merged

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 bot commented Nov 30, 2020

Size Change: -77 B (0%)

Total Size: 1.21 MB

Filename Size Change
build/components/index.js 171 kB -336 B (0%)
build/compose/index.js 10.5 kB +259 B (2%)
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/annotations/index.js 3.8 kB 0 B
build/api-fetch/index.js 3.42 kB 0 B
build/autop/index.js 2.83 kB 0 B
build/blob/index.js 665 B 0 B
build/block-directory/index.js 8.72 kB 0 B
build/block-directory/style-rtl.css 943 B 0 B
build/block-directory/style.css 942 B 0 B
build/block-editor/index.js 128 kB 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/editor-rtl.css 9.07 kB 0 B
build/block-library/editor.css 9.07 kB 0 B
build/block-library/index.js 149 kB 0 B
build/block-library/style-rtl.css 8.35 kB 0 B
build/block-library/style.css 8.35 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/blocks/index.js 48.1 kB 0 B
build/components/style-rtl.css 15.4 kB 0 B
build/components/style.css 15.3 kB 0 B
build/core-data/index.js 15.3 kB 0 B
build/data-controls/index.js 827 B 0 B
build/data/index.js 8.98 kB 0 B
build/date/index.js 31.8 kB 0 B
build/deprecated/index.js 769 B 0 B
build/dom-ready/index.js 571 B 0 B
build/dom/index.js 4.95 kB 0 B
build/edit-navigation/index.js 11.1 kB 0 B
build/edit-navigation/style-rtl.css 881 B 0 B
build/edit-navigation/style.css 885 B 0 B
build/edit-post/index.js 306 kB 0 B
build/edit-post/style-rtl.css 6.49 kB 0 B
build/edit-post/style.css 6.47 kB 0 B
build/edit-site/index.js 24.7 kB 0 B
build/edit-site/style-rtl.css 3.93 kB 0 B
build/edit-site/style.css 3.93 kB 0 B
build/edit-widgets/index.js 26.3 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/editor/index.js 43.4 kB 0 B
build/editor/style-rtl.css 3.84 kB 0 B
build/editor/style.css 3.84 kB 0 B
build/element/index.js 4.62 kB 0 B
build/escape-html/index.js 735 B 0 B
build/format-library/index.js 6.74 kB 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 622 B 0 B
build/i18n/index.js 3.57 kB 0 B
build/is-shallow-equal/index.js 698 B 0 B
build/keyboard-shortcuts/index.js 2.54 kB 0 B
build/keycodes/index.js 1.93 kB 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/media-utils/index.js 5.31 kB 0 B
build/notices/index.js 1.82 kB 0 B
build/nux/index.js 3.42 kB 0 B
build/nux/style-rtl.css 671 B 0 B
build/nux/style.css 668 B 0 B
build/plugins/index.js 2.54 kB 0 B
build/primitives/index.js 1.43 kB 0 B
build/priority-queue/index.js 789 B 0 B
build/redux-routine/index.js 2.84 kB 0 B
build/reusable-blocks/index.js 2.92 kB 0 B
build/rich-text/index.js 13.4 kB 0 B
build/server-side-render/index.js 2.77 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/viewport/index.js 1.86 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 ) )
Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Member

Choose a reason for hiding this comment

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

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;
 		}
 	};

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Member

Choose a reason for hiding this comment

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

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 Compare December 2, 2020 08:45
@talldan talldan self-assigned this Dec 2, 2020
@talldan talldan added [Package] Components /packages/components [Type] Task Issues or PRs that have been broken down into an individual action to take labels Dec 2, 2020
@talldan talldan force-pushed the refactor/with-focus-outside-to-hook branch from 201074a to adca380 Compare December 2, 2020 10:13
Comment on lines 64 to 67
/**
* @typedef {Object} FocusOutsideReactElement
* @property {EventCallback} handleFocusOutside callback for a focus outside 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.

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

Copy link
Member

Choose a reason for hiding this comment

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

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 December 3, 2020 08:03
Comment on lines 55 to 62
/**
* @typedef {import('react').SyntheticEvent} SyntheticEvent
*/

/**
* @callback EventCallback
* @param {SyntheticEvent} event input related event.
*/
Copy link
Member

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I noticed this when trying to alias MutableRefObject to reduce the line length of some of the documentation. That doesn't have a default so it can't be aliased at all.

Comment on lines 64 to 67
/**
* @typedef {Object} FocusOutsideReactElement
* @property {EventCallback} handleFocusOutside callback for a focus outside event.
*/
Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Member

@kevin940726 kevin940726 left a comment

Choose a reason for hiding this comment

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

Haven't tested it yet, just some nitpicking and code smell.

packages/components/src/utils/hooks/use-focus-outside.js Outdated Show resolved Hide resolved
packages/components/src/utils/hooks/use-focus-outside.js Outdated Show resolved Hide resolved
packages/components/src/utils/hooks/use-focus-outside.js Outdated Show resolved Hide resolved
packages/components/src/utils/hooks/use-focus-outside.js Outdated Show resolved Hide resolved
packages/components/src/utils/hooks/use-focus-outside.js Outdated Show resolved Hide resolved
Comment on lines 204 to 209
onFocus: cancelBlurCheck,
onMouseDown: normalizeButtonFocus,
onMouseUp: normalizeButtonFocus,
onTouchStart: normalizeButtonFocus,
onTouchEnd: normalizeButtonFocus,
onBlur: queueBlurCheck,
Copy link
Member

Choose a reason for hiding this comment

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

A potential issue would be if the element receiving these props also has some of them defined, then these will override them. We can expect the developer to group the event handlers by themselves, or we can do it for them here, I'm okay with both approaches.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I don't like making the implementer handle so many callbacks, but I don't think there's a good alternative. Using DOM event handlers and a ref would be possible if the hook didn't have to handle virtual event bubbling, but it does.

What are the options for improving this? I've seen the pattern where the hook takes and returns the props from a component, which is slightly easier to use:

const propsWithFocusOutside = useFocusOutside( props, onFocusOutside );

return (
    <div { ...propsWithFocusOutside }>

Is that what you mean when you say we can do it for them?

Copy link
Member

@kevin940726 kevin940726 Dec 7, 2020

Choose a reason for hiding this comment

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

Wait, nvm. I was thinking component design 😅 .

I think this is fine and I don't think we should pass props to useFocusOutside. However, I think we can try to refactor this to a component instead.

<FocusOutside as="div">
  // ...
</FocusOutside>

And we can then handle the composition/grouping of the event handlers for them.

<FocusOutside as="div" onMouseDown={() => console.log("mousedown")}>
  // ...
</FocusOutside>

function FocusOutside(props) {
  // ...
  return {
    // ...
    onMouseDown: groupEvents(normalizeButtonFocus, props.onMouseDown)
  };
}

Maybe we can still try to keep this hook, but build the component with this hook. Either way I feel like the default export should be the component but not the hook.

Copy link
Contributor Author

@talldan talldan Dec 7, 2020

Choose a reason for hiding this comment

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

Hah 😄

Yeah, it's an interesting idea making it a component. I definitely need to think about future usage here. My original goal was to remove the wrapping div introduced in the combobox component in #27367.

But within Popovers or Modals this will likely be used with some other HOCs/components:

  • withFocusReturn (another HOC that renders a div)
  • withConstrainedTabbing (yet again another HOC that renders a div)
  • IsolatedEventContainer (a wrapper that renders a div)

That's why these components end up with so many div wrappers when they render, and it'd be nice to reduce that as much as possible with a consistent approach (refactor to hooks/components).

Does the as pattern scale well to multiple components that render the same element? I think that's where hooks might be better suited to the problem and are more explicit

Copy link
Member

Choose a reason for hiding this comment

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

Oh this is interesting, thx for the additional info!

I think in theory we can still do it with as prop:

<FocusOutside as={FocusReturn}>
  // ...
</FocusOutside>

But this can easily become tedious if we want to combine from multiple sources. Hooks with a useCompositeProps hook could solve this.

const htmlProps = useCompositeProps(
  useFocusOutside(),
  useFocusReturn(),
  useConstrainedTabbing(),
  // ...
);

<div {...htmlProps></div>

But this could also be a little bit overly complicated.

Maybe just export mergeEventHandlers to the user so that they can combine them by themselves?

Might be worth studying how reakit solves this though.

node,
node.handleFocusOutside
)
: undefined
Copy link
Member

Choose a reason for hiding this comment

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

If we want to avoid re-rendering, then we should probably return the prev state to bail out update.


export default createHigherOrderComponent(
( WrappedComponent ) => ( props ) => {
const [ handleFocusOutside, setHandleFocusOutside ] = useState();
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need this state at all, why not just pass the callback to useFocusOutside?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, long story:

Passing the callback works if it's wrapped in a closure in withFocusOutside like in your PR:

useFocusOutside( ( event ) => node.current?.handleFocusOutside( event ) );

However, I went for useState because of this method:

bindNode( node ) {
if ( node ) {
this.node = node;
} else {
delete this.node;
this.cancelBlurCheck();
}
}

That seems to say when node becomes a falsey value cancelBlurCheck should be triggered.

So the idea of using state is to emulate that, the state gets unset when the node isn't present and cancelBlurCheck is triggered in the other bit of code that you queried here - #27369 (comment)

If I go with the option of using the closure mentioned at the top of this comment, it means there's always an onFocusOutside callback provided to the hook so it won't be reactive to the node being falsey.

TBH, I find the use of cancelBlurCheck in bindNode quite hard to reason about. Is there likely to be a blur check in flight when the node is removed?

Copy link
Contributor

Choose a reason for hiding this comment

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

Did you try to remove it and see whether any test fail?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just looking now, none of the unit tests fail and I didn't see any issues in manual testing. I can push the change to see if the e2e tests pass?

packages/components/src/utils/hooks/index.js Outdated Show resolved Hide resolved
packages/components/src/utils/hooks/use-focus-outside.js Outdated Show resolved Hide resolved
packages/components/src/utils/hooks/use-focus-outside.js Outdated Show resolved Hide resolved
@youknowriad
Copy link
Contributor

I'd have preferred to find a way to implement the hook by returning a ref but it seems there's no way to attach React event handlers that way and we need that nested popovers bubbling.

@youknowriad
Copy link
Contributor

Let's make this experimental (until we try the ref approach) in @wordpress/compose and ship it.

@talldan talldan force-pushed the refactor/with-focus-outside-to-hook branch from d1d1f9c to d9f0e01 Compare December 9, 2020 07:42
@talldan
Copy link
Contributor Author

talldan commented Dec 9, 2020

Some failing e2e tests, the reusable block ones and the React Native tests. Might need to double-check I ported the native HOC ok in d9f0e01.

But I've re-run them to confirm it's a legit failure.

@talldan talldan added [Package] Compose /packages/compose [Type] Experimental Experimental feature or API. and removed [Package] Components /packages/components [Type] Task Issues or PRs that have been broken down into an individual action to take labels Dec 9, 2020
@talldan talldan force-pushed the refactor/with-focus-outside-to-hook branch from b7bbe3b to cbd8a1f Compare December 10, 2020 09:02
@talldan
Copy link
Contributor Author

talldan commented Dec 10, 2020

Merging this and will follow up with any of the discussed points.

@talldan talldan merged commit c08c295 into master Dec 10, 2020
@talldan talldan deleted the refactor/with-focus-outside-to-hook branch December 10, 2020 09:37
@github-actions github-actions bot added this to the Gutenberg 9.6 milestone Dec 10, 2020
// this blur event thereby leaving focus in place.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus.
if ( ! document.hasFocus() ) {
event.preventDefault();
Copy link
Member

Choose a reason for hiding this comment

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

I don't think you can prevent default an event after a timeout?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point! I did some digging and it looks like Andrew made a similar comment on the PR that introduced this, focus events aren't cancelable so preventDefault would have no effect - #17051 (comment)

I'll remove this line in a separate PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Compose /packages/compose [Type] Experimental Experimental feature or API.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants