diff --git a/packages/ui/src/elements/AnimateHeight/index.tsx b/packages/ui/src/elements/AnimateHeight/index.tsx index 5b6d8790d77..8b58a26750e 100644 --- a/packages/ui/src/elements/AnimateHeight/index.tsx +++ b/packages/ui/src/elements/AnimateHeight/index.tsx @@ -12,6 +12,7 @@ export const AnimateHeight: React.FC<{ id?: string }> = ({ id, children, className, duration = 300, height }) => { const [open, setOpen] = React.useState(() => Boolean(height)) + const prevIsOpen = useRef(open) const [childrenDisplay, setChildrenDisplay] = React.useState( @@ -24,6 +25,9 @@ export const AnimateHeight: React.FC<{ const [isAnimating, setIsAnimating] = React.useState(false) + const containerRef = useRef(null) + const contentRef = useRef(null) + useEffect(() => { let displayTimer: number let overflowTimer: number @@ -65,10 +69,9 @@ export const AnimateHeight: React.FC<{ } }, [height, duration]) - const containerRef = useRef(null) - usePatchAnimateHeight({ containerRef, + contentRef, duration, open, }) @@ -92,7 +95,9 @@ export const AnimateHeight: React.FC<{ transition: `height ${duration}ms ease`, }} > -
{children}
+
+ {children} +
) } diff --git a/packages/ui/src/elements/AnimateHeight/usePatchAnimateHeight.ts b/packages/ui/src/elements/AnimateHeight/usePatchAnimateHeight.ts index c1443c95bb9..a00c60185bd 100644 --- a/packages/ui/src/elements/AnimateHeight/usePatchAnimateHeight.ts +++ b/packages/ui/src/elements/AnimateHeight/usePatchAnimateHeight.ts @@ -1,70 +1,97 @@ -'use client' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' export const usePatchAnimateHeight = ({ containerRef, + contentRef, duration, open, }: { containerRef: React.RefObject + contentRef: React.RefObject duration: number open: boolean }): { browserSupportsKeywordAnimation: boolean } => { const browserSupportsKeywordAnimation = useMemo( () => typeof CSS !== 'undefined' && CSS && CSS.supports - ? Boolean(CSS.supports('interpolate-size', 'allow-keywords')) + ? CSS.supports('interpolate-size', 'allow-keywords') : false, [], ) + const hasInitialized = useRef(false) const previousOpenState = useRef(open) + const [isAnimating, setIsAnimating] = useState(false) + const resizeObserverRef = useRef(null) useEffect(() => { - if (containerRef.current && !browserSupportsKeywordAnimation) { - const container = containerRef.current - - const getTotalHeight = (el: HTMLDivElement) => { - const styles = window.getComputedStyle(el) - const marginTop = parseFloat(styles.marginTop) - const marginBottom = parseFloat(styles.marginBottom) - return el.scrollHeight + marginTop + marginBottom + const container = containerRef.current + const content = contentRef.current + + if (!container || !content || browserSupportsKeywordAnimation) { + return + } + + const setContainerHeight = (height: string) => { + container.style.height = height + } + + const handleTransitionEnd = () => { + if (container) { + container.style.transition = '' + container.style.height = open ? 'auto' : '0px' + setIsAnimating(false) } + } - const animate = () => { - const maxContentHeight = getTotalHeight(container) + const animate = () => { + if (!hasInitialized.current && open) { + // Skip animation on first render + setContainerHeight('auto') + setIsAnimating(false) + return + } - // Set initial state - if (previousOpenState.current !== open) { - container.style.height = open ? '0px' : `${maxContentHeight}px` - } + hasInitialized.current = true - // Trigger reflow - container.offsetHeight // eslint-disable-line @typescript-eslint/no-unused-expressions + if (previousOpenState.current !== open) { + setContainerHeight(open ? '0px' : `${content.scrollHeight}px`) + } - // Start animation - container.style.transition = `height ${duration}ms ease` - container.style.height = open ? `${maxContentHeight}px` : '0px' + // Trigger reflow + container.offsetHeight // eslint-disable-line @typescript-eslint/no-unused-expressions - const transitionEndHandler = () => { - container.style.transition = '' - container.style.height = !open ? '0px' : 'auto' - container.removeEventListener('transitionend', transitionEndHandler) - } + setIsAnimating(true) + container.style.transition = `height ${duration}ms ease` + setContainerHeight(open ? `${content.scrollHeight}px` : '0px') - container.addEventListener('transitionend', transitionEndHandler) + const onTransitionEnd = () => { + handleTransitionEnd() + container.removeEventListener('transitionend', onTransitionEnd) } - animate() + container.addEventListener('transitionend', onTransitionEnd) + } + + animate() + previousOpenState.current = open - previousOpenState.current = open + // Setup ResizeObserver + resizeObserverRef.current = new ResizeObserver(() => { + if (isAnimating) { + container.style.height = open ? `${content.scrollHeight}px` : '0px' + } + }) + resizeObserverRef.current.observe(content) - return () => { + return () => { + if (container) { container.style.transition = '' container.style.height = '' } + resizeObserverRef.current?.disconnect() } - }, [open, duration, containerRef, browserSupportsKeywordAnimation]) + }, [open, duration, containerRef, contentRef, browserSupportsKeywordAnimation]) return { browserSupportsKeywordAnimation } }