UNPKG

@redocly/theme

Version:

Shared UI components lib

204 lines (164 loc) 5.4 kB
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import type { RefObject } from 'react'; import type { ToastItem } from '../types/toast'; import { getAutoDismissDuration } from '../utils'; const STACK_SHIFT_ENTER_DURATION_MS = 280; const STACK_SHIFT_EXIT_DURATION_MS = 200; interface UseToastLogicOptions { toast: ToastItem; onDismiss: (id: string) => void; stackIndex: number; } interface UseToastLogicReturn { wrapperRef: RefObject<HTMLDivElement | null>; hasDetails: boolean; dismissToast: () => void; handleMouseEnter: () => void; handleMouseLeave: () => void; ariaRole: 'alert' | 'status'; ariaLive: 'assertive' | 'polite'; } export function useToastLogic({ toast, onDismiss, stackIndex, }: UseToastLogicOptions): UseToastLogicReturn { const wrapperRef = useRef<HTMLDivElement | null>(null); const previousTopRef = useRef<number | null>(null); const previousStackIndexRef = useRef<number | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const startedAtRef = useRef<number | null>(null); const remainingTimeRef = useRef<number>(0); const animationFrameRef = useRef<number | null>(null); const isHoveredRef = useRef<boolean>(false); const hasDetails = Boolean(toast.description); const resolvedDuration = useMemo( () => getAutoDismissDuration(hasDetails, toast.type, toast.duration), [hasDetails, toast.duration, toast.type], ); const ariaRole = toast.type === 'error' ? 'alert' : 'status'; const ariaLive = toast.type === 'error' ? 'assertive' : 'polite'; const clearTimer = useCallback((): void => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } startedAtRef.current = null; }, []); const dismissToast = useCallback((): void => { onDismiss(toast.id); }, [onDismiss, toast.id]); const startTimer = useCallback( (delay: number): void => { clearTimer(); if (delay <= 0) { dismissToast(); return; } remainingTimeRef.current = delay; startedAtRef.current = Date.now(); timerRef.current = setTimeout(() => { dismissToast(); }, delay); }, [clearTimer, dismissToast], ); useEffect(() => { if (toast.isExiting || resolvedDuration === null) { clearTimer(); return undefined; } if (!isHoveredRef.current) { startTimer(resolvedDuration); } return () => { clearTimer(); }; }, [ clearTimer, resolvedDuration, startTimer, toast.description, toast.isExiting, toast.title, toast.type, ]); useLayoutEffect(() => { const node = wrapperRef.current; if (!node) { return undefined; } node.style.transition = 'none'; node.style.transform = ''; const currentTop = node.getBoundingClientRect().top; const previousTop = previousTopRef.current; const previousStackIndex = previousStackIndexRef.current; if (toast.isExiting) { previousTopRef.current = currentTop; previousStackIndexRef.current = stackIndex; return () => { if (animationFrameRef.current !== null) { window.cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }; } const didStackIndexChange = previousStackIndex !== null && previousStackIndex !== stackIndex; if (didStackIndexChange && previousTop !== null && previousTop !== currentTop) { const delta = previousTop - currentTop; const shiftDuration = delta > 0 ? STACK_SHIFT_ENTER_DURATION_MS : STACK_SHIFT_EXIT_DURATION_MS; node.style.transform = `translateY(${delta}px)`; node.getBoundingClientRect(); animationFrameRef.current = window.requestAnimationFrame(() => { animationFrameRef.current = null; if (!wrapperRef.current) { return; } wrapperRef.current.style.transition = `transform ${shiftDuration}ms ease-out`; wrapperRef.current.style.transform = 'translateY(0)'; }); } previousTopRef.current = currentTop; previousStackIndexRef.current = stackIndex; return () => { if (animationFrameRef.current !== null) { window.cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }; }); useEffect(() => { return () => { clearTimer(); if (animationFrameRef.current !== null) { window.cancelAnimationFrame(animationFrameRef.current); } }; }, [clearTimer]); const handleMouseEnter = useCallback((): void => { isHoveredRef.current = true; if (resolvedDuration === null || startedAtRef.current === null) { return; } const elapsed = Date.now() - startedAtRef.current; remainingTimeRef.current = Math.max(remainingTimeRef.current - elapsed, 0); clearTimer(); }, [clearTimer, resolvedDuration]); const handleMouseLeave = useCallback((): void => { isHoveredRef.current = false; if (toast.isExiting || resolvedDuration === null) { return; } startTimer(remainingTimeRef.current || resolvedDuration); }, [resolvedDuration, startTimer, toast.isExiting]); return { wrapperRef, hasDetails, dismissToast, handleMouseEnter, handleMouseLeave, ariaRole, ariaLive, }; }