Files
writer-work-flow/apps/web/components/Toast.tsx

148 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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