feat(web): 前端共享基础(focusRing/proseBody/text-2xs + useBodyScrollLock + Toast撤销 + error/not-found/loading路由边界)
This commit is contained in:
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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user