Skip to content

Commit

Permalink
Tabs: split animation logic into multiple separate composable utiliti…
Browse files Browse the repository at this point in the history
…es. (WordPress#62942)

* Split animation logic into multiple separate composable utilities.

* JSDoc tweak.

Co-authored-by: DaniGuardiola <[email protected]>
Co-authored-by: ciampo <[email protected]>
  • Loading branch information
3 people authored Jun 28, 2024
1 parent 9a331f1 commit 0ea6751
Showing 1 changed file with 187 additions and 42 deletions.
229 changes: 187 additions & 42 deletions packages/components/src/tabs/tablist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,57 +26,121 @@ 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
*
* ```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;
};

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 );
* } );
*
* <div ref={ setTargetElement } />;
* ```
*/
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 );
}
} );
}
const { current: resizeObserver } = resizeObserverRef;

// Observe new element.
if ( targetElement ) {
updateIndicator( targetElement );
if ( fireOnObserve ) {
onResizeEvent( targetElement );
}
resizeObserver.observe( targetElement );
}

Expand All @@ -85,35 +150,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<
Expand All @@ -123,7 +268,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
);

Expand Down

0 comments on commit 0ea6751

Please sign in to comment.