@wordpress/components
Version:
UI components for WordPress.
190 lines (175 loc) • 6 kB
text/typescript
/* eslint-disable jsdoc/require-param */
/**
* WordPress dependencies
*/
import { useLayoutEffect, useRef, useState } from '@wordpress/element';
import { useEvent, useResizeObserver } from '@wordpress/compose';
/**
* The position and dimensions of an element, relative to its offset parent.
*/
export type ElementOffsetRect = {
/**
* The element the rect belongs to.
*/
element: HTMLElement | undefined;
/**
* The distance from the top edge of the offset parent to the top edge of
* the element.
*/
top: number;
/**
* The distance from the right edge of the offset parent to the right edge
* of the element.
*/
right: number;
/**
* The distance from the bottom edge of the offset parent to the bottom edge
* of the element.
*/
bottom: number;
/**
* The distance from the left edge of the offset parent to the left edge of
* the element.
*/
left: 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 = {
element: undefined,
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, with subpixel precision. Values reflect the real measures before any
* potential scaling distortions along the X and Y axes.
*
* 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 | 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 offsetParent = element.offsetParent;
const offsetParentRect =
offsetParent?.getBoundingClientRect() ?? NULL_ELEMENT_OFFSET_RECT;
const offsetParentScrollX = offsetParent?.scrollLeft ?? 0;
const offsetParentScrollY = offsetParent?.scrollTop ?? 0;
// 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 {
element,
// 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.
// 3. Adjust for the scroll position of the offset parent.
top:
( rect.top - offsetParentRect?.top ) * scaleY + offsetParentScrollY,
right:
( offsetParentRect?.right - rect.right ) * scaleX -
offsetParentScrollX,
bottom:
( offsetParentRect?.bottom - rect.bottom ) * scaleY -
offsetParentScrollY,
left:
( rect.left - offsetParentRect?.left ) * scaleX +
offsetParentScrollX,
// 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.
*
* When no element is provided (`null` or `undefined`), the hook will return
* a "null" rect, in which all values are `0` and `element` is `undefined`.
*
* **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,
deps: unknown[] = []
) {
const [ indicatorPosition, setIndicatorPosition ] =
useState< ElementOffsetRect >( NULL_ELEMENT_OFFSET_RECT );
const intervalRef = useRef< ReturnType< typeof setInterval > >();
const measure = useEvent( () => {
// Check that the targetElement is still attached to the DOM, in case
// it was removed since the last `measure` call.
if ( targetElement && targetElement.isConnected ) {
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 );
}
} );
}
} );
useLayoutEffect( () => {
setElement( targetElement );
if ( ! targetElement ) {
setIndicatorPosition( NULL_ELEMENT_OFFSET_RECT );
}
}, [ setElement, targetElement ] );
// Escape hatch to force a remeasurement when something else changes rather
// than the target elements' ref or size (for example, the target element
// can change its position within the tablist).
useLayoutEffect( () => {
measure();
// `measure` is a stable function, so it's safe to omit it from the deps array.
// deps can't be statically analyzed by ESLint
}, deps );
return indicatorPosition;
}
/* eslint-enable jsdoc/require-param */