205 lines
5.3 KiB
TypeScript
205 lines
5.3 KiB
TypeScript
import {
|
|
type VariantProps,
|
|
getCalculatedTransitionDuration,
|
|
useDocument,
|
|
vcn,
|
|
} from "@pswui-lib";
|
|
import React, { type MutableRefObject, useEffect, useId, useRef } from "react";
|
|
import ReactDOM from "react-dom";
|
|
|
|
import {
|
|
type ToastOption,
|
|
close,
|
|
defaultToastOption,
|
|
getSingleSnapshot,
|
|
getSnapshot,
|
|
notify,
|
|
notifySingle,
|
|
subscribe,
|
|
subscribeSingle,
|
|
toasts,
|
|
} from "./Store";
|
|
import { toastVariant } from "./Variant";
|
|
|
|
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)());
|
|
});
|
|
}, [id]);
|
|
|
|
const toastData = {
|
|
...globalOption,
|
|
...toast,
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (toastData.life === "born") {
|
|
requestAnimationFrame(function untilBorn() {
|
|
/*
|
|
To confirm that the toast is rendered as "born" state and then change to "normal" state
|
|
This way will make sure born -> normal stage transition animation will work.
|
|
*/
|
|
const elm = document.querySelector(
|
|
`div[data-toaster-root] > div[data-toast-id="${id}"][data-toast-lifecycle="born"]`,
|
|
);
|
|
if (!elm) return requestAnimationFrame(untilBorn);
|
|
|
|
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 calculatedTransitionDurationMs = 1;
|
|
if (ref.current)
|
|
calculatedTransitionDurationMs = getCalculatedTransitionDuration(
|
|
ref as MutableRefObject<HTMLDivElement>,
|
|
);
|
|
const timeout = setTimeout(() => {
|
|
delete toasts[id];
|
|
notify();
|
|
}, calculatedTransitionDurationMs);
|
|
return () => clearTimeout(timeout);
|
|
}
|
|
}, [id, toastData.life, toastData.closeTimeout]);
|
|
|
|
return (
|
|
<div
|
|
className={toastVariant({
|
|
status: toastData.status,
|
|
life: toastData.life,
|
|
})}
|
|
ref={ref}
|
|
data-toast-id={id}
|
|
data-toast-lifecycle={toastData.life}
|
|
>
|
|
{toastData.closeButton && (
|
|
<button
|
|
className="absolute top-2 right-2"
|
|
onClick={() => close(id)}
|
|
type={"button"}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="1.2rem"
|
|
height="1.2rem"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<title>Close</title>
|
|
<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(() => {
|
|
return subscribe(() => {
|
|
setToastList(getSnapshot());
|
|
});
|
|
}, []);
|
|
|
|
const option = React.useMemo(() => {
|
|
return {
|
|
...defaultToastOption,
|
|
...defaultOption,
|
|
};
|
|
}, [defaultOption]);
|
|
|
|
const document = useDocument();
|
|
|
|
if (!document) return null;
|
|
|
|
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={true}
|
|
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,
|
|
)}
|
|
</>
|
|
);
|
|
});
|
|
Toaster.displayName = "Toaster";
|
|
|
|
export { Toaster };
|