Skip to content

Commit

Permalink
fix(ui): properly animates height for dynamically rendered children
Browse files Browse the repository at this point in the history
  • Loading branch information
jacobsfletch committed Dec 2, 2024
1 parent 71ba4a8 commit 9d06a64
Show file tree
Hide file tree
Showing 2 changed files with 61 additions and 36 deletions.
11 changes: 8 additions & 3 deletions packages/ui/src/elements/AnimateHeight/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CSSStyleDeclaration['display']>(
Expand All @@ -24,6 +25,9 @@ export const AnimateHeight: React.FC<{

const [isAnimating, setIsAnimating] = React.useState(false)

const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)

useEffect(() => {
let displayTimer: number
let overflowTimer: number
Expand Down Expand Up @@ -65,10 +69,9 @@ export const AnimateHeight: React.FC<{
}
}, [height, duration])

const containerRef = useRef<HTMLDivElement>(null)

usePatchAnimateHeight({
containerRef,
contentRef,
duration,
open,
})
Expand All @@ -92,7 +95,9 @@ export const AnimateHeight: React.FC<{
transition: `height ${duration}ms ease`,
}}
>
<div {...(childrenDisplay ? { style: { display: childrenDisplay } } : {})}>{children}</div>
<div ref={contentRef} {...(childrenDisplay ? { style: { display: childrenDisplay } } : {})}>
{children}
</div>
</div>
)
}
86 changes: 53 additions & 33 deletions packages/ui/src/elements/AnimateHeight/usePatchAnimateHeight.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
'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<HTMLDivElement>
contentRef: React.RefObject<HTMLDivElement>
duration: number
open: boolean
}): { browserSupportsKeywordAnimation: boolean } => {
Expand All @@ -19,52 +22,69 @@ export const usePatchAnimateHeight = ({
)

const previousOpenState = useRef(open)
const [isAnimating, setIsAnimating] = useState(false)
const resizeObserverRef = useRef<null | ResizeObserver>(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

const animate = () => {
const maxContentHeight = getTotalHeight(container)

// Set initial state
if (previousOpenState.current !== open) {
container.style.height = open ? '0px' : `${maxContentHeight}px`
}
if (!container || !content || browserSupportsKeywordAnimation) {
return
}

// Trigger reflow
container.offsetHeight // eslint-disable-line @typescript-eslint/no-unused-expressions
let animationFrameId: number

// Start animation
container.style.transition = `height ${duration}ms ease`
container.style.height = open ? `${maxContentHeight}px` : '0px'
const updateHeight = () => {
if (isAnimating && container && content) {
container.style.height = open ? `${content.scrollHeight}px` : '0px'
}
}

const transitionEndHandler = () => {
container.style.transition = ''
container.style.height = !open ? '0px' : 'auto'
container.removeEventListener('transitionend', transitionEndHandler)
}
const animate = () => {
setIsAnimating(true)

container.addEventListener('transitionend', transitionEndHandler)
// Set initial state
if (previousOpenState.current !== open) {
container.style.height = open ? '0px' : `${content.scrollHeight}px`
}

animate()
// Trigger reflow
container.offsetHeight // eslint-disable-line @typescript-eslint/no-unused-expressions

previousOpenState.current = open
// Start animation
container.style.transition = `height ${duration}ms ease`
container.style.height = open ? `${content.scrollHeight}px` : '0px'

return () => {
const transitionEndHandler = () => {
container.style.transition = ''
container.style.height = ''
container.style.height = open ? 'auto' : '0px'
container.removeEventListener('transitionend', transitionEndHandler)
setIsAnimating(false)
}

container.addEventListener('transitionend', transitionEndHandler)
}

animate()
previousOpenState.current = open

// Setup ResizeObserver
resizeObserverRef.current = new ResizeObserver(() => {
if (isAnimating) {
animationFrameId = requestAnimationFrame(updateHeight)
}
})

resizeObserverRef.current.observe(content)

return () => {
container.style.transition = ''
container.style.height = ''
resizeObserverRef.current?.disconnect()
cancelAnimationFrame(animationFrameId)
}
}, [open, duration, containerRef, browserSupportsKeywordAnimation])
}, [open, duration, containerRef, contentRef, browserSupportsKeywordAnimation, isAnimating])

return { browserSupportsKeywordAnimation }
}

0 comments on commit 9d06a64

Please sign in to comment.