Join GitHub today
GitHub is home to over 50 million developers working together to host and review code, manage projects, and build software together.
Sign upGitHub is where the world builds software
Millions of developers and companies build, ship, and maintain their software on GitHub — the largest and most advanced development platform in the world.
Refactor withFocusOutside to hook #27369
Conversation
Size Change: -128 B (0%) Total Size: 1.19 MB
|
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 |
if ( isInteractionEnd ) { | ||
preventBlurCheck.current = false; | ||
} else if ( | ||
isFocusNormalizedButton( /** @type {HTMLElement} */ ( target ) ) |
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.
Should this be currentTarget
?
The
currentTarget
read-only property of theEvent
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 toEvent.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.
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.
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.
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;
}
};
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
, andWindow
are the most common event targets, but other objects can be event targets, too. For exampleXMLHttpRequest
,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;
}
};
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)?
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)?
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.
Nice work href
on something of type HTMLLinkElement
, but not HTMLLinkElement | HTMLButtonElement
because href
doesn't exist on HTMLButtonElement
.
…e to unbound ref at render time
26dfffe
to
c6717b4
201074a
to
adca380
/** | ||
* @typedef {Object} FocusOutsideReactElement | ||
* @property {EventCallback} handleFocusOutside callback for a focus outside event. | ||
*/ |
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
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
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.
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.
/** | ||
* @typedef {import('react').SyntheticEvent} SyntheticEvent | ||
*/ | ||
|
||
/** | ||
* @callback EventCallback | ||
* @param {SyntheticEvent} event input related event. | ||
*/ |
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)
}
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. | ||
*/ |
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.
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.
Description
Refactors
withFocusOutside
to a react hookuseFocusOutside
. The hook is now used withinwithFocusOutside
to provide back compat.The hook has the following API:
How has this been tested?
Existing tests for
withFocusOutside
still pass.Add matching tests for
useFocusOutside
.Manual testing.
Types of changes
Non-breaking refactor.
Checklist: