| ... | ... | @@ -1,10 +1,25 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | | import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; |
| 3 | import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 4 | |
| 5 | const HEIGHT_TRANSITION_FALLBACK_MS = 250; |
| 4 | 6 | |
| 5 | 7 | export function AnimatedHeight({ children }: { readonly children: ReactNode }) { |
| 6 | 8 | const contentRef = useRef<HTMLDivElement>(null); |
| 7 | | const [height, setHeight] = useState<number | null>(null); |
| 9 | const [heightState, setHeightState] = useState<{ |
| 10 | readonly height: number | null; |
| 11 | readonly isClipping: boolean; |
| 12 | }>({ height: null, isClipping: false }); |
| 13 | |
| 14 | useEffect(() => { |
| 15 | if (!heightState.isClipping) return; |
| 16 | const timeoutId = window.setTimeout(() => { |
| 17 | setHeightState((currentState) => |
| 18 | currentState.isClipping ? { ...currentState, isClipping: false } : currentState, |
| 19 | ); |
| 20 | }, HEIGHT_TRANSITION_FALLBACK_MS); |
| 21 | return () => window.clearTimeout(timeoutId); |
| 22 | }, [heightState.height, heightState.isClipping]); |
| 8 | 23 | |
| 9 | 24 | useLayoutEffect(() => { |
| 10 | 25 | const element = contentRef.current; |
| ... | ... | @@ -14,7 +29,13 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { |
| 14 | 29 | |
| 15 | 30 | const updateHeight = () => { |
| 16 | 31 | const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); |
| 17 | | setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); |
| 32 | setHeightState((currentState) => { |
| 33 | if (currentState.height === nextHeight) return currentState; |
| 34 | return { |
| 35 | height: nextHeight, |
| 36 | isClipping: currentState.height !== null, |
| 37 | }; |
| 38 | }); |
| 18 | 39 | }; |
| 19 | 40 | const cancelPendingFrames = () => { |
| 20 | 41 | if (firstFrameId !== null) { |
| ... | ... | @@ -50,8 +71,19 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { |
| 50 | 71 | |
| 51 | 72 | return ( |
| 52 | 73 | <div |
| 53 | | className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none" |
| 54 | | style={height === null ? undefined : { height }} |
| 74 | data-slot="animated-height" |
| 75 | className="transition-[height] duration-200 ease-out motion-reduce:transition-none" |
| 76 | style={ |
| 77 | heightState.height === null |
| 78 | ? undefined |
| 79 | : { height: heightState.height, overflow: heightState.isClipping ? "hidden" : "visible" } |
| 80 | } |
| 81 | onTransitionEnd={(event) => { |
| 82 | if (event.target !== event.currentTarget || event.propertyName !== "height") return; |
| 83 | setHeightState((currentState) => |
| 84 | currentState.isClipping ? { ...currentState, isClipping: false } : currentState, |
| 85 | ); |
| 86 | }} |
| 55 | 87 | > |
| 56 | 88 | <div ref={contentRef}>{children}</div> |
| 57 | 89 | </div> |