refactor(toast): animation & toast region (#5647)

* fix(toast): unexpected gap after closing a toast

* refactor(toast): animation

* fix(toast): close toast when disableAnimation is set to true

* refactor: coderabbit comment
This commit is contained in:
WK 2025-08-30 21:18:15 +08:00 committed by GitHub
parent afe2f977fc
commit e0dc33f3b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 60 additions and 43 deletions

View File

@ -0,0 +1,5 @@
---
"@heroui/toast": patch
---
refactor: toast

View File

@ -6,7 +6,7 @@ export type {ToastProps} from "./toast";
// export hooks
export {useToast} from "./use-toast";
export {addToast, closeAll, closeToast, getToastQueue} from "./toast-provider";
export {addToast, closeAll, closeToast, getToastQueue, isToastClosing} from "./toast-provider";
// export component
export {Toast};

View File

@ -68,11 +68,23 @@ export const addToast = ({...props}: ToastProps & ToastOptions) => {
return globalToastQueue.add(props);
};
const closingToasts = new Map<string, ReturnType<typeof setTimeout>>();
export const closeToast = (key: string) => {
if (!globalToastQueue) {
return;
}
globalToastQueue.close(key);
if (closingToasts.has(key)) {
return;
}
const timeoutId = setTimeout(() => {
closingToasts.delete(key);
globalToastQueue?.close(key);
}, 300);
closingToasts.set(key, timeoutId);
};
export const closeAll = () => {
@ -80,11 +92,11 @@ export const closeAll = () => {
return;
}
const keys = globalToastQueue.visibleToasts.map((toast) => toast.key);
const toasts = [...globalToastQueue.visibleToasts];
keys.forEach((key, index) => {
setTimeout(() => {
globalToastQueue?.close(key);
}, index * 100);
toasts.forEach((toast) => {
closeToast(toast.key);
});
};
export const isToastClosing = (key: string) => closingToasts.has(key);

View File

@ -11,6 +11,7 @@ import {clsx, mergeProps} from "@heroui/shared-utils";
import {AnimatePresence} from "framer-motion";
import Toast from "./toast";
import {isToastClosing} from "./toast-provider";
export interface RegionProps {
className?: string;
@ -93,12 +94,14 @@ export function ToastRegion<T extends ToastProps>({
total - index <= 4 ||
(isHovered && total - index <= maxVisibleToasts + 1)
) {
const isClosing = isToastClosing(toast.key);
return (
<Toast
key={toast.key}
state={toastQueue}
toast={toast}
{...mergeProps(toastProps, toast.content)}
{...mergeProps(toastProps, toast.content, {isClosing})}
disableAnimation={disableAnimation}
heights={heights}
index={index}

View File

@ -109,23 +109,7 @@ const Toast = forwardRef<"div", ToastProps>((props, ref) => {
);
return (
<>
{disableAnimation ? (
toastContent
) : (
<m.div {...getMotionDivProps()}>
<m.div
key={"inner-div"}
animate={{opacity: 1}}
exit={{opacity: 0}}
initial={{opacity: 0}}
transition={{duration: 0.25, ease: "easeOut", delay: 0.1}}
>
{toastContent}
</m.div>
</m.div>
)}
</>
<>{disableAnimation ? toastContent : <m.div {...getMotionDivProps()}>{toastContent}</m.div>}</>
);
});

View File

@ -110,6 +110,10 @@ export interface ToastProps extends ToastVariantProps {
* @default "default"
*/
severity?: "default" | "primary" | "secondary" | "success" | "warning" | "danger";
/**
* Whether the toast is being closed programmatically
*/
isClosing?: boolean;
}
interface Props<T> extends Omit<HTMLHeroUIProps<"div">, "title">, ToastProps {
@ -165,6 +169,7 @@ export function useToast<T extends ToastProps>(originalProps: UseToastProps<T>)
severity,
maxVisibleToasts,
loadingComponent,
isClosing = false,
...otherProps
} = props;
@ -210,6 +215,18 @@ export function useToast<T extends ToastProps>(originalProps: UseToastProps<T>)
});
}, [promiseProp]);
useEffect(() => {
if (isClosing && !isToastExiting) {
setIsToastExiting(true);
}
}, [isClosing, isToastExiting]);
useEffect(() => {
if (isToastExiting && disableAnimation) {
state.close(toast.key);
}
}, [isToastExiting, disableAnimation, state, toast.key]);
useEffect(() => {
const updateProgress = (timestamp: number) => {
if (!timeout || isLoading) {
@ -290,6 +307,7 @@ export function useToast<T extends ToastProps>(originalProps: UseToastProps<T>)
if (!domRef.current || !mounted || isToastExiting) {
return;
}
const toastNode = domRef.current;
const originalHeight = toastNode.style.height;
@ -315,7 +333,7 @@ export function useToast<T extends ToastProps>(originalProps: UseToastProps<T>)
let liftHeight = 4;
for (let idx = index + 1; idx < total; idx++) {
liftHeight += heights[idx];
liftHeight += heights[idx] || 0;
}
const frontHeight = heights[heights.length - 1];
@ -413,16 +431,12 @@ export function useToast<T extends ToastProps>(originalProps: UseToastProps<T>)
"data-toast": true,
"aria-label": "toast",
"data-toast-exiting": dataAttr(isToastExiting),
onTransitionEnd: () => {
if (isToastExiting) {
const updatedHeights = heights;
updatedHeights.splice(index, 1);
setHeights([...updatedHeights]);
state.close(toast.key);
}
},
onTransitionEnd: disableAnimation
? undefined
: () => {
if (!isToastExiting) return;
state.close(toast.key);
},
style: {
opacity: opacityValue,
...pseudoElementStyles,
@ -441,6 +455,7 @@ export function useToast<T extends ToastProps>(originalProps: UseToastProps<T>)
isToastExiting,
state,
toast.key,
disableAnimation,
],
);
@ -580,7 +595,10 @@ export function useToast<T extends ToastProps>(originalProps: UseToastProps<T>)
},
drag: dragDirection,
dragConstraints,
exit: {opacity: 0},
exit: {
opacity: 0,
transition: {duration: 0.3},
},
initial: {opacity: 0, scale: 1, y: -40 * multiplier},
transition: {duration: 0.3, ease: "easeOut"},
variants: toastVariants,
@ -591,12 +609,7 @@ export function useToast<T extends ToastProps>(originalProps: UseToastProps<T>)
setDrag(false);
if (shouldCloseToast(offsetX, offsetY)) {
const updatedHeights = heights;
updatedHeights.splice(index, 1);
setHeights([...updatedHeights]);
state.close(toast.key);
setIsToastExiting(true);
return;
}