feat(web): 前端共享基础(focusRing/proseBody/text-2xs + useBodyScrollLock + Toast撤销 + error/not-found/loading路由边界)

This commit is contained in:
Yaojia Wang
2026-06-30 08:35:52 +02:00
parent 69f3c2a29b
commit a22a16c9c4
9 changed files with 245 additions and 23 deletions

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

View 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]);
}

View File

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

View File

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