From f1ec576e600f9000bd32e4fef711de700064fc4b Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Thu, 27 Jun 2024 20:50:29 +0200 Subject: [PATCH 1/6] Split animation logic into multiple separate composable utilities. --- packages/components/src/tabs/tablist.tsx | 225 ++++++++++++++++++----- 1 file changed, 183 insertions(+), 42 deletions(-) diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index 917bcbe755ee01..cee3630e5a1ac6 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -10,8 +10,9 @@ import * as Ariakit from '@ariakit/react'; import warning from '@wordpress/warning'; import { forwardRef, + useCallback, useEffect, - useLayoutEffect, + useInsertionEffect, useRef, useState, } from '@wordpress/element'; @@ -25,49 +26,107 @@ import { TabListWrapper } from './styles'; import type { WordPressComponentProps } from '../context'; import clsx from 'clsx'; -function useTrackElementOffset( - targetElement?: HTMLElement | null, - onUpdate?: () => void -) { - const [ indicatorPosition, setIndicatorPosition ] = useState( { - left: 0, - top: 0, - width: 0, - height: 0, - } ); +// TODO: move these into a separate utility file, for use in other components +// such as ToggleGroupControl. - // TODO: replace with useEventCallback or similar when officially available. - const updateCallbackRef = useRef( onUpdate ); - useLayoutEffect( () => { - updateCallbackRef.current = onUpdate; +/** + * Any function. + */ +type AnyFunction = ( ...args: any ) => any; + +/** + * Creates a stable callback function that has access to the latest state and + * can be used within event handlers and effect callbacks. Throws when used in + * the render phase. + * @example + * function Component(props) { + * const onClick = useEvent(props.onClick); + * React.useEffect(() => {}, [onClick]); + * } + */ +function useEvent< T extends AnyFunction >( callback?: T ) { + const ref = useRef< AnyFunction | undefined >( () => { + throw new Error( 'Cannot call an event handler while rendering.' ); } ); + useInsertionEffect( () => { + ref.current = callback; + } ); + return useCallback< AnyFunction >( + ( ...args ) => ref.current?.( ...args ), + [] + ) as T; +} + +/** + * `useResizeObserver` options. + */ +type UseResizeObserverOptions = { + /** + * Whether to trigger the callback when an element's ResizeObserver is + * first set up. + * + * @default true + */ + fireOnObserve?: boolean; +}; - const observedElementRef = useRef< HTMLElement >(); +/** + * Fires `onResize` when the target element is resized. + * + * **The element must not be stored in a ref**, else it won't be observed + * or updated. Instead, it should be stored in a React state or equivalent. + * + * It sets up a `ResizeObserver` that tracks the element under the hood. The + * target element can be changed dynamically, and the observer will be + * updated accordingly. + * + * By default, `onResize` is called when the observer is set up, in addition + * to when the element is resized. This behavior can be disabled with the + * `fireOnObserve` option. + * + * @example + * + * ```tsx + * const [ targetElement, setTargetElement ] = useState< HTMLElement | null >(); + * + * useResizeObserver( targetElement, ( element ) => { + * console.log( 'Element resized:', element ); + * } ); + * + *
; + * ``` + */ +function useResizeObserver( + /** + * The target element to observe. It can be changed dynamically. + */ + targetElement: HTMLElement | undefined | null, + + /** + * Callback to fire when the element is resized. It will also be + * called when the observer is set up, unless `fireOnObserve` is + * set to `false`. + */ + onResize: ( element: HTMLElement ) => void, + { fireOnObserve = true }: UseResizeObserverOptions = {} +) { + const onResizeEvent = useEvent( onResize ); + + const observedElementRef = useRef< HTMLElement | null >(); const resizeObserverRef = useRef< ResizeObserver >(); + useEffect( () => { if ( targetElement === observedElementRef.current ) { return; } - observedElementRef.current = targetElement ?? undefined; - - function updateIndicator( element: HTMLElement ) { - setIndicatorPosition( { - // Workaround to prevent unwanted scrollbars, see: - // https://github.com/WordPress/gutenberg/pull/61979 - left: Math.max( element.offsetLeft - 1, 0 ), - top: Math.max( element.offsetTop - 1, 0 ), - width: parseFloat( getComputedStyle( element ).width ), - height: parseFloat( getComputedStyle( element ).height ), - } ); - updateCallbackRef.current?.(); - } + observedElementRef.current = targetElement; // Set up a ResizeObserver. if ( ! resizeObserverRef.current ) { resizeObserverRef.current = new ResizeObserver( () => { if ( observedElementRef.current ) { - updateIndicator( observedElementRef.current ); + onResizeEvent( observedElementRef.current ); } } ); } @@ -75,7 +134,9 @@ function useTrackElementOffset( // Observe new element. if ( targetElement ) { - updateIndicator( targetElement ); + if ( fireOnObserve ) { + onResizeEvent( targetElement ); + } resizeObserver.observe( targetElement ); } @@ -85,35 +146,115 @@ function useTrackElementOffset( resizeObserver.unobserve( observedElementRef.current ); } }; - }, [ targetElement ] ); + }, [ fireOnObserve, onResizeEvent, targetElement ] ); +} + +/** + * The position and dimensions of an element, relative to its offset parent. + */ +type ElementOffsetRect = { + /** + * The distance from the left edge of the offset parent to the left edge of + * the element. + */ + left: number; + /** + * The distance from the top edge of the offset parent to the top edge of + * the element. + */ + top: number; + /** + * The width of the element. + */ + width: number; + /** + * The height of the element. + */ + height: number; +}; + +/** + * An `ElementOffsetRect` object with all values set to zero. + */ +const NULL_ELEMENT_OFFSET_RECT = { + left: 0, + top: 0, + width: 0, + height: 0, +} satisfies ElementOffsetRect; + +/** + * Returns the position and dimensions of an element, relative to its offset + * parent. This is useful in contexts where `getBoundingClientRect` is not + * suitable, such as when the element is transformed. + * + * **Note:** the `left` and `right` values are adjusted due to a limitation + * in the way the browser calculates the offset position of the element, + * which can cause unwanted scrollbars to appear. This adjustment makes the + * values potentially inaccurate within a range of 1 pixel. + */ +function getElementOffsetRect( element: HTMLElement ): ElementOffsetRect { + return { + // The adjustments mentioned in the documentation above are necessary + // because `offsetLeft` and `offsetTop` are rounded to the nearest pixel, + // which can result in a position mismatch that causes unwanted overflow. + // For context, see: https://github.com/WordPress/gutenberg/pull/61979 + left: Math.max( element.offsetLeft - 1, 0 ), + top: Math.max( element.offsetTop - 1, 0 ), + // This is a workaround to obtain these values with a sub-pixel precision, + // since `offsetWidth` and `offsetHeight` are rounded to the nearest pixel. + width: parseFloat( getComputedStyle( element ).width ), + height: parseFloat( getComputedStyle( element ).height ), + }; +} + +/** + * Tracks the position and dimensions of an element, relative to its offset + * parent. The element can be changed dynamically. + */ +function useTrackElementOffsetRect( + targetElement: HTMLElement | undefined | null +) { + const [ indicatorPosition, setIndicatorPosition ] = + useState< ElementOffsetRect >( NULL_ELEMENT_OFFSET_RECT ); + + useResizeObserver( targetElement, ( element ) => + setIndicatorPosition( getElementOffsetRect( element ) ) + ); return indicatorPosition; } +/** + * Context object for the `onUpdate` callback of `useOnValueUpdate`. + */ type ValueUpdateContext< T > = { previousValue: T; }; +/** + * Calls the `onUpdate` callback when the `value` changes. + */ function useOnValueUpdate< T >( + /** + * The value to watch for changes. + */ value: T, + /** + * Callback to fire when the value changes. + */ onUpdate: ( context: ValueUpdateContext< T > ) => void ) { const previousValueRef = useRef( value ); - - // TODO: replace with useEventCallback or similar when officially available. - const updateCallbackRef = useRef( onUpdate ); - useLayoutEffect( () => { - updateCallbackRef.current = onUpdate; - } ); - + const updateCallbackEvent = useEvent( onUpdate ); useEffect( () => { if ( previousValueRef.current !== value ) { - updateCallbackRef.current( { + updateCallbackEvent( { previousValue: previousValueRef.current, } ); previousValueRef.current = value; } - }, [ value ] ); + }, [ updateCallbackEvent, value ] ); } export const TabList = forwardRef< @@ -123,7 +264,7 @@ export const TabList = forwardRef< const context = useTabsContext(); const selectedId = context?.store.useState( 'selectedId' ); - const indicatorPosition = useTrackElementOffset( + const indicatorPosition = useTrackElementOffsetRect( context?.store.item( selectedId )?.element ); From 7ec0d1c3a3590857de02cd556fcb4d7387ea4ad9 Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Thu, 27 Jun 2024 20:57:50 +0200 Subject: [PATCH 2/6] JSDoc tweak. --- packages/components/src/tabs/tablist.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index cee3630e5a1ac6..ae2a6194d005ed 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -38,11 +38,15 @@ type AnyFunction = ( ...args: any ) => any; * Creates a stable callback function that has access to the latest state and * can be used within event handlers and effect callbacks. Throws when used in * the render phase. + * * @example + * + * ```tsx * function Component(props) { * const onClick = useEvent(props.onClick); * React.useEffect(() => {}, [onClick]); * } + * ``` */ function useEvent< T extends AnyFunction >( callback?: T ) { const ref = useRef< AnyFunction | undefined >( () => { From 9c9ece2550ef65fe54f2f78798a0c357e9d0a44a Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Thu, 27 Jun 2024 22:09:29 +0200 Subject: [PATCH 3/6] Tabs: move animation-related utilities into separate utils file. --- .eslintrc.js | 2 +- packages/components/src/tabs/tablist.tsx | 245 +---------------------- packages/components/src/utils/react.ts | 244 ++++++++++++++++++++++ 3 files changed, 247 insertions(+), 244 deletions(-) create mode 100644 packages/components/src/utils/react.ts diff --git a/.eslintrc.js b/.eslintrc.js index 177f3cf35b8ccf..8cb4a6aa3d54a8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -379,7 +379,7 @@ module.exports = { { files: [ '**/@(storybook|stories)/*', - 'packages/components/src/**/*.tsx', + 'packages/components/src/**/*.{ts,tsx}', ], rules: { // Useful to add story descriptions via JSDoc without specifying params, diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index ae2a6194d005ed..c1b6ddaa3dbad0 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -8,14 +8,7 @@ import * as Ariakit from '@ariakit/react'; * WordPress dependencies */ import warning from '@wordpress/warning'; -import { - forwardRef, - useCallback, - useEffect, - useInsertionEffect, - useRef, - useState, -} from '@wordpress/element'; +import { forwardRef, useState } from '@wordpress/element'; /** * Internal dependencies @@ -25,241 +18,7 @@ import { useTabsContext } from './context'; import { TabListWrapper } from './styles'; import type { WordPressComponentProps } from '../context'; import clsx from 'clsx'; - -// TODO: move these into a separate utility file, for use in other components -// such as ToggleGroupControl. - -/** - * Any function. - */ -type AnyFunction = ( ...args: any ) => any; - -/** - * Creates a stable callback function that has access to the latest state and - * can be used within event handlers and effect callbacks. Throws when used in - * the render phase. - * - * @example - * - * ```tsx - * function Component(props) { - * const onClick = useEvent(props.onClick); - * React.useEffect(() => {}, [onClick]); - * } - * ``` - */ -function useEvent< T extends AnyFunction >( callback?: T ) { - const ref = useRef< AnyFunction | undefined >( () => { - throw new Error( 'Cannot call an event handler while rendering.' ); - } ); - useInsertionEffect( () => { - ref.current = callback; - } ); - return useCallback< AnyFunction >( - ( ...args ) => ref.current?.( ...args ), - [] - ) as T; -} - -/** - * `useResizeObserver` options. - */ -type UseResizeObserverOptions = { - /** - * Whether to trigger the callback when an element's ResizeObserver is - * first set up. - * - * @default true - */ - fireOnObserve?: boolean; -}; - -/** - * Fires `onResize` when the target element is resized. - * - * **The element must not be stored in a ref**, else it won't be observed - * or updated. Instead, it should be stored in a React state or equivalent. - * - * It sets up a `ResizeObserver` that tracks the element under the hood. The - * target element can be changed dynamically, and the observer will be - * updated accordingly. - * - * By default, `onResize` is called when the observer is set up, in addition - * to when the element is resized. This behavior can be disabled with the - * `fireOnObserve` option. - * - * @example - * - * ```tsx - * const [ targetElement, setTargetElement ] = useState< HTMLElement | null >(); - * - * useResizeObserver( targetElement, ( element ) => { - * console.log( 'Element resized:', element ); - * } ); - * - *
; - * ``` - */ -function useResizeObserver( - /** - * The target element to observe. It can be changed dynamically. - */ - targetElement: HTMLElement | undefined | null, - - /** - * Callback to fire when the element is resized. It will also be - * called when the observer is set up, unless `fireOnObserve` is - * set to `false`. - */ - onResize: ( element: HTMLElement ) => void, - { fireOnObserve = true }: UseResizeObserverOptions = {} -) { - const onResizeEvent = useEvent( onResize ); - - const observedElementRef = useRef< HTMLElement | null >(); - const resizeObserverRef = useRef< ResizeObserver >(); - - useEffect( () => { - if ( targetElement === observedElementRef.current ) { - return; - } - - observedElementRef.current = targetElement; - - // Set up a ResizeObserver. - if ( ! resizeObserverRef.current ) { - resizeObserverRef.current = new ResizeObserver( () => { - if ( observedElementRef.current ) { - onResizeEvent( observedElementRef.current ); - } - } ); - } - const { current: resizeObserver } = resizeObserverRef; - - // Observe new element. - if ( targetElement ) { - if ( fireOnObserve ) { - onResizeEvent( targetElement ); - } - resizeObserver.observe( targetElement ); - } - - return () => { - // Unobserve previous element. - if ( observedElementRef.current ) { - resizeObserver.unobserve( observedElementRef.current ); - } - }; - }, [ fireOnObserve, onResizeEvent, targetElement ] ); -} - -/** - * The position and dimensions of an element, relative to its offset parent. - */ -type ElementOffsetRect = { - /** - * The distance from the left edge of the offset parent to the left edge of - * the element. - */ - left: number; - /** - * The distance from the top edge of the offset parent to the top edge of - * the element. - */ - top: number; - /** - * The width of the element. - */ - width: number; - /** - * The height of the element. - */ - height: number; -}; - -/** - * An `ElementOffsetRect` object with all values set to zero. - */ -const NULL_ELEMENT_OFFSET_RECT = { - left: 0, - top: 0, - width: 0, - height: 0, -} satisfies ElementOffsetRect; - -/** - * Returns the position and dimensions of an element, relative to its offset - * parent. This is useful in contexts where `getBoundingClientRect` is not - * suitable, such as when the element is transformed. - * - * **Note:** the `left` and `right` values are adjusted due to a limitation - * in the way the browser calculates the offset position of the element, - * which can cause unwanted scrollbars to appear. This adjustment makes the - * values potentially inaccurate within a range of 1 pixel. - */ -function getElementOffsetRect( element: HTMLElement ): ElementOffsetRect { - return { - // The adjustments mentioned in the documentation above are necessary - // because `offsetLeft` and `offsetTop` are rounded to the nearest pixel, - // which can result in a position mismatch that causes unwanted overflow. - // For context, see: https://github.com/WordPress/gutenberg/pull/61979 - left: Math.max( element.offsetLeft - 1, 0 ), - top: Math.max( element.offsetTop - 1, 0 ), - // This is a workaround to obtain these values with a sub-pixel precision, - // since `offsetWidth` and `offsetHeight` are rounded to the nearest pixel. - width: parseFloat( getComputedStyle( element ).width ), - height: parseFloat( getComputedStyle( element ).height ), - }; -} - -/** - * Tracks the position and dimensions of an element, relative to its offset - * parent. The element can be changed dynamically. - */ -function useTrackElementOffsetRect( - targetElement: HTMLElement | undefined | null -) { - const [ indicatorPosition, setIndicatorPosition ] = - useState< ElementOffsetRect >( NULL_ELEMENT_OFFSET_RECT ); - - useResizeObserver( targetElement, ( element ) => - setIndicatorPosition( getElementOffsetRect( element ) ) - ); - - return indicatorPosition; -} - -/** - * Context object for the `onUpdate` callback of `useOnValueUpdate`. - */ -type ValueUpdateContext< T > = { - previousValue: T; -}; - -/** - * Calls the `onUpdate` callback when the `value` changes. - */ -function useOnValueUpdate< T >( - /** - * The value to watch for changes. - */ - value: T, - /** - * Callback to fire when the value changes. - */ - onUpdate: ( context: ValueUpdateContext< T > ) => void -) { - const previousValueRef = useRef( value ); - const updateCallbackEvent = useEvent( onUpdate ); - useEffect( () => { - if ( previousValueRef.current !== value ) { - updateCallbackEvent( { - previousValue: previousValueRef.current, - } ); - previousValueRef.current = value; - } - }, [ updateCallbackEvent, value ] ); -} +import { useOnValueUpdate, useTrackElementOffsetRect } from '../utils/react'; export const TabList = forwardRef< HTMLDivElement, diff --git a/packages/components/src/utils/react.ts b/packages/components/src/utils/react.ts new file mode 100644 index 00000000000000..c4d924b76bde3a --- /dev/null +++ b/packages/components/src/utils/react.ts @@ -0,0 +1,244 @@ +/** + * WordPress dependencies + */ +import { + useRef, + useInsertionEffect, + useCallback, + useEffect, + useState, +} from '@wordpress/element'; + +/** + * Any function. + */ +export type AnyFunction = ( ...args: any ) => any; + +/** + * Creates a stable callback function that has access to the latest state and + * can be used within event handlers and effect callbacks. Throws when used in + * the render phase. + * + * @example + * + * ```tsx + * function Component(props) { + * const onClick = useEvent(props.onClick); + * React.useEffect(() => {}, [onClick]); + * } + * ``` + */ +export function useEvent< T extends AnyFunction >( callback?: T ) { + const ref = useRef< AnyFunction | undefined >( () => { + throw new Error( 'Cannot call an event handler while rendering.' ); + } ); + useInsertionEffect( () => { + ref.current = callback; + } ); + return useCallback< AnyFunction >( + ( ...args ) => ref.current?.( ...args ), + [] + ) as T; +} + +/** + * `useResizeObserver` options. + */ +export type UseResizeObserverOptions = { + /** + * Whether to trigger the callback when an element's ResizeObserver is + * first set up. + * + * @default true + */ + fireOnObserve?: boolean; +}; + +/** + * Fires `onResize` when the target element is resized. + * + * **The element must not be stored in a ref**, else it won't be observed + * or updated. Instead, it should be stored in a React state or equivalent. + * + * It sets up a `ResizeObserver` that tracks the element under the hood. The + * target element can be changed dynamically, and the observer will be + * updated accordingly. + * + * By default, `onResize` is called when the observer is set up, in addition + * to when the element is resized. This behavior can be disabled with the + * `fireOnObserve` option. + * + * @example + * + * ```tsx + * const [ targetElement, setTargetElement ] = useState< HTMLElement | null >(); + * + * useResizeObserver( targetElement, ( element ) => { + * console.log( 'Element resized:', element ); + * } ); + * + *
; + * ``` + */ +export function useResizeObserver( + /** + * The target element to observe. It can be changed dynamically. + */ + targetElement: HTMLElement | undefined | null, + + /** + * Callback to fire when the element is resized. It will also be + * called when the observer is set up, unless `fireOnObserve` is + * set to `false`. + */ + onResize: ( element: HTMLElement ) => void, + { fireOnObserve = true }: UseResizeObserverOptions = {} +) { + const onResizeEvent = useEvent( onResize ); + + const observedElementRef = useRef< HTMLElement | null >(); + const resizeObserverRef = useRef< ResizeObserver >(); + + useEffect( () => { + if ( targetElement === observedElementRef.current ) { + return; + } + + observedElementRef.current = targetElement; + + // Set up a ResizeObserver. + if ( ! resizeObserverRef.current ) { + resizeObserverRef.current = new ResizeObserver( () => { + if ( observedElementRef.current ) { + onResizeEvent( observedElementRef.current ); + } + } ); + } + const { current: resizeObserver } = resizeObserverRef; + + // Observe new element. + if ( targetElement ) { + if ( fireOnObserve ) { + onResizeEvent( targetElement ); + } + resizeObserver.observe( targetElement ); + } + + return () => { + // Unobserve previous element. + if ( observedElementRef.current ) { + resizeObserver.unobserve( observedElementRef.current ); + } + }; + }, [ fireOnObserve, onResizeEvent, targetElement ] ); +} + +/** + * The position and dimensions of an element, relative to its offset parent. + */ +export type ElementOffsetRect = { + /** + * The distance from the left edge of the offset parent to the left edge of + * the element. + */ + left: number; + /** + * The distance from the top edge of the offset parent to the top edge of + * the element. + */ + top: number; + /** + * The width of the element. + */ + width: number; + /** + * The height of the element. + */ + height: number; +}; + +/** + * An `ElementOffsetRect` object with all values set to zero. + */ +export const NULL_ELEMENT_OFFSET_RECT = { + left: 0, + top: 0, + width: 0, + height: 0, +} satisfies ElementOffsetRect; + +/** + * Returns the position and dimensions of an element, relative to its offset + * parent. This is useful in contexts where `getBoundingClientRect` is not + * suitable, such as when the element is transformed. + * + * **Note:** the `left` and `right` values are adjusted due to a limitation + * in the way the browser calculates the offset position of the element, + * which can cause unwanted scrollbars to appear. This adjustment makes the + * values potentially inaccurate within a range of 1 pixel. + */ +export function getElementOffsetRect( + element: HTMLElement +): ElementOffsetRect { + return { + // The adjustments mentioned in the documentation above are necessary + // because `offsetLeft` and `offsetTop` are rounded to the nearest pixel, + // which can result in a position mismatch that causes unwanted overflow. + // For context, see: https://github.com/WordPress/gutenberg/pull/61979 + left: Math.max( element.offsetLeft - 1, 0 ), + top: Math.max( element.offsetTop - 1, 0 ), + // This is a workaround to obtain these values with a sub-pixel precision, + // since `offsetWidth` and `offsetHeight` are rounded to the nearest pixel. + width: parseFloat( getComputedStyle( element ).width ), + height: parseFloat( getComputedStyle( element ).height ), + }; +} + +/** + * Tracks the position and dimensions of an element, relative to its offset + * parent. The element can be changed dynamically. + */ +export function useTrackElementOffsetRect( + targetElement: HTMLElement | undefined | null +) { + const [ indicatorPosition, setIndicatorPosition ] = + useState< ElementOffsetRect >( NULL_ELEMENT_OFFSET_RECT ); + + useResizeObserver( targetElement, ( element ) => + setIndicatorPosition( getElementOffsetRect( element ) ) + ); + + return indicatorPosition; +} + +/** + * Context object for the `onUpdate` callback of `useOnValueUpdate`. + */ +export type ValueUpdateContext< T > = { + previousValue: T; +}; + +/** + * Calls the `onUpdate` callback when the `value` changes. + */ +export function useOnValueUpdate< T >( + /** + * The value to watch for changes. + */ + value: T, + /** + * Callback to fire when the value changes. + */ + onUpdate: ( context: ValueUpdateContext< T > ) => void +) { + const previousValueRef = useRef( value ); + const updateCallbackEvent = useEvent( onUpdate ); + useEffect( () => { + if ( previousValueRef.current !== value ) { + updateCallbackEvent( { + previousValue: previousValueRef.current, + } ); + previousValueRef.current = value; + } + }, [ updateCallbackEvent, value ] ); +} From d40d3abdf5a59534b8cc3fe7c5646c1c1310b64a Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Thu, 11 Jul 2024 00:57:43 +0200 Subject: [PATCH 4/6] Rename hook and update some docs. --- packages/components/src/utils/react.ts | 66 ++++++++++++++++---------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/components/src/utils/react.ts b/packages/components/src/utils/react.ts index c4d924b76bde3a..c584af2541e4fe 100644 --- a/packages/components/src/utils/react.ts +++ b/packages/components/src/utils/react.ts @@ -42,63 +42,77 @@ export function useEvent< T extends AnyFunction >( callback?: T ) { } /** - * `useResizeObserver` options. + * `useTrackElementRectUpdates` options. */ -export type UseResizeObserverOptions = { +export type UseTrackElementRectUpdatesOptions = { /** * Whether to trigger the callback when an element's ResizeObserver is - * first set up. + * first set up, including when the target element changes. * * @default true */ - fireOnObserve?: boolean; + fireOnElementInit?: boolean; }; /** - * Fires `onResize` when the target element is resized. + * Tracks an element's "rect" (size and position) and fires `onRect` for all + * of its discrete values. The element can be changed dynamically and **it + * must not be stored in a ref**. Instead, it should be stored in a React + * state or equivalent. * - * **The element must not be stored in a ref**, else it won't be observed - * or updated. Instead, it should be stored in a React state or equivalent. + * By default, `onRect` is called initially for the target element (including + * when the target element changes), not only on size or position updates. + * This allows consumers of the hook to always be in sync with all rect values + * of the target element throughout its lifetime. This behavior can be + * disabled by setting the `fireOnElementInit` option to `false`. * - * It sets up a `ResizeObserver` that tracks the element under the hood. The + * Under the hood, it sets up a `ResizeObserver` that tracks the element. The * target element can be changed dynamically, and the observer will be * updated accordingly. * - * By default, `onResize` is called when the observer is set up, in addition - * to when the element is resized. This behavior can be disabled with the - * `fireOnObserve` option. - * * @example * * ```tsx * const [ targetElement, setTargetElement ] = useState< HTMLElement | null >(); * - * useResizeObserver( targetElement, ( element ) => { + * useTrackElementRectUpdates( targetElement, ( element ) => { * console.log( 'Element resized:', element ); * } ); * *
; * ``` */ -export function useResizeObserver( +export function useTrackElementRectUpdates( /** * The target element to observe. It can be changed dynamically. */ targetElement: HTMLElement | undefined | null, - /** * Callback to fire when the element is resized. It will also be - * called when the observer is set up, unless `fireOnObserve` is + * called when the observer is set up, unless `fireOnElementInit` is * set to `false`. */ - onResize: ( element: HTMLElement ) => void, - { fireOnObserve = true }: UseResizeObserverOptions = {} + onRect: ( + /** + * The element being tracked at the time of this update. + */ + element: HTMLElement, + /** + * The list of + * [`ResizeObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) + * objects passed to the `ResizeObserver.observe` callback. This list + * won't be available when the observer is set up, and only on updates. + */ + resizeObserverEntries?: ResizeObserverEntry[] + ) => void, + { fireOnElementInit = true }: UseTrackElementRectUpdatesOptions = {} ) { - const onResizeEvent = useEvent( onResize ); + const onRectEvent = useEvent( onRect ); const observedElementRef = useRef< HTMLElement | null >(); const resizeObserverRef = useRef< ResizeObserver >(); + // TODO: could this be a layout effect? useEffect( () => { if ( targetElement === observedElementRef.current ) { return; @@ -108,9 +122,9 @@ export function useResizeObserver( // Set up a ResizeObserver. if ( ! resizeObserverRef.current ) { - resizeObserverRef.current = new ResizeObserver( () => { + resizeObserverRef.current = new ResizeObserver( ( entries ) => { if ( observedElementRef.current ) { - onResizeEvent( observedElementRef.current ); + onRectEvent( observedElementRef.current, entries ); } } ); } @@ -118,8 +132,10 @@ export function useResizeObserver( // Observe new element. if ( targetElement ) { - if ( fireOnObserve ) { - onResizeEvent( targetElement ); + if ( fireOnElementInit ) { + // TODO: investigate if this can be removed, + // see: https://stackoverflow.com/a/60026394 + onRectEvent( targetElement ); } resizeObserver.observe( targetElement ); } @@ -130,7 +146,7 @@ export function useResizeObserver( resizeObserver.unobserve( observedElementRef.current ); } }; - }, [ fireOnObserve, onResizeEvent, targetElement ] ); + }, [ fireOnElementInit, onRectEvent, targetElement ] ); } /** @@ -204,7 +220,7 @@ export function useTrackElementOffsetRect( const [ indicatorPosition, setIndicatorPosition ] = useState< ElementOffsetRect >( NULL_ELEMENT_OFFSET_RECT ); - useResizeObserver( targetElement, ( element ) => + useTrackElementRectUpdates( targetElement, ( element ) => setIndicatorPosition( getElementOffsetRect( element ) ) ); From 443608a4ff20e1728cdc3f4288646520356a2cc7 Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Thu, 11 Jul 2024 01:02:47 +0200 Subject: [PATCH 5/6] Revert eslint rule and disable for the file. --- .eslintrc.js | 2 +- packages/components/src/utils/react.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index c7464655ebad52..7925bceafd3d57 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -379,7 +379,7 @@ module.exports = { { files: [ '**/@(storybook|stories)/*', - 'packages/components/src/**/*.{ts,tsx}', + 'packages/components/src/**/*.tsx', ], rules: { // Useful to add story descriptions via JSDoc without specifying params, diff --git a/packages/components/src/utils/react.ts b/packages/components/src/utils/react.ts index c584af2541e4fe..64e0ca8c1678fc 100644 --- a/packages/components/src/utils/react.ts +++ b/packages/components/src/utils/react.ts @@ -1,3 +1,4 @@ +/* eslint-disable jsdoc/require-param */ /** * WordPress dependencies */ @@ -258,3 +259,4 @@ export function useOnValueUpdate< T >( } }, [ updateCallbackEvent, value ] ); } +/* eslint-enable jsdoc/require-param */ From c1d94fcb4a02b651909aae04a85e4412efbe0845 Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Thu, 11 Jul 2024 01:10:24 +0200 Subject: [PATCH 6/6] Re-organize utils to fit current structure better. --- packages/components/src/tabs/tablist.tsx | 3 +- .../src/utils/{react.ts => element-rect.ts} | 71 +------------------ .../components/src/utils/hooks/use-event.ts | 38 ++++++++++ .../src/utils/hooks/use-on-value-update.ts | 42 +++++++++++ 4 files changed, 85 insertions(+), 69 deletions(-) rename packages/components/src/utils/{react.ts => element-rect.ts} (78%) create mode 100644 packages/components/src/utils/hooks/use-event.ts create mode 100644 packages/components/src/utils/hooks/use-on-value-update.ts diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index b3852f1c8dc87f..4906e0088417b7 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -17,7 +17,8 @@ import { useTabsContext } from './context'; import { TabListWrapper } from './styles'; import type { WordPressComponentProps } from '../context'; import clsx from 'clsx'; -import { useOnValueUpdate, useTrackElementOffsetRect } from '../utils/react'; +import { useTrackElementOffsetRect } from '../utils/element-rect'; +import { useOnValueUpdate } from '../utils/hooks/use-on-value-update'; export const TabList = forwardRef< HTMLDivElement, diff --git a/packages/components/src/utils/react.ts b/packages/components/src/utils/element-rect.ts similarity index 78% rename from packages/components/src/utils/react.ts rename to packages/components/src/utils/element-rect.ts index 64e0ca8c1678fc..9f6eb120b32fc5 100644 --- a/packages/components/src/utils/react.ts +++ b/packages/components/src/utils/element-rect.ts @@ -2,45 +2,11 @@ /** * WordPress dependencies */ -import { - useRef, - useInsertionEffect, - useCallback, - useEffect, - useState, -} from '@wordpress/element'; - +import { useRef, useEffect, useState } from '@wordpress/element'; /** - * Any function. + * Internal dependencies */ -export type AnyFunction = ( ...args: any ) => any; - -/** - * Creates a stable callback function that has access to the latest state and - * can be used within event handlers and effect callbacks. Throws when used in - * the render phase. - * - * @example - * - * ```tsx - * function Component(props) { - * const onClick = useEvent(props.onClick); - * React.useEffect(() => {}, [onClick]); - * } - * ``` - */ -export function useEvent< T extends AnyFunction >( callback?: T ) { - const ref = useRef< AnyFunction | undefined >( () => { - throw new Error( 'Cannot call an event handler while rendering.' ); - } ); - useInsertionEffect( () => { - ref.current = callback; - } ); - return useCallback< AnyFunction >( - ( ...args ) => ref.current?.( ...args ), - [] - ) as T; -} +import { useEvent } from './hooks/use-event'; /** * `useTrackElementRectUpdates` options. @@ -228,35 +194,4 @@ export function useTrackElementOffsetRect( return indicatorPosition; } -/** - * Context object for the `onUpdate` callback of `useOnValueUpdate`. - */ -export type ValueUpdateContext< T > = { - previousValue: T; -}; - -/** - * Calls the `onUpdate` callback when the `value` changes. - */ -export function useOnValueUpdate< T >( - /** - * The value to watch for changes. - */ - value: T, - /** - * Callback to fire when the value changes. - */ - onUpdate: ( context: ValueUpdateContext< T > ) => void -) { - const previousValueRef = useRef( value ); - const updateCallbackEvent = useEvent( onUpdate ); - useEffect( () => { - if ( previousValueRef.current !== value ) { - updateCallbackEvent( { - previousValue: previousValueRef.current, - } ); - previousValueRef.current = value; - } - }, [ updateCallbackEvent, value ] ); -} /* eslint-enable jsdoc/require-param */ diff --git a/packages/components/src/utils/hooks/use-event.ts b/packages/components/src/utils/hooks/use-event.ts new file mode 100644 index 00000000000000..eefac9478a8b4f --- /dev/null +++ b/packages/components/src/utils/hooks/use-event.ts @@ -0,0 +1,38 @@ +/* eslint-disable jsdoc/require-param */ +/** + * WordPress dependencies + */ +import { useRef, useInsertionEffect, useCallback } from '@wordpress/element'; + +/** + * Any function. + */ +export type AnyFunction = ( ...args: any ) => any; + +/** + * Creates a stable callback function that has access to the latest state and + * can be used within event handlers and effect callbacks. Throws when used in + * the render phase. + * + * @example + * + * ```tsx + * function Component(props) { + * const onClick = useEvent(props.onClick); + * React.useEffect(() => {}, [onClick]); + * } + * ``` + */ +export function useEvent< T extends AnyFunction >( callback?: T ) { + const ref = useRef< AnyFunction | undefined >( () => { + throw new Error( 'Cannot call an event handler while rendering.' ); + } ); + useInsertionEffect( () => { + ref.current = callback; + } ); + return useCallback< AnyFunction >( + ( ...args ) => ref.current?.( ...args ), + [] + ) as T; +} +/* eslint-enable jsdoc/require-param */ diff --git a/packages/components/src/utils/hooks/use-on-value-update.ts b/packages/components/src/utils/hooks/use-on-value-update.ts new file mode 100644 index 00000000000000..5726f3977daf04 --- /dev/null +++ b/packages/components/src/utils/hooks/use-on-value-update.ts @@ -0,0 +1,42 @@ +/* eslint-disable jsdoc/require-param */ +/** + * WordPress dependencies + */ +import { useRef, useEffect } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { useEvent } from './use-event'; + +/** + * Context object for the `onUpdate` callback of `useOnValueUpdate`. + */ +export type ValueUpdateContext< T > = { + previousValue: T; +}; + +/** + * Calls the `onUpdate` callback when the `value` changes. + */ +export function useOnValueUpdate< T >( + /** + * The value to watch for changes. + */ + value: T, + /** + * Callback to fire when the value changes. + */ + onUpdate: ( context: ValueUpdateContext< T > ) => void +) { + const previousValueRef = useRef( value ); + const updateCallbackEvent = useEvent( onUpdate ); + useEffect( () => { + if ( previousValueRef.current !== value ) { + updateCallbackEvent( { + previousValue: previousValueRef.current, + } ); + previousValueRef.current = value; + } + }, [ updateCallbackEvent, value ] ); +} +/* eslint-enable jsdoc/require-param */