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

Tabs: move animation-related utilities into separate utils file. #62946

Merged
merged 7 commits into from
Jul 11, 2024
Merged
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ module.exports = {
{
files: [
'**/@(storybook|stories)/*',
'packages/components/src/**/*.tsx',
DaniGuardiola marked this conversation as resolved.
Show resolved Hide resolved
'packages/components/src/**/*.{ts,tsx}',
],
rules: {
// Useful to add story descriptions via JSDoc without specifying params,
Expand Down
102 changes: 3 additions & 99 deletions packages/components/src/tabs/tablist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,7 @@ import * as Ariakit from '@ariakit/react';
* WordPress dependencies
*/
import warning from '@wordpress/warning';
import {
forwardRef,
useEffect,
useLayoutEffect,
useRef,
useState,
} from '@wordpress/element';
import { forwardRef, useState } from '@wordpress/element';

/**
* Internal dependencies
Expand All @@ -24,97 +18,7 @@ import { useTabsContext } from './context';
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: replace with useEventCallback or similar when officially available.
const updateCallbackRef = useRef( onUpdate );
useLayoutEffect( () => {
updateCallbackRef.current = onUpdate;
} );

const observedElementRef = useRef< HTMLElement >();
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?.();
}

// Set up a ResizeObserver.
if ( ! resizeObserverRef.current ) {
resizeObserverRef.current = new ResizeObserver( () => {
if ( observedElementRef.current ) {
updateIndicator( observedElementRef.current );
}
} );
}
const { current: resizeObserver } = resizeObserverRef;

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

return () => {
// Unobserve previous element.
if ( observedElementRef.current ) {
resizeObserver.unobserve( observedElementRef.current );
}
};
}, [ targetElement ] );

return indicatorPosition;
}

type ValueUpdateContext< T > = {
previousValue: T;
};

function useOnValueUpdate< T >(
value: T,
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;
} );

useEffect( () => {
if ( previousValueRef.current !== value ) {
updateCallbackRef.current( {
previousValue: previousValueRef.current,
} );
previousValueRef.current = value;
}
}, [ value ] );
}
import { useOnValueUpdate, useTrackElementOffsetRect } from '../utils/react';

export const TabList = forwardRef<
HTMLDivElement,
Expand All @@ -123,7 +27,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
244 changes: 244 additions & 0 deletions packages/components/src/utils/react.ts
DaniGuardiola marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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 ) {
DaniGuardiola marked this conversation as resolved.
Show resolved Hide resolved
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 );
* } );
*
* <div ref={ setTargetElement } />;
* ```
*/
export function useResizeObserver(
DaniGuardiola marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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 ] );
}
Loading