| ... | @@ -1,10 +1,25 @@ | ... | @@ -1,10 +1,25 @@ |
| 1 | "use client"; | 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 | export function AnimatedHeight({ children }: { readonly children: ReactNode }) { | 7 | export function AnimatedHeight({ children }: { readonly children: ReactNode }) { |
| 6 | const contentRef = useRef<HTMLDivElement>(null); | 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 | useLayoutEffect(() => { | 24 | useLayoutEffect(() => { |
| 10 | const element = contentRef.current; | 25 | const element = contentRef.current; |
| ... | @@ -14,7 +29,13 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { | ... | @@ -14,7 +29,13 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { |
| 14 | | 29 | |
| 15 | const updateHeight = () => { | 30 | const updateHeight = () => { |
| 16 | const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); | 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 | const cancelPendingFrames = () => { | 40 | const cancelPendingFrames = () => { |
| 20 | if (firstFrameId !== null) { | 41 | if (firstFrameId !== null) { |
| ... | @@ -50,8 +71,19 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { | ... | @@ -50,8 +71,19 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) { |
| 50 | | 71 | |
| 51 | return ( | 72 | return ( |
| 52 | <div | 73 | <div |
| 53 | className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none" | 74 | data-slot="animated-height" |
| 54 | style={height === null ? undefined : { 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 | <div ref={contentRef}>{children}</div> | 88 | <div ref={contentRef}>{children}</div> |
| 57 | </div> | 89 | </div> |