feat(web): 前端共享基础(focusRing/proseBody/text-2xs + useBodyScrollLock + Toast撤销 + error/not-found/loading路由边界)
This commit is contained in:
37
apps/web/app/error.tsx
Normal file
37
apps/web/app/error.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export default function Error({ reset }: ErrorBoundaryProps) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
|
||||
<div className="w-full max-w-md">
|
||||
<EmptyState
|
||||
icon={TriangleAlert}
|
||||
title="出错了"
|
||||
description="刚才的操作没能完成。可以重试,或先回到作品库。"
|
||||
action={
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="primary" onClick={reset}>
|
||||
重试
|
||||
</Button>
|
||||
<Link href="/" className={buttonClass({ variant: "secondary" })}>
|
||||
返回作品库
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
9
apps/web/app/loading.tsx
Normal file
9
apps/web/app/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
|
||||
<ThinkingIndicator label="加载中…" className="text-ink-soft" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
24
apps/web/app/not-found.tsx
Normal file
24
apps/web/app/not-found.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { FileQuestion } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
|
||||
<div className="w-full max-w-md">
|
||||
<EmptyState
|
||||
icon={FileQuestion}
|
||||
title="页面不存在"
|
||||
description="你要找的内容可能已被移动或从未存在。回到作品库继续创作吧。"
|
||||
action={
|
||||
<Link href="/" className={buttonClass({ variant: "primary" })}>
|
||||
返回作品库
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
41
apps/web/lib/ui/useBodyScrollLock.test.ts
Normal file
41
apps/web/lib/ui/useBodyScrollLock.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useBodyScrollLock } from "./useBodyScrollLock";
|
||||
|
||||
describe("useBodyScrollLock", () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
it("active 时锁定 body 滚动", () => {
|
||||
renderHook(() => useBodyScrollLock(true));
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
});
|
||||
|
||||
it("active 为假时不改动 overflow", () => {
|
||||
document.body.style.overflow = "scroll";
|
||||
renderHook(() => useBodyScrollLock(false));
|
||||
expect(document.body.style.overflow).toBe("scroll");
|
||||
});
|
||||
|
||||
it("卸载时恢复原 overflow 值", () => {
|
||||
document.body.style.overflow = "auto";
|
||||
const { unmount } = renderHook(() => useBodyScrollLock(true));
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
unmount();
|
||||
expect(document.body.style.overflow).toBe("auto");
|
||||
});
|
||||
|
||||
it("active 由真转假时恢复原值", () => {
|
||||
document.body.style.overflow = "visible";
|
||||
const { rerender } = renderHook(
|
||||
({ active }: { active: boolean }) => useBodyScrollLock(active),
|
||||
{ initialProps: { active: true } },
|
||||
);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
rerender({ active: false });
|
||||
expect(document.body.style.overflow).toBe("visible");
|
||||
});
|
||||
});
|
||||
20
apps/web/lib/ui/useBodyScrollLock.ts
Normal file
20
apps/web/lib/ui/useBodyScrollLock.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
// 弹层(Drawer / CommandPalette / Modal)打开时锁定 body 滚动,关闭后恢复原值。
|
||||
// SSR 守卫:服务端无 document,直接跳过。
|
||||
export function useBodyScrollLock(active: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const { body } = document;
|
||||
const previousOverflow = body.style.overflow;
|
||||
body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [active]);
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
badgeClass,
|
||||
buttonClass,
|
||||
cn,
|
||||
focusRing,
|
||||
inputClass,
|
||||
overlayScrim,
|
||||
proseBody,
|
||||
segmentedClass,
|
||||
statusNoteClass,
|
||||
} from "./variants";
|
||||
@@ -49,4 +52,22 @@ describe("ui variants", () => {
|
||||
expect(klass).toContain("inline-flex");
|
||||
expect(klass).toContain("border-line");
|
||||
});
|
||||
|
||||
it("shares the cinnabar focus ring with the button base", () => {
|
||||
expect(focusRing).toContain("focus-visible:ring-2");
|
||||
expect(focusRing).toContain("focus-visible:ring-cinnabar/35");
|
||||
expect(buttonClass({ variant: "primary" })).toContain(focusRing);
|
||||
});
|
||||
|
||||
it("renders manuscript body as serif 18px with airy leading", () => {
|
||||
expect(proseBody).toContain("font-serif");
|
||||
expect(proseBody).toContain("text-[18px]");
|
||||
expect(proseBody).toContain("leading-[1.9]");
|
||||
});
|
||||
|
||||
it("provides a full-screen overlay scrim below dialog chrome", () => {
|
||||
expect(overlayScrim).toContain("fixed");
|
||||
expect(overlayScrim).toContain("inset-0");
|
||||
expect(overlayScrim).toContain("z-40");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,16 @@ export function cn(...classes: Array<string | false | null | undefined>): string
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用。
|
||||
export const focusRing =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
|
||||
|
||||
// 手稿正文统一排版:衬线 18px、行高 1.9。
|
||||
export const proseBody = "font-serif text-[18px] leading-[1.9]";
|
||||
|
||||
// Drawer / CommandPalette 共用的全屏遮罩。
|
||||
export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
|
||||
|
||||
const buttonBase =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ const config: Config = {
|
||||
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
|
||||
mono: ['"JetBrains Mono"', "ui-monospace"],
|
||||
},
|
||||
fontSize: {
|
||||
"2xs": ["0.6875rem", { lineHeight: "1rem" }],
|
||||
},
|
||||
borderRadius: { DEFAULT: "6px" },
|
||||
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
|
||||
maxWidth: { prose: "720px" },
|
||||
|
||||
Reference in New Issue
Block a user