From f1ec576e600f9000bd32e4fef711de700064fc4b Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Thu, 27 Jun 2024 20:50:29 +0200 Subject: [PATCH 1/2] 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 917bcbe755ee0..cee3630e5a1ac 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/2] 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 cee3630e5a1ac..ae2a6194d005e 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 >( () => {