Files
Yaojia Wang f4bea7f26d feat(ui): 弹层进出场过渡 + 首屏暖深色英雄(P2-6/P4-6)
- useMountTransition hook(延迟卸载播放退场,尊重 reduced-motion)+ vitest
- Drawer 淡入+滑入进出场;focus/scroll-lock 仍由 open 驱动、focus 键 shouldRender 防丢首焦
- Toast 入场 toast-in 动画(motion-safe)
- P4-6 首屏零作品用 callout 暖深色编辑部英雄(对标 cta-band-dark,不干扰工作库)
  新增 callout/on-callout/on-callout-soft 双主题成对 token
2026-07-11 14:04:14 +02:00

148 lines
4.1 KiB
TypeScript
Raw Permalink 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={`toast-enter pointer-events-auto flex items-center gap-3 rounded-md 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;
}