import React, { useEffect, useId, useRef } from "react"; import ReactDOM from "react-dom"; import { VariantProps, vcn } from "@pswui-lib/shared@1.0.0"; interface ToastOption { closeButton: boolean; closeTimeout: number | null; } const defaultToastOption: ToastOption = { closeButton: true, closeTimeout: 3000, }; const toastColors = { background: "bg-white dark:bg-black", borders: { default: "border-black/10 dark:border-white/20", error: "border-red-500/80", success: "border-green-500/80", warning: "border-yellow-500/80", loading: "border-black/50 dark:border-white/50 animate-pulse", }, }; const [toastVariant] = vcn({ base: `flex flex-col gap-2 border p-4 rounded-lg pr-8 pointer-events-auto ${toastColors.background} relative transition-all duration-150`, variants: { status: { default: toastColors.borders.default, error: toastColors.borders.error, success: toastColors.borders.success, warning: toastColors.borders.warning, loading: toastColors.borders.loading, }, life: { born: "-translate-y-full md:translate-y-full scale-90 ease-[cubic-bezier(0,.6,.7,1)]", normal: "translate-y-0 scale-100 ease-[cubic-bezier(0,.6,.7,1)]", dead: "-translate-y-full md:translate-y-full scale-90 ease-[cubic-bezier(.6,0,1,.7)]", }, }, defaults: { status: "default", life: "born", }, }); interface ToastBody extends Omit<VariantProps<typeof toastVariant>, "preset"> { title: string; description: string; } let index = 0; let toasts: Record< `${number}`, ToastBody & Partial<ToastOption> & { subscribers: (() => void)[] } > = {}; let subscribers: (() => void)[] = []; /** * ==== * Controls * ==== */ function subscribe(callback: () => void) { subscribers.push(callback); return () => { subscribers = subscribers.filter((subscriber) => subscriber !== callback); }; } function getSnapshot() { return { ...toasts }; } function subscribeSingle(id: `${number}`) { return (callback: () => void) => { toasts[id].subscribers.push(callback); return () => { toasts[id].subscribers = toasts[id].subscribers.filter( (subscriber) => subscriber !== callback, ); }; }; } function getSingleSnapshot(id: `${number}`) { return () => { return { ...toasts[id], }; }; } function notify() { subscribers.forEach((subscriber) => subscriber()); } function notifySingle(id: `${number}`) { toasts[id].subscribers.forEach((subscriber) => subscriber()); } function close(id: `${number}`) { toasts[id] = { ...toasts[id], life: "dead", }; notifySingle(id); } function update( id: `${number}`, toast: Partial<Omit<ToastBody, "life"> & Partial<ToastOption>>, ) { toasts[id] = { ...toasts[id], ...toast, }; notifySingle(id); } function addToast(toast: Omit<ToastBody, "life"> & Partial<ToastOption>) { const id: `${number}` = `${index}`; toasts[id] = { ...toast, subscribers: [], life: "born", }; index += 1; notify(); return { update: (toast: Partial<Omit<ToastBody, "life"> & Partial<ToastOption>>) => update(id, toast), close: () => close(id), }; } function useToast() { return { toast: addToast, update, close, }; } const ToastTemplate = ({ id, globalOption, }: { id: `${number}`; globalOption: ToastOption; }) => { const [toast, setToast] = React.useState<(typeof toasts)[`${number}`]>( toasts[id], ); const ref = React.useRef<HTMLDivElement | null>(null); useEffect(() => { subscribeSingle(id)(() => { setToast(getSingleSnapshot(id)()); }); }, []); const toastData = { ...globalOption, ...toast, }; React.useEffect(() => { if (toastData.life === "born") { requestAnimationFrame(() => { // To make sure that the toast is rendered as "born" state // and then change to "normal" state toasts[id] = { ...toasts[id], life: "normal", }; notifySingle(id); }); } if (toastData.life === "normal" && toastData.closeTimeout !== null) { const timeout = setTimeout(() => { close(id); }, toastData.closeTimeout); return () => clearTimeout(timeout); } if (toastData.life === "dead") { let transitionDuration: { value: number; unit: string; } | null; if (!ref.current) { transitionDuration = null; } else if (ref.current.computedStyleMap !== undefined) { transitionDuration = ref.current .computedStyleMap() .get("transition-duration") as { value: number; unit: string }; } else { const style = /(\d+(\.\d+)?)(.+)/.exec( window.getComputedStyle(ref.current).transitionDuration, ); transitionDuration = style ? { value: parseFloat(style[1] ?? "0"), unit: style[3] ?? style[2] ?? "s", } : null; } if (!transitionDuration) { delete toasts[id]; notify(); return; } const calculatedTransitionDuration = transitionDuration.value * ({ s: 1000, ms: 1, }[transitionDuration.unit] ?? 1); const timeout = setTimeout(() => { delete toasts[id]; notify(); }, calculatedTransitionDuration); return () => clearTimeout(timeout); } }, [toastData.life, toastData.closeTimeout, toastData.closeButton]); return ( <div className={toastVariant({ status: toastData.status, life: toastData.life, })} ref={ref} > {toastData.closeButton && ( <button className="absolute top-2 right-2" onClick={() => close(id)}> <svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" height="1.2rem" viewBox="0 0 24 24" > <path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" /> </svg> </button> )} <div className="text-sm font-bold">{toastData.title}</div> <div className="text-sm">{toastData.description}</div> </div> ); }; const [toasterVariant, resolveToasterVariantProps] = vcn({ base: "fixed p-4 flex flex-col gap-4 top-0 right-0 w-full md:max-w-md md:bottom-0 md:top-auto pointer-events-none z-40", variants: {}, defaults: {}, }); interface ToasterProps extends React.ComponentPropsWithoutRef<"div">, VariantProps<typeof toasterVariant> { defaultOption?: Partial<ToastOption>; muteDuplicationWarning?: boolean; } const Toaster = React.forwardRef<HTMLDivElement, ToasterProps>((props, ref) => { const id = useId(); const [variantProps, otherPropsCompressed] = resolveToasterVariantProps(props); const { defaultOption, muteDuplicationWarning, ...otherPropsExtracted } = otherPropsCompressed; const [toastList, setToastList] = React.useState<typeof toasts>(toasts); const internalRef = useRef<HTMLDivElement | null>(null); useEffect(() => { const unsubscribe = subscribe(() => { setToastList(getSnapshot()); }); return unsubscribe; }, []); const option = React.useMemo(() => { return { ...defaultToastOption, ...defaultOption, }; }, [defaultOption]); const toasterInstance = document.querySelector("div[data-toaster-root]"); if (toasterInstance && id !== toasterInstance.id) { if (process.env.NODE_ENV === "development" && !muteDuplicationWarning) { console.warn( `Multiple Toaster instances detected. Only one Toaster is allowed.`, ); } return null; } return ( <> {ReactDOM.createPortal( <div {...otherPropsExtracted} data-toaster-root className={toasterVariant(variantProps)} ref={(el) => { internalRef.current = el; if (typeof ref === "function") { ref(el); } else if (ref) { ref.current = el; } }} id={id} > {Object.entries(toastList).map(([id]) => ( <ToastTemplate key={id} id={id as `${number}`} globalOption={option} /> ))} </div>, document.body, )} </> ); }); export { Toaster, useToast };