import { Component, type JSX, useCallback, useEffect, useRef, useState } from 'react'; import type { CSSProperties, PropsWithChildren, ReactNode, Ref } from 'react'; export const ENTRY_REVEAL_ANIMATION_MS = 700; export const ENTRY_REVEAL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; interface AnimatedHeightRevealProps extends PropsWithChildren { animate?: boolean; className?: string; style?: CSSProperties; containerRef?: Ref; } function assignRef(ref: Ref | undefined, value: T | null): void { if (!ref) return; if (typeof ref === 'function') { ref(value); return; } const mutableRef = ref as { current: T | null }; mutableRef.current = value; } function needsAnimatedWrapper(props: AnimatedHeightRevealProps): boolean { return Boolean(props.animate || props.className || props.style || props.containerRef); } const AnimatedHeightRevealPassthrough = ({ children }: PropsWithChildren): JSX.Element => ( // eslint-disable-next-line react/jsx-no-useless-fragment -- preserves a DOM-free passthrough slot. <>{children} ); class AnimatedHeightRevealSlot extends Component { private hasRenderedInner = needsAnimatedWrapper(this.props); // eslint-disable-next-line sonarjs/function-return-type -- latch intentionally switches from passthrough to animated slot once. render(): ReactNode { const { animate, className, style, containerRef, children } = this.props; const needsWrapper = needsAnimatedWrapper(this.props); if (needsWrapper) { this.hasRenderedInner = true; } if (!this.hasRenderedInner) { return {children}; } return ( {children} ); } } export const AnimatedHeightReveal = (props: AnimatedHeightRevealProps): JSX.Element => { // Latch the inner (hook-bearing, animating) variant for the lifetime of this slot. // A call site that only passes `animate` (e.g. animate={isNewItem}) flips it true->false // on the render right after the item appears. Without the latch the returned element type // would switch from AnimatedHeightRevealInner to a bare Fragment on that flip, so React // would unmount the inner subtree mid-reveal - aborting the entry animation and remounting // the children (losing focus/internal state). Once the inner variant has rendered we keep // rendering it so the element type stays stable; items that never need it keep the // hook-free fast path. return ( {props.children} ); }; const AnimatedHeightRevealInner = ({ animate, className, style, containerRef, children, }: AnimatedHeightRevealProps): JSX.Element => { const [shouldAnimateOnMount] = useState(() => Boolean(animate)); const wrapperRef = useRef(null); const animationFrameRef = useRef(null); const [prefersReducedMotion] = useState( () => window.matchMedia('(prefers-reduced-motion: reduce)').matches ); const [isExpanded, setIsExpanded] = useState( () => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches ); // Overflow must stay hidden during the height transition so the grid clip // actually works. Switch to visible only after the animation completes. const [overflowVisible, setOverflowVisible] = useState( () => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches ); const setWrapperRef = useCallback( (node: HTMLDivElement | null) => { wrapperRef.current = node; assignRef(containerRef, node); }, [containerRef] ); const clearPendingAnimation = useCallback(() => { if (animationFrameRef.current !== null) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }, []); useEffect(() => { if (!shouldAnimateOnMount || prefersReducedMotion) { return; } animationFrameRef.current = requestAnimationFrame(() => { animationFrameRef.current = requestAnimationFrame(() => { setIsExpanded(true); animationFrameRef.current = null; }); }); // Switch overflow to visible after the height transition finishes // so popovers/tooltips inside can render outside bounds. const overflowTimer = setTimeout(() => { setOverflowVisible(true); }, ENTRY_REVEAL_ANIMATION_MS + 50); return () => { clearPendingAnimation(); clearTimeout(overflowTimer); }; }, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]); useEffect( () => () => { clearPendingAnimation(); }, [clearPendingAnimation] ); const shouldTransition = shouldAnimateOnMount && !prefersReducedMotion && isExpanded; return (
{children}
); };