p-sw 2deae4af79 refactor(components): update import statements
Updated the import statements in multiple component files to use the updated package name for shared resources. This change was necessary for import consistency and to ensure proper functioning of the components as per the new package name "@pswui-lib/shared@1.0.0".
2024-06-11 16:20:24 +09:00

338 lines
8.3 KiB
TypeScript

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 };