diff --git a/frontend/src/features/system-feedback/toast/base.tsx b/frontend/src/features/system-feedback/toast/base.tsx index 6c1542db6..2f1effd22 100644 --- a/frontend/src/features/system-feedback/toast/base.tsx +++ b/frontend/src/features/system-feedback/toast/base.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import * as Toast from "@radix-ui/react-toast"; import { XIcon } from "lucide-react"; import ProgressBar from "@/features/system-feedback/toast/progress-bar"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { cn } from "@/lib/utils/classname"; type BaseToastProps = { @@ -17,6 +18,7 @@ type BaseToastProps = { duration?: number; isPersistent?: boolean; isPaused: boolean; + stackIndex: number; }; export default function BaseToast({ @@ -30,6 +32,7 @@ export default function BaseToast({ duration = 5000, isPersistent = false, isPaused, + stackIndex, }: BaseToastProps) { // Whenever the viewport hover state changes, the toast provider rerenders // all toasts, which means that all toast durations will get reset. In order @@ -38,6 +41,19 @@ export default function BaseToast({ const startTime = useRef(0); const timerRef = useRef(null); + // ON MOBILE ONLY, when the toast is moved to any position but the top of the stack, the + // duration is reset to give each toast, when in the top position, its full duration + const isMobile = useCheckMobile(); + const [prevStackIndex, setPrevStackIndex] = useState(stackIndex); + const [resetCount, setResetCount] = useState(0); + useEffect(() => { + if (isMobile && stackIndex !== prevStackIndex) { + setPrevStackIndex(stackIndex); + remainingTime.current = duration; + setResetCount((prev) => prev + 1); + } + }, [isMobile, stackIndex, prevStackIndex, duration]); + useEffect(() => { if (isPersistent || !open) return; @@ -64,12 +80,34 @@ export default function BaseToast({ return ( {!isPersistent && ( )} -
{icon}
+
1 && "opacity-0", + )} + aria-hidden={isMobile && stackIndex > 0} + > +
+
{icon}
-
- - {title} - - - {message} - -
+
+ + {title} + + + {message} + +
+
- - - + + + +
); } diff --git a/frontend/src/features/system-feedback/toast/provider.tsx b/frontend/src/features/system-feedback/toast/provider.tsx index 7e15e9ea3..263a3ceaf 100644 --- a/frontend/src/features/system-feedback/toast/provider.tsx +++ b/frontend/src/features/system-feedback/toast/provider.tsx @@ -9,6 +9,7 @@ import { TOAST_CONFIG } from "@/features/system-feedback/toast/config"; import ToastContext from "@/features/system-feedback/toast/context"; import { ToastData, ToastOptions } from "@/features/system-feedback/toast/type"; import { ToastType } from "@/features/system-feedback/type"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { cn } from "@/lib/utils/classname"; export default function ToastProvider({ @@ -16,6 +17,8 @@ export default function ToastProvider({ }: { children: React.ReactNode; }) { + const isMobile = useCheckMobile(); + const [toasts, setToasts] = useState([]); const [isHoveringViewport, setIsHoveringViewport] = useState(false); @@ -88,21 +91,27 @@ export default function ToastProvider({ [], ); - const removeToast = useCallback((id: number) => { - if (id === -1) return; + const removeToast = useCallback( + (id: number) => { + if (id === -1) return; - setToasts((prevToasts) => - prevToasts.map((t) => (t.id === id ? { ...t, open: false } : t)), - ); + setToasts((prevToasts) => + prevToasts.map((t) => (t.id === id ? { ...t, open: false } : t)), + ); - setTimeout(() => { - setToasts((prevToasts) => prevToasts.filter((t) => t.id !== id)); - }, 400); - }, []); + setTimeout( + () => { + setToasts((prevToasts) => prevToasts.filter((t) => t.id !== id)); + }, + isMobile ? 100 : 250, + ); + }, + [isMobile], + ); return ( - + {children} setIsHoveringViewport(false)} style={{ // adjust the position of the toast viewport based on keyboard height - transform: `translateY(-${keyboardHeight}px)`, + transform: isMobile + ? undefined + : `translateY(-${keyboardHeight}px)`, transition: "transform 0.2s ease-out", }} className={cn( - "fixed bottom-12 right-0 z-[2147483647] md:bottom-0", - keyboardHeight > 0 && "bottom-0", - "flex list-none flex-col items-end outline-none", - "m-0 space-y-1 pb-[var(--viewport-padding)] pr-[var(--viewport-padding)] [--viewport-padding:_25px]", + "fixed z-[2147483647]", + "m-0 list-none outline-none [--viewport-padding:_25px]", + isMobile + ? cn( + "left-0 right-0 top-0", + "px-[var(--viewport-padding)] pt-[var(--viewport-padding)]", + ) + : cn( + "bottom-0 right-0", + "flex flex-col items-end", + "space-y-1 pb-[var(--viewport-padding)] pr-[var(--viewport-padding)]", + ), )} > - {toasts.map((toast) => { + {toasts.map((toast, index) => { const config = TOAST_CONFIG[toast.type] || TOAST_CONFIG.info; const Icon = config.icon; + const stackIndex = toasts.length - index - 1; return ( } isPersistent={toast.isPersistent} duration={toast.duration} - isPaused={isHoveringViewport} + isPaused={isHoveringViewport || (isMobile && stackIndex > 0)} onOpenChange={(isOpen) => { if (!isOpen) { if (toast.onDismiss) { @@ -144,6 +164,7 @@ export default function ToastProvider({ removeToast(toast.id); } }} + stackIndex={stackIndex} /> ); })} diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css index 303278c98..973c8a9aa 100644 --- a/frontend/src/styles/tailwind.css +++ b/frontend/src/styles/tailwind.css @@ -122,63 +122,6 @@ --animate-slideLeftAndFade: slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1); - --animate-slideUpAndFadeOut: slideUpAndFadeOut 400ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-slideRightAndFadeOut: slideRightAndFadeOut 400ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-slideDownAndFadeOut: slideDownAndFadeOut 400ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-slideLeftAndFadeOut: slideLeftAndFadeOut 400ms - cubic-bezier(0.16, 1, 0.3, 1); - - --animate-slideUp: slideUp 400ms cubic-bezier(0.16, 1, 0.3, 1); - --animate-slideDown: slideDown 400ms cubic-bezier(0.16, 1, 0.3, 1); - --animate-hide: hide 100ms ease-in; - --animate-slideIn: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1); - --animate-slideOut: slideOut 400ms cubic-bezier(0.16, 1, 0.3, 1); - --animate-swipeOut: swipeOut 100ms ease-out; - - @keyframes hide { - 0% { - opacity: 1; - } - - 100% { - opacity: 0; - } - } - - @keyframes slideIn { - 0% { - transform: translateX(calc(100% + var(--viewport-padding))); - } - - 100% { - transform: translateX(0); - } - } - - @keyframes slideOut { - 0% { - opacity: 1; - transform: translateX(0); - } - 100% { - opacity: 0; - transform: translateX(calc(100% + var(--viewport-padding))); - } - } - - @keyframes swipeOut { - 0% { - transform: translateX(var(--radix-toast-swipe-end-x)); - } - - 100% { - transform: translateX(calc(100% + var(--viewport-padding))); - } - } - @keyframes slideUpAndFade { 0% { opacity: 0; @@ -227,6 +170,15 @@ } } + --animate-slideUpAndFadeOut: slideUpAndFadeOut 400ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-slideRightAndFadeOut: slideRightAndFadeOut 400ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-slideDownAndFadeOut: slideDownAndFadeOut 400ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-slideLeftAndFadeOut: slideLeftAndFadeOut 400ms + cubic-bezier(0.16, 1, 0.3, 1); + @keyframes slideUpAndFadeOut { 0% { opacity: 1; @@ -275,23 +227,81 @@ } } - @keyframes slideUp { + --animate-toastSlideInLeft: toastSlideInLeft 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-toastSlideOutRight: toastSlideOutRight 400ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-toastSwipeOutRight: toastSwipeOutRight 100ms ease-out; + + @keyframes toastSlideInLeft { 0% { - transform: translateY(100%); + transform: translateX(calc(100% + var(--viewport-padding))); + } + + 100% { + transform: translateX(0); } + } + @keyframes toastSlideOutRight { + 0% { + opacity: 1; + transform: translateX(0); + } 100% { + opacity: 0; + transform: translateX(calc(100% + var(--viewport-padding))); + } + } + + @keyframes toastSwipeOutRight { + 0% { + transform: translateX(var(--radix-toast-swipe-end-x)); + } + + 100% { + transform: translateX(calc(100% + var(--viewport-padding))); + } + } + + --animate-toastSlideInDown: toastSlideInDown 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-toastSlideOutUp: toastSlideOutUp 400ms cubic-bezier(0.16, 1, 0.3, 1); + --animate-toastSwipeOutUp: toastSwipeOutUp 100ms ease-out; + + @keyframes toastSlideInDown { + 0% { + opacity: 0; + transform: translateY(calc(-100% - var(--viewport-padding))); + } + + 100% { + opacity: 1; transform: translateY(0); } } - @keyframes slideDown { + @keyframes toastSlideOutUp { 0% { + opacity: 1; transform: translateY(0); } 100% { - transform: translateY(100%); + opacity: 0; + transform: translateY(calc(-100% - var(--viewport-padding))); + } + } + + @keyframes toastSwipeOutUp { + 0% { + opacity: 1; + transform: translateY(var(--radix-toast-swipe-end-y)); + } + + 100% { + opacity: 0; + transform: translateY(calc(-100% - var(--viewport-padding))); } }