From f5a833abe152fef9698228784b8e3e96b553928e Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Fri, 30 Aug 2024 13:44:35 +0200 Subject: [PATCH 01/18] Refactor utils and switch Tabs indicator animation to `transform`. --- packages/components/src/tabs/styles.ts | 14 +- packages/components/src/tabs/tablist.tsx | 8 +- packages/components/src/utils/element-rect.ts | 126 ++++++++++-------- 3 files changed, 83 insertions(+), 65 deletions(-) diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts index fcdb43512d82f..f2373b3353b2e 100644 --- a/packages/components/src/tabs/styles.ts +++ b/packages/components/src/tabs/styles.ts @@ -24,7 +24,7 @@ export const TabListWrapper = styled.div` @media not ( prefers-reduced-motion: reduce ) { &.is-animation-enabled::after { - transition-property: left, top, width, height; + transition-property: transform; transition-duration: 0.2s; transition-timing-function: ease-out; } @@ -33,6 +33,7 @@ export const TabListWrapper = styled.div` content: ''; position: absolute; pointer-events: none; + transform-origin: 0 0; // Windows high contrast mode. outline: 2px solid transparent; @@ -40,18 +41,21 @@ export const TabListWrapper = styled.div` } &:not( [aria-orientation='vertical'] )::after { bottom: 0; - left: var( --indicator-left ); - width: var( --indicator-width ); height: 0; + width: 1px; + transform: translateX( calc( var( --indicator-left ) * 1px ) ) + scaleX( var( --indicator-width ) ); 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 ); + height: 1px; + transform: translateY( calc( var( --indicator-top ) * 1px ) ) + scaleY( var( --indicator-height ) ); background-color: ${ COLORS.theme.gray[ 100 ] }; } `; diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index 80ed9b4c5bea2..9adf8aedbbcd2 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -78,10 +78,10 @@ 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-left': indicatorPosition.left, + '--indicator-top': indicatorPosition.top, + '--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 9f6eb120b32fc..77fb313c5bf1c 100644 --- a/packages/components/src/utils/element-rect.ts +++ b/packages/components/src/utils/element-rect.ts @@ -22,64 +22,64 @@ export type UseTrackElementRectUpdatesOptions = { }; /** - * 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 + * Tracks a given element's size and calls `onUpdate` for all of its discrete + * values using a `ResizeObserver`. The element can change 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 ); + * useResizeObserver( targetElement, ( resizeObserverEntries, element, { box: "border-box" } ) => { + * console.log( 'Resize observer entries:', resizeObserverEntries ); + * console.log( 'Element that was resized:', element ); * } ); - * *
; * ``` */ -export function useTrackElementRectUpdates( +export 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 `fireOnElementInit` is - * set to `false`. + * Callback that will be called when the element is resized. */ - onRect: ( - /** - * The element being tracked at the time of this update. - */ - element: HTMLElement, + onUpdate: ( /** * 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. + * objects passed to the `ResizeObserver.observe` callback internally. + */ + resizeObserverEntries: ResizeObserverEntry[], + /** + * The element being tracked at the time of this update. */ - resizeObserverEntries?: ResizeObserverEntry[] + element: HTMLElement ) => void, - { fireOnElementInit = true }: UseTrackElementRectUpdatesOptions = {} + /** + * Options to pass to the `ResizeObserver.observe` callback. + * + * Updating this option will not cause the observer to be re-created, and it + * will only take effect if a new element is observed. + */ + resizeObserverOptions?: ResizeObserverOptions ) { - const onRectEvent = useEvent( onRect ); + const onUpdateEvent = useEvent( onUpdate ); const observedElementRef = useRef< HTMLElement | null >(); const resizeObserverRef = useRef< ResizeObserver >(); - // TODO: could this be a layout effect? + // Options are passed on `.observe` once and never updated, so we store them + // in an up-to-date ref to avoid unnecessary cycles of the effect due to + // unstable option objects such as inlined literals. + const resizeObserverOptionsRef = useRef( resizeObserverOptions ); + useEffect( () => { + resizeObserverOptionsRef.current = resizeObserverOptions; + }, [ resizeObserverOptions ] ); + + // TODO: could/should this be a layout effect? useEffect( () => { if ( targetElement === observedElementRef.current ) { return; @@ -91,7 +91,7 @@ export function useTrackElementRectUpdates( if ( ! resizeObserverRef.current ) { resizeObserverRef.current = new ResizeObserver( ( entries ) => { if ( observedElementRef.current ) { - onRectEvent( observedElementRef.current, entries ); + onUpdateEvent( entries, observedElementRef.current ); } } ); } @@ -99,12 +99,10 @@ export function useTrackElementRectUpdates( // 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 ); + resizeObserver.observe( + targetElement, + resizeObserverOptionsRef.current + ); } return () => { @@ -113,7 +111,7 @@ export function useTrackElementRectUpdates( resizeObserver.unobserve( observedElementRef.current ); } }; - }, [ fireOnElementInit, onRectEvent, targetElement ] ); + }, [ onUpdateEvent, targetElement ] ); } /** @@ -152,28 +150,44 @@ export const NULL_ELEMENT_OFFSET_RECT = { /** * 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. */ export function getElementOffsetRect( element: HTMLElement ): ElementOffsetRect { + // 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(); + 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 right values for the position: + // 1. Compute the element's position relative to the offset parent. + // 2. Correct for the scale factor. + left: ( rect.left - offsetParentRect?.left ) * scaleX, + top: ( rect.top - offsetParentRect?.top ) * scaleY, + // Computed dimensions don't need any adjustments. + width: computedWidth, + height: computedHeight, }; } @@ -187,7 +201,7 @@ export function useTrackElementOffsetRect( const [ indicatorPosition, setIndicatorPosition ] = useState< ElementOffsetRect >( NULL_ELEMENT_OFFSET_RECT ); - useTrackElementRectUpdates( targetElement, ( element ) => + useResizeObserver( targetElement, ( _, element ) => setIndicatorPosition( getElementOffsetRect( element ) ) ); From 42080a493f3812452437ec4d3cbf3907f13e59f8 Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Fri, 30 Aug 2024 17:46:33 +0200 Subject: [PATCH 02/18] docs tweak --- packages/components/src/utils/element-rect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/utils/element-rect.ts b/packages/components/src/utils/element-rect.ts index 77fb313c5bf1c..d76975408bb1d 100644 --- a/packages/components/src/utils/element-rect.ts +++ b/packages/components/src/utils/element-rect.ts @@ -59,7 +59,7 @@ export function useResizeObserver( element: HTMLElement ) => void, /** - * Options to pass to the `ResizeObserver.observe` callback. + * Options to pass to `ResizeObserver.observe` when called internally. * * Updating this option will not cause the observer to be re-created, and it * will only take effect if a new element is observed. From 7377fc0a378f955e910a0045b110ca7bcc274deb Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Fri, 30 Aug 2024 19:11:24 +0200 Subject: [PATCH 03/18] Update packages/components/src/tabs/styles.ts Co-authored-by: Marco Ciampini --- packages/components/src/tabs/styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts index f2373b3353b2e..8e0ea0990c42f 100644 --- a/packages/components/src/tabs/styles.ts +++ b/packages/components/src/tabs/styles.ts @@ -22,7 +22,7 @@ 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: transform; transition-duration: 0.2s; From 405c258e7bf3dd6d78b0665cc071b4b2568abed8 Mon Sep 17 00:00:00 2001 From: Dani Guardiola Date: Mon, 2 Sep 2024 12:08:28 +0200 Subject: [PATCH 04/18] Add RTL support. --- .../src/tabs/stories/index.story.tsx | 9 +++++++ packages/components/src/tabs/styles.ts | 27 ++++++++++++------- packages/components/src/tabs/tablist.tsx | 3 ++- packages/components/src/utils/element-rect.ts | 26 +++++++++++++----- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx index e5f113d93b7d0..5836f620c4049 100644 --- a/packages/components/src/tabs/stories/index.story.tsx +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -70,6 +70,15 @@ const Template: StoryFn< typeof Tabs > = ( props ) => { export const Default = Template.bind( {} ); +export const RTL = () => { + return ( +
+ { /* @ts-expect-error - This is a temporary hack until we migrate to CSF 3 and my sanity is restored. */ } +