Skip to content

Commit

Permalink
Improve Tabs indicator animation and related utils (#64926)
Browse files Browse the repository at this point in the history
* Refactor utils and switch Tabs indicator animation to `transform`.

* docs tweak

* Update packages/components/src/tabs/styles.ts

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>

* Add RTL support.

* Addressed @ciampo's comments.

* Correct for antialiasing rounding.

* Make antialiasing adjustment optional.

* Use larger base value and revert antialiasing adjustment code.

* DRY RTL

* Remove RTL story (redundant since Storybook has a dynamic setting to test RTL).

* Fix bug.

* Fix bug (for real this time).

* Add changelog entry.

* De-cleverfy code.

* Sync useResizeObserver with #64943 and make useTrackElementOffsetRect resilient.

* Deduplicate utility and clean up.

* DRY antialiasing factor.

* Changelogs.

---------

Co-authored-by: DaniGuardiola <daniguardiola@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: t-hamano <wildworks@git.wordpress.org>
Co-authored-by: tyxla <tyxla@git.wordpress.org>
  • Loading branch information
5 people authored Sep 11, 2024
1 parent 0c0e605 commit e1ad8c2
Show file tree
Hide file tree
Showing 4 changed files with 143 additions and 145 deletions.
5 changes: 5 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- `Tabs`: indicator positioning under RTL direction ([#64926](https://github.com/WordPress/gutenberg/pull/64926)).

### Deprecations

- Deprecate `__unstableComposite`, `__unstableCompositeGroup`, `__unstableCompositeItem` and `__unstableUseCompositeState`. Consumers of the package should use the stable `Composite` component instead ([#63572](https://github.com/WordPress/gutenberg/pull/63572)).
Expand All @@ -21,6 +25,7 @@

### Internal

- `Tabs`: improved performance of the indicator animation ([#64926](https://github.com/WordPress/gutenberg/pull/64926)).
- `Composite`: Remove from private APIs ([#63569](https://github.com/WordPress/gutenberg/pull/63569)).
- use local copy of `use-lilius` instead of `npm` dependency ([#65097](https://github.com/WordPress/gutenberg/pull/65097)).

Expand Down
51 changes: 40 additions & 11 deletions packages/components/src/tabs/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,65 @@ export const TabListWrapper = styled.div`
text-align: start;
}
@media not ( prefers-reduced-motion: reduce ) {
@media not ( prefers-reduced-motion ) {
&.is-animation-enabled::after {
transition-property: left, top, width, height;
transition-property: transform;
transition-duration: 0.2s;
transition-timing-function: ease-out;
}
}
--direction-factor: 1;
--direction-origin-x: left;
--indicator-start: var( --indicator-left );
&:dir( rtl ) {
--direction-factor: -1;
--direction-origin-x: right;
--indicator-start: var( --indicator-right );
}
&::after {
content: '';
position: absolute;
pointer-events: none;
transform-origin: var( --direction-origin-x ) top;
// Windows high contrast mode.
outline: 2px solid transparent;
outline-offset: -1px;
}
&:not( [aria-orientation='vertical'] )::after {
bottom: 0;
left: var( --indicator-left );
width: var( --indicator-width );
height: 0;
border-bottom: var( --wp-admin-border-width-focus ) solid
${ COLORS.theme.accent };
/* Using a large value to avoid antialiasing rounding issues
when scaling in the transform, see: https://stackoverflow.com/a/52159123 */
--antialiasing-factor: 100;
&:not( [aria-orientation='vertical'] ) {
&::after {
bottom: 0;
height: 0;
width: calc( var( --antialiasing-factor ) * 1px );
transform: translateX(
calc(
var( --indicator-start ) * var( --direction-factor ) *
1px
)
)
scaleX(
calc(
var( --indicator-width ) / var( --antialiasing-factor )
)
);
border-bottom: var( --wp-admin-border-width-focus ) solid
${ COLORS.theme.accent };
}
}
&[aria-orientation='vertical']::after {
z-index: -1;
top: 0;
left: 0;
width: 100%;
top: var( --indicator-top );
height: var( --indicator-height );
width: calc( var( --antialiasing-factor ) * 1px );
transform: translateY( calc( var( --indicator-top ) * 1px ) )
scaleY(
calc( var( --indicator-height ) / var( --antialiasing-factor ) )
);
background-color: ${ COLORS.theme.gray[ 100 ] };
}
`;
Expand Down
9 changes: 5 additions & 4 deletions packages/components/src/tabs/tablist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ export const TabList = forwardRef<
onBlur={ onBlur }
{ ...otherProps }
style={ {
'--indicator-left': `${ indicatorPosition.left }px`,
'--indicator-top': `${ indicatorPosition.top }px`,
'--indicator-width': `${ indicatorPosition.width }px`,
'--indicator-height': `${ indicatorPosition.height }px`,
'--indicator-top': indicatorPosition.top,
'--indicator-right': indicatorPosition.right,
'--indicator-left': indicatorPosition.left,
'--indicator-width': indicatorPosition.width,
'--indicator-height': indicatorPosition.height,
...otherProps.style,
} }
className={ clsx(
Expand Down
223 changes: 93 additions & 130 deletions packages/components/src/utils/element-rect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,134 +2,37 @@
/**
* WordPress dependencies
*/
import { useRef, useEffect, useState } from '@wordpress/element';
import { useLayoutEffect, useRef, useState } from '@wordpress/element';
import { useResizeObserver } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { useEvent } from './hooks/use-event';

/**
* `useTrackElementRectUpdates` options.
* The position and dimensions of an element, relative to its offset parent.
*/
export type UseTrackElementRectUpdatesOptions = {
export type ElementOffsetRect = {
/**
* Whether to trigger the callback when an element's ResizeObserver is
* first set up, including when the target element changes.
*
* @default true
* The distance from the top edge of the offset parent to the top edge of
* the element.
*/
fireOnElementInit?: boolean;
};

/**
* 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.
*
* 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`.
*
* 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.
*
* @example
*
* ```tsx
* const [ targetElement, setTargetElement ] = useState< HTMLElement | null >();
*
* useTrackElementRectUpdates( targetElement, ( element ) => {
* console.log( 'Element resized:', element );
* } );
*
* <div ref={ setTargetElement } />;
* ```
*/
export function useTrackElementRectUpdates(
top: number;
/**
* The target element to observe. It can be changed dynamically.
* The distance from the right edge of the offset parent to the right edge
* of the element.
*/
targetElement: HTMLElement | undefined | null,
right: number;
/**
* Callback to fire when the element is resized. It will also be
* called when the observer is set up, unless `fireOnElementInit` is
* set to `false`.
* The distance from the bottom edge of the offset parent to the bottom edge
* of the element.
*/
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 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;
}

observedElementRef.current = targetElement;

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

// Observe new element.
if ( targetElement ) {
if ( fireOnElementInit ) {
// TODO: investigate if this can be removed,
// see: https://stackoverflow.com/a/60026394
onRectEvent( targetElement );
}
resizeObserver.observe( targetElement );
}

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

/**
* The position and dimensions of an element, relative to its offset parent.
*/
export type ElementOffsetRect = {
bottom: number;
/**
* 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.
*/
Expand All @@ -144,51 +47,111 @@ export type ElementOffsetRect = {
* An `ElementOffsetRect` object with all values set to zero.
*/
export const NULL_ELEMENT_OFFSET_RECT = {
left: 0,
top: 0,
right: 0,
bottom: 0,
left: 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.
* parent, with subpixel precision. Values reflect the real measures before any
* potential scaling distortions along the X and Y axes.
*
* **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.
* Useful in contexts where plain `getBoundingClientRect` calls or `ResizeObserver`
* entries are not suitable, such as when the element is transformed, and when
* `element.offset<Top|Left|Width|Height>` methods are not precise enough.
*
* **Note:** in some contexts, like when the scale is 0, this method will fail
* because it's impossible to calculate a scaling ratio. When that happens, it
* will return `undefined`.
*/
export function getElementOffsetRect(
element: HTMLElement
): ElementOffsetRect {
): ElementOffsetRect | undefined {
// Position and dimension values computed with `getBoundingClientRect` have
// subpixel precision, but are affected by distortions since they represent
// the "real" measures, or in other words, the actual final values as rendered
// by the browser.
const rect = element.getBoundingClientRect();
if ( rect.width === 0 || rect.height === 0 ) {
return;
}
const offsetParentRect =
element.offsetParent?.getBoundingClientRect() ??
NULL_ELEMENT_OFFSET_RECT;

// Computed widths and heights have subpixel precision, and are not affected
// by distortions.
const computedWidth = parseFloat( getComputedStyle( element ).width );
const computedHeight = parseFloat( getComputedStyle( element ).height );

// We can obtain the current scale factor for the element by comparing "computed"
// dimensions with the "real" ones.
const scaleX = computedWidth / rect.width;
const scaleY = computedHeight / rect.height;

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 ),
// To obtain the adjusted values for the position:
// 1. Compute the element's position relative to the offset parent.
// 2. Correct for the scale factor.
top: ( rect.top - offsetParentRect?.top ) * scaleY,
right: ( offsetParentRect?.right - rect.right ) * scaleX,
bottom: ( offsetParentRect?.bottom - rect.bottom ) * scaleY,
left: ( rect.left - offsetParentRect?.left ) * scaleX,
// Computed dimensions don't need any adjustments.
width: computedWidth,
height: computedHeight,
};
}

const POLL_RATE = 100;

/**
* Tracks the position and dimensions of an element, relative to its offset
* parent. The element can be changed dynamically.
*
* **Note:** sometimes, the measurement will fail (see `getElementOffsetRect`'s
* documentation for more details). When that happens, this hook will attempt
* to measure again after a frame, and if that fails, it will poll every 100
* milliseconds until it succeeds.
*/
export function useTrackElementOffsetRect(
targetElement: HTMLElement | undefined | null
) {
const [ indicatorPosition, setIndicatorPosition ] =
useState< ElementOffsetRect >( NULL_ELEMENT_OFFSET_RECT );
const intervalRef = useRef< ReturnType< typeof setInterval > >();

const measure = useEvent( () => {
if ( targetElement ) {
const elementOffsetRect = getElementOffsetRect( targetElement );
if ( elementOffsetRect ) {
setIndicatorPosition( elementOffsetRect );
clearInterval( intervalRef.current );
return true;
}
} else {
clearInterval( intervalRef.current );
}
return false;
} );

const setElement = useResizeObserver( () => {
if ( ! measure() ) {
requestAnimationFrame( () => {
if ( ! measure() ) {
intervalRef.current = setInterval( measure, POLL_RATE );
}
} );
}
} );

useTrackElementRectUpdates( targetElement, ( element ) =>
setIndicatorPosition( getElementOffsetRect( element ) )
useLayoutEffect(
() => setElement( targetElement ),
[ setElement, targetElement ]
);

return indicatorPosition;
Expand Down

0 comments on commit e1ad8c2

Please sign in to comment.