148 lines
4.0 KiB
TypeScript
148 lines
4.0 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
createContext,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ReactNode,
|
||
} from "react";
|
||
|
||
import { buttonClass } from "@/lib/ui/variants";
|
||
|
||
type ToastKind = "info" | "error" | "success";
|
||
|
||
interface ToastAction {
|
||
label: string;
|
||
onClick: () => void;
|
||
}
|
||
|
||
interface ToastOptions {
|
||
action?: ToastAction;
|
||
durationMs?: number;
|
||
}
|
||
|
||
interface ToastItem {
|
||
id: number;
|
||
message: string;
|
||
kind: ToastKind;
|
||
action?: ToastAction;
|
||
}
|
||
|
||
type ShowToast = (
|
||
message: string,
|
||
kind?: ToastKind,
|
||
options?: ToastOptions,
|
||
) => void;
|
||
|
||
const ToastContext = createContext<ShowToast | null>(null);
|
||
|
||
// 普通提示 4s 自动消失;错误更需被看到,默认延长到 7s。
|
||
const TOAST_TTL_MS = 4000;
|
||
const ERROR_TOAST_TTL_MS = 7000;
|
||
|
||
// 屏读前缀:让无视觉的用户也能区分提示性质。
|
||
const SR_PREFIX: Record<ToastKind, string> = {
|
||
error: "错误:",
|
||
success: "成功:",
|
||
info: "",
|
||
};
|
||
|
||
function toastShellClass(kind: ToastKind): string {
|
||
if (kind === "error") return "border-conflict bg-panel text-conflict";
|
||
if (kind === "success") return "border-pass bg-panel text-pass";
|
||
return "border-line bg-panel text-ink";
|
||
}
|
||
|
||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||
const [items, setItems] = useState<ToastItem[]>([]);
|
||
// 单调计数器作 id(不再用 Date.now()+random,避免极小概率碰撞且稳定可测)。
|
||
const nextIdRef = useRef(0);
|
||
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
|
||
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||
|
||
const dismiss = useCallback((id: number) => {
|
||
setItems((prev) => prev.filter((t) => t.id !== id));
|
||
}, []);
|
||
|
||
const show = useCallback<ShowToast>((message, kind = "info", options) => {
|
||
const id = nextIdRef.current++;
|
||
setItems((prev) => [...prev, { id, message, kind, action: options?.action }]);
|
||
const ttl =
|
||
options?.durationMs ??
|
||
(kind === "error" ? ERROR_TOAST_TTL_MS : TOAST_TTL_MS);
|
||
const timer = setTimeout(() => {
|
||
timersRef.current.delete(timer);
|
||
setItems((prev) => prev.filter((t) => t.id !== id));
|
||
}, ttl);
|
||
timersRef.current.add(timer);
|
||
}, []);
|
||
|
||
// 卸载清理所有在途定时器。
|
||
useEffect(() => {
|
||
const timers = timersRef.current;
|
||
return () => {
|
||
timers.forEach((t) => clearTimeout(t));
|
||
timers.clear();
|
||
};
|
||
}, []);
|
||
|
||
const value = useMemo(() => show, [show]);
|
||
|
||
const errorItems = items.filter((t) => t.kind === "error");
|
||
const politeItems = items.filter((t) => t.kind !== "error");
|
||
|
||
const renderItem = (t: ToastItem) => (
|
||
<div
|
||
key={t.id}
|
||
className={`pointer-events-auto flex items-center gap-3 rounded border px-4 py-2 text-sm shadow-paper ${toastShellClass(
|
||
t.kind,
|
||
)}`}
|
||
>
|
||
<span className="min-w-0">
|
||
<span className="sr-only">{SR_PREFIX[t.kind]}</span>
|
||
{t.message}
|
||
</span>
|
||
{t.action ? (
|
||
<button
|
||
type="button"
|
||
className={buttonClass({ variant: "ghost", size: "sm" })}
|
||
onClick={() => {
|
||
t.action?.onClick();
|
||
dismiss(t.id);
|
||
}}
|
||
>
|
||
{t.action.label}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<ToastContext.Provider value={value}>
|
||
{children}
|
||
{/* error 用 assertive 立即播报;其余 info/success 用 polite 礼貌播报。两区共用同一锚点容器避免重叠。 */}
|
||
<div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
|
||
<div className="flex flex-col gap-2" aria-live="assertive" role="alert">
|
||
{errorItems.map(renderItem)}
|
||
</div>
|
||
<div className="flex flex-col gap-2" aria-live="polite" role="status">
|
||
{politeItems.map(renderItem)}
|
||
</div>
|
||
</div>
|
||
</ToastContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useToast(): ShowToast {
|
||
const ctx = useContext(ToastContext);
|
||
if (!ctx) {
|
||
// 容错:未挂 Provider 时退化为 no-op(不应在生产路径发生)。
|
||
return () => undefined;
|
||
}
|
||
return ctx;
|
||
}
|