sel-chat-coach / src /hooks /useCoachModalAnimation.ts
james-d-taboola's picture
refactor: extract coach modal animation into reusable components
403a2e1
import { useState, useRef, useEffect, RefObject } from 'react';
interface AnimationTarget {
x: number;
y: number;
}
interface UseCoachModalAnimationReturn {
// State
isClosingModal: boolean;
animationTarget: AnimationTarget;
tailTarget: AnimationTarget | null;
// Refs to attach to elements
coachBubbleRef: RefObject<HTMLDivElement | null>;
coachBubbleContentRef: RefObject<HTMLDivElement | null>;
// Handlers
handleCloseModal: () => void;
// Animation class names
getOverlayClassName: (baseClass: string) => string;
getBubbleClassName: (baseClass: string) => string;
getBubbleStyle: () => React.CSSProperties;
}
const ANIMATION_DURATION = 400;
const ESTIMATED_BUBBLE_HEIGHT = 280;
export function useCoachModalAnimation(
showModal: boolean,
setShowModal: (show: boolean) => void,
triggerRef: RefObject<HTMLButtonElement | null>
): UseCoachModalAnimationReturn {
const [isClosingModal, setIsClosingModal] = useState(false);
const [animationTarget, setAnimationTarget] = useState<AnimationTarget>({ x: 0, y: 0 });
const [tailTarget, setTailTarget] = useState<AnimationTarget | null>(null);
const coachBubbleRef = useRef<HTMLDivElement>(null);
const coachBubbleContentRef = useRef<HTMLDivElement>(null);
// Calculate animation targets when modal opens
useEffect(() => {
if (showModal && !isClosingModal) {
// Calculate animation target
const buttonRect = triggerRef.current?.getBoundingClientRect();
const viewportCenterX = window.innerWidth / 2;
const viewportCenterY = window.innerHeight / 2;
if (buttonRect) {
const buttonCenterX = buttonRect.left + buttonRect.width / 2;
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
setAnimationTarget({
x: buttonCenterX - viewportCenterX,
y: buttonCenterY - viewportCenterY,
});
// Calculate tail target
const estimatedBubbleWidth = Math.min(384, window.innerWidth - 32);
const bubbleLeft = viewportCenterX - estimatedBubbleWidth / 2;
const bubbleBottom = viewportCenterY + ESTIMATED_BUBBLE_HEIGHT / 2;
const buttonTargetX = buttonRect.left + buttonRect.width * 0.5;
const buttonTopY = buttonRect.top;
setTailTarget({
x: buttonTargetX - bubbleLeft,
y: buttonTopY - bubbleBottom,
});
}
} else if (!showModal) {
setTailTarget(null);
}
}, [showModal, isClosingModal, triggerRef]);
const handleCloseModal = () => {
// Recalculate animation target for closing (bubble position may have changed)
const buttonRect = triggerRef.current?.getBoundingClientRect();
const bubbleRect = coachBubbleRef.current?.getBoundingClientRect();
if (buttonRect && bubbleRect) {
const buttonCenterX = buttonRect.left + buttonRect.width / 2;
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
const bubbleCenterX = bubbleRect.left + bubbleRect.width / 2;
const bubbleCenterY = bubbleRect.top + bubbleRect.height / 2;
const targetX = buttonCenterX - bubbleCenterX;
const targetY = buttonCenterY - bubbleCenterY;
// Set CSS variables directly on element for immediate effect (before React re-renders)
if (coachBubbleRef.current) {
coachBubbleRef.current.style.setProperty('--translate-x', `${targetX}px`);
coachBubbleRef.current.style.setProperty('--translate-y', `${targetY}px`);
}
// Also update state to prevent React from overwriting on re-render
setAnimationTarget({ x: targetX, y: targetY });
}
setIsClosingModal(true);
// Wait for animation to complete before actually closing
setTimeout(() => {
setShowModal(false);
setIsClosingModal(false);
}, ANIMATION_DURATION);
};
const getOverlayClassName = (baseClass: string): string => {
return `${baseClass} ${isClosingModal ? 'animate-fade-out' : 'animate-fade-in'}`;
};
const getBubbleClassName = (baseClass: string): string => {
return `${baseClass} ${isClosingModal ? 'animate-shrink-to-coach-button' : 'animate-expand-from-coach-button'}`;
};
const getBubbleStyle = (): React.CSSProperties => {
return {
'--translate-x': `${animationTarget.x}px`,
'--translate-y': `${animationTarget.y}px`,
} as React.CSSProperties;
};
return {
isClosingModal,
animationTarget,
tailTarget,
coachBubbleRef,
coachBubbleContentRef,
handleCloseModal,
getOverlayClassName,
getBubbleClassName,
getBubbleStyle,
};
}