diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 55c85565f522aa..67a63b8b656461 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -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)). @@ -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)). diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts index fcdb43512d82f9..a6425f9ab31e45 100644 --- a/packages/components/src/tabs/styles.ts +++ b/packages/components/src/tabs/styles.ts @@ -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 ] }; } `; diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index 80ed9b4c5bea2f..2977d6a6283708 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -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( diff --git a/packages/components/src/utils/element-rect.ts b/packages/components/src/utils/element-rect.ts index 9f6eb120b32fc5..550ec35b0bc932 100644 --- a/packages/components/src/utils/element-rect.ts +++ b/packages/components/src/utils/element-rect.ts @@ -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 ); - * } ); - * - *
; - * ``` - */ -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. */ @@ -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` 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;