agent-ecosystem/src/renderer/components/team/activity/AnimatedHeightReveal.tsx
iliya 47dac2e8b5 feat: migrate to React 19
Upgrade React 18.3.1 → 19.2.4 with full type compatibility.

Changes:
- react, react-dom → ^19.0.0
- @types/react, @types/react-dom → ^19.0.0
- lucide-react → ^0.577.0 (React 19 type fixes)
- @tiptap/* → ^3.20.4 (React 19 support)
- useRef calls now require explicit initial value (undefined)
- RefObject types updated for React 19 (includes null)
- MutableRefObject → RefObject (deprecated in 19)
- act() import moved from react-dom/test-utils to react
- Scoped JSX namespace imports added where needed
2026-03-24 17:11:55 +02:00

117 lines
3.5 KiB
TypeScript

import { type JSX, useCallback, useEffect, useRef, useState } from 'react';
import type { CSSProperties, MutableRefObject, PropsWithChildren, Ref } from 'react';
export const ENTRY_REVEAL_ANIMATION_MS = 700;
export const ENTRY_REVEAL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
interface AnimatedHeightRevealProps extends PropsWithChildren {
animate?: boolean;
className?: string;
style?: CSSProperties;
containerRef?: Ref<HTMLDivElement>;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
return;
}
const mutableRef = ref as MutableRefObject<T | null>;
mutableRef.current = value;
}
export const AnimatedHeightReveal = ({
animate,
className,
style,
containerRef,
children,
}: AnimatedHeightRevealProps): JSX.Element => {
const [shouldAnimateOnMount] = useState(() => Boolean(animate));
const wrapperRef = useRef<HTMLDivElement | null>(null);
const animationFrameRef = useRef<number | null>(null);
const [prefersReducedMotion] = useState(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
const [isExpanded, setIsExpanded] = useState(
() => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
// Overflow must stay hidden during the height transition so the grid clip
// actually works. Switch to visible only after the animation completes.
const [overflowVisible, setOverflowVisible] = useState(
() => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
const setWrapperRef = useCallback(
(node: HTMLDivElement | null) => {
wrapperRef.current = node;
assignRef(containerRef, node);
},
[containerRef]
);
const clearPendingAnimation = useCallback(() => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
}, []);
useEffect(() => {
if (!shouldAnimateOnMount || prefersReducedMotion) {
return;
}
animationFrameRef.current = requestAnimationFrame(() => {
animationFrameRef.current = requestAnimationFrame(() => {
setIsExpanded(true);
animationFrameRef.current = null;
});
});
// Switch overflow to visible after the height transition finishes
// so popovers/tooltips inside can render outside bounds.
const overflowTimer = setTimeout(() => {
setOverflowVisible(true);
}, ENTRY_REVEAL_ANIMATION_MS + 50);
return () => {
clearPendingAnimation();
clearTimeout(overflowTimer);
};
}, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]);
useEffect(
() => () => {
clearPendingAnimation();
},
[clearPendingAnimation]
);
const shouldTransition = shouldAnimateOnMount && !prefersReducedMotion && isExpanded;
return (
<div
ref={setWrapperRef}
className={className}
style={{
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
opacity: isExpanded ? 1 : 0,
transition: shouldTransition
? [
`grid-template-rows ${ENTRY_REVEAL_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`,
`opacity ${ENTRY_REVEAL_ANIMATION_MS}ms ease`,
].join(', ')
: undefined,
...style,
}}
>
<div style={{ minHeight: 0, minWidth: 0, overflow: overflowVisible ? 'visible' : 'hidden' }}>
{children}
</div>
</div>
);
};