feat(web): 前端共享基础(focusRing/proseBody/text-2xs + useBodyScrollLock + Toast撤销 + error/not-found/loading路由边界)
This commit is contained in:
@@ -11,19 +11,51 @@ import {
|
||||
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) => void;
|
||||
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[]>([]);
|
||||
@@ -32,13 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
|
||||
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
const show = useCallback<ShowToast>((message, kind = "info") => {
|
||||
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 }]);
|
||||
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));
|
||||
}, TOAST_TTL_MS);
|
||||
}, ttl);
|
||||
timersRef.current.add(timer);
|
||||
}, []);
|
||||
|
||||
@@ -53,28 +92,46 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
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}
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto rounded border px-4 py-2 text-sm shadow-paper ${
|
||||
t.kind === "error"
|
||||
? "border-conflict bg-panel text-conflict"
|
||||
: t.kind === "success"
|
||||
? "border-pass bg-panel text-pass"
|
||||
: "border-line bg-panel text-ink"
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user