81 lines
2.6 KiB
TypeScript
81 lines
2.6 KiB
TypeScript
import { useEffect, useRef, type HTMLAttributes } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { cn } from "./cn.js";
|
|
import { Icon, type IconName } from "./Icon.js";
|
|
|
|
export type ToastTone = "success" | "error" | "warning" | "info" | "loading";
|
|
|
|
export type ToastItem = {
|
|
id: string;
|
|
tone: ToastTone;
|
|
title: string;
|
|
description?: string;
|
|
durationMs?: number | null;
|
|
};
|
|
|
|
const toastIcons: Record<ToastTone, IconName> = {
|
|
success: "check",
|
|
error: "alert",
|
|
warning: "alert",
|
|
info: "activity",
|
|
loading: "refresh",
|
|
};
|
|
|
|
const DEFAULT_TOAST_DURATION_MS = 10_000;
|
|
|
|
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
|
|
item: ToastItem;
|
|
onDismiss?: (id: string) => void;
|
|
}
|
|
|
|
export function ToastCard({ item, onDismiss, className, ...props }: ToastCardProps) {
|
|
return (
|
|
<div
|
|
className={cn("nodedc-toast nodedc-glass-material nodedc-material-rim", className)}
|
|
data-tone={item.tone}
|
|
role={item.tone === "error" ? "alert" : "status"}
|
|
{...props}
|
|
>
|
|
<span className="nodedc-toast__icon" aria-hidden="true"><Icon name={toastIcons[item.tone]} /></span>
|
|
<span className="nodedc-toast__copy">
|
|
<strong>{item.title}</strong>
|
|
{item.description ? <small>{item.description}</small> : null}
|
|
</span>
|
|
{onDismiss ? (
|
|
<button type="button" className="nodedc-toast__dismiss" aria-label="Закрыть уведомление" onClick={() => onDismiss(item.id)}>
|
|
<Icon name="close" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimedToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
|
|
const dismissRef = useRef(onDismiss);
|
|
|
|
useEffect(() => {
|
|
dismissRef.current = onDismiss;
|
|
}, [onDismiss]);
|
|
|
|
useEffect(() => {
|
|
const duration = item.durationMs === undefined
|
|
? item.tone === "loading" ? null : DEFAULT_TOAST_DURATION_MS
|
|
: item.durationMs;
|
|
if (typeof duration !== "number" || duration <= 0) return undefined;
|
|
const timer = window.setTimeout(() => dismissRef.current(item.id), duration);
|
|
return () => window.clearTimeout(timer);
|
|
}, [item.durationMs, item.id, item.tone]);
|
|
|
|
return <ToastCard item={item} onDismiss={onDismiss} />;
|
|
}
|
|
|
|
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
|
|
if (typeof document === "undefined" || items.length === 0) return null;
|
|
return createPortal(
|
|
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
|
|
{items.map((item) => <TimedToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|