UNPKG

@acusti/use-is-out-of-bounds

Version:

React hook that detects if an element is outside of its parent’s bounds on any of its sides (like collision detection)

200 lines (179 loc) 7.83 kB
import useBoundingClientRect from '@acusti/use-bounding-client-rect'; import * as React from 'react'; const { useRef } = React; type MaybeHTMLElement = HTMLElement | null; type OutOfBounds = { bottom: boolean; hasLayout: boolean; left: boolean; maxHeight: null | number; maxWidth: null | number; right: boolean; top: boolean; }; const INITIAL_OUT_OF_BOUNDS: OutOfBounds = Object.freeze({ bottom: false, hasLayout: false, left: false, maxHeight: null, maxWidth: null, right: false, top: false, }); const INITIAL_OUT_OF_BOUNDS_HAS_LAYOUT: OutOfBounds = Object.freeze({ ...INITIAL_OUT_OF_BOUNDS, hasLayout: true, }); const getBoundingAncestor = (element?: MaybeHTMLElement): MaybeHTMLElement => { while (element?.parentElement) { // If we’ve reached the body, use that as boundingElement if (element.parentElement.tagName === 'BODY') return element.parentElement; // Only need to check one overflow direction, because if either direction // is not visible, neither can be visible if (getComputedStyle(element.parentElement).overflowX !== 'visible') { return element.parentElement; } element = element.parentElement as MaybeHTMLElement; } return null; }; // Opposite direction needs to have much more available space then current direction const getShouldSwitchDirection = ( spaceAvailable: number, spaceAvailableOpposite: number, ) => { // Give preference to current direction if it is within 10px out of bounds if (spaceAvailable > -10) return false; const difference = spaceAvailableOpposite - spaceAvailable; // If opposite direction doesn’t have a lot more available space, don’t switch if (difference <= 40) return false; // To be worth switching, opposite direction has to be at least ¼ less out of bounds return spaceAvailableOpposite >= spaceAvailable * 0.75; }; const getIsCurrentDirectionBefore = ({ length, positionAfter, positionBefore, }: { length: number; positionAfter: number; positionBefore: number; }) => { if (Number.isNaN(positionAfter)) return false; if (Number.isNaN(positionBefore)) return true; // If before position is already positive, it’s direction isn’t before if (positionBefore >= 0) return false; // Consider element in negative direction if more than ½ its length is before origin return positionAfter < length / 2; }; const useIsOutOfBounds = (element?: MaybeHTMLElement): OutOfBounds => { const outOfBoundsRef = useRef<OutOfBounds>(INITIAL_OUT_OF_BOUNDS); const elementRef = useRef<MaybeHTMLElement>(element ?? null); const computedStyleRef = useRef<CSSStyleDeclaration | null>(null); const elementRect = useBoundingClientRect(element); const boundingElement = getBoundingAncestor(element); const boundingElementRect = useBoundingClientRect(boundingElement); if (element !== elementRef.current) { elementRef.current = element ?? null; computedStyleRef.current = null; } if (!element || elementRect.top == null) { outOfBoundsRef.current = INITIAL_OUT_OF_BOUNDS; return INITIAL_OUT_OF_BOUNDS; } else if (boundingElementRect.top == null) { outOfBoundsRef.current = INITIAL_OUT_OF_BOUNDS_HAS_LAYOUT; return INITIAL_OUT_OF_BOUNDS_HAS_LAYOUT; } const previousOutOfBounds = outOfBoundsRef.current; const elementBottom = elementRect.bottom!; const elementLeft = elementRect.left!; const elementRight = elementRect.right!; const elementTop = elementRect.top!; // If boundingElement is the <body> element, use viewport height as bottom const boundingElementBottom = boundingElement?.tagName === 'BODY' ? boundingElement.ownerDocument.documentElement.clientHeight! : boundingElementRect.bottom!; const boundingElementLeft = boundingElementRect.left!; const boundingElementRight = boundingElementRect.right!; const boundingElementTop = boundingElementRect.top!; const elementHeight = elementBottom - elementTop; const elementWidth = elementRight - elementLeft; // If direction isn’t currently out-of-bounds, default to previous value. // This prevents us erroneously switching back if direction changed and new // direction causes the previous direction to seem to now have available space. let bottom = elementBottom > boundingElementBottom || previousOutOfBounds.bottom; let left = elementLeft < boundingElementLeft || previousOutOfBounds.left; let right = elementRight > boundingElementRight || previousOutOfBounds.right; let top = elementTop < boundingElementTop || previousOutOfBounds.top; if (bottom || left || right || top) { const style = computedStyleRef.current ?? getComputedStyle(element); if (!computedStyleRef.current) { computedStyleRef.current = style; } const isUpward = getIsCurrentDirectionBefore({ length: elementHeight, positionAfter: parseFloat(style.getPropertyValue('bottom')), positionBefore: parseFloat(style.getPropertyValue('top')), }); const isLeftward = getIsCurrentDirectionBefore({ length: elementWidth, positionAfter: parseFloat(style.getPropertyValue('right')), positionBefore: parseFloat(style.getPropertyValue('left')), }); // Identify available space in each direction const offsetBottom = isUpward ? elementHeight : 0; const offsetLeft = isLeftward ? 0 : elementWidth; const offsetRight = isLeftward ? elementWidth : 0; const offsetTop = isUpward ? 0 : elementHeight; const availableLeft = (elementLeft - offsetLeft) - boundingElementLeft; // prettier-ignore const availableRight = boundingElementRight - (elementRight + offsetRight); // prettier-ignore const availableTop = (elementTop - offsetTop) - boundingElementTop; // prettier-ignore const availableBottom = boundingElementBottom - (elementBottom + offsetBottom); // prettier-ignore // If element is out-of-bounds in direction it’s rendering, check if should switch if (isUpward && top) { top = getShouldSwitchDirection(availableTop, availableBottom); bottom = !top; } else if (!isUpward && bottom) { bottom = getShouldSwitchDirection(availableBottom, availableTop); top = !bottom; } if (isLeftward && left) { left = getShouldSwitchDirection(availableLeft, availableRight); right = !left; } else if (!isLeftward && right) { right = getShouldSwitchDirection(availableRight, availableLeft); left = !right; } } const maxHeight = !bottom || top ? boundingElementBottom - elementTop : elementBottom - boundingElementTop; const maxWidth = !right || left ? boundingElementRight - elementLeft : elementRight - boundingElementLeft; // Only overwrite outOfBoundsRef if one of the values has changed if ( !previousOutOfBounds.hasLayout || bottom !== previousOutOfBounds.bottom || left !== previousOutOfBounds.left || maxHeight !== previousOutOfBounds.maxHeight || maxWidth !== previousOutOfBounds.maxWidth || right !== previousOutOfBounds.right || top !== previousOutOfBounds.top ) { outOfBoundsRef.current = { bottom, hasLayout: true, left, maxHeight, maxWidth, right, top, }; } return outOfBoundsRef.current; }; export default useIsOutOfBounds;