@redocly/theme
Version:
Shared UI components lib
204 lines (164 loc) • 5.4 kB
text/typescript
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,
};
}