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
…9665)

Fixes #9567. When using the
`AnimateHeight` component on a patched browser such as Webkit,
components with dynamically rendered children are not properly animating
in, such as blocks with rich text. This is because the height of that
content is unable to be calculated before it's rendered, preventing the
component from acquiring a target height to animate toward. This change
was originally introduced in #9456 in effort to remove unnecessary
dependencies.

The fix is to setup a ResizeObserver during animation to watch for
changes to the content's height. This way, as components dynamically
render in based on the "open" state, the hook will simply increment the
target height accordingly.
  • Loading branch information
jacobsfletch authored Dec 2, 2024
1 parent 24c75b0 commit d04cea1
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 35 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>
)
}
91 changes: 59 additions & 32 deletions packages/ui/src/elements/AnimateHeight/usePatchAnimateHeight.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>
contentRef: React.RefObject<HTMLDivElement>
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 | 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

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 }
}

0 comments on commit d04cea1

Please sign in to comment.