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

Improve Tabs indicator animation and related utils #64926

Merged
merged 19 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions packages/components/src/tabs/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const TabListWrapper = styled.div`

@media not ( prefers-reduced-motion: reduce ) {
DaniGuardiola marked this conversation as resolved.
Show resolved Hide resolved
&.is-animation-enabled::after {
transition-property: left, top, width, height;
transition-property: transform;
transition-duration: 0.2s;
transition-timing-function: ease-out;
}
Expand All @@ -33,25 +33,29 @@ export const TabListWrapper = styled.div`
content: '';
position: absolute;
pointer-events: none;
transform-origin: 0 0;

// 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;
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 ] };
}
`;
Expand Down
8 changes: 4 additions & 4 deletions packages/components/src/tabs/tablist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
126 changes: 70 additions & 56 deletions packages/components/src/utils/element-rect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
* } );
*
* <div ref={ setTargetElement } />;
* ```
*/
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;
Expand All @@ -91,20 +91,18 @@ export function useTrackElementRectUpdates(
if ( ! resizeObserverRef.current ) {
resizeObserverRef.current = new ResizeObserver( ( entries ) => {
if ( observedElementRef.current ) {
onRectEvent( observedElementRef.current, entries );
onUpdateEvent( entries, observedElementRef.current );
}
} );
}
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 );
resizeObserver.observe(
targetElement,
resizeObserverOptionsRef.current
);
}

return () => {
Expand All @@ -113,7 +111,7 @@ export function useTrackElementRectUpdates(
resizeObserver.unobserve( observedElementRef.current );
}
};
}, [ fireOnElementInit, onRectEvent, targetElement ] );
}, [ onUpdateEvent, targetElement ] );
}

/**
Expand Down Expand Up @@ -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<Top|Left|Width|Height>` 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() ??
DaniGuardiola marked this conversation as resolved.
Show resolved Hide resolved
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:
DaniGuardiola marked this conversation as resolved.
Show resolved Hide resolved
// 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,
};
}

Expand All @@ -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 ) )
);

Expand Down
Loading