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
This commit is contained in:
53
apps/web/lib/ui/useMountTransition.test.ts
Normal file
53
apps/web/lib/ui/useMountTransition.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useMountTransition } from "./useMountTransition";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// 让 rAF 同步执行,便于断言 isVisible 翻转。
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("useMountTransition", () => {
|
||||
it("renders nothing while closed", () => {
|
||||
const { result } = renderHook(() => useMountTransition(false, 180));
|
||||
expect(result.current.shouldRender).toBe(false);
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("mounts and becomes visible when opened", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ open }) => useMountTransition(open, 180),
|
||||
{ initialProps: { open: false } },
|
||||
);
|
||||
act(() => rerender({ open: true }));
|
||||
expect(result.current.shouldRender).toBe(true);
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps rendering during exit, then unmounts after the duration", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ open }) => useMountTransition(open, 180),
|
||||
{ initialProps: { open: true } },
|
||||
);
|
||||
act(() => rerender({ open: false }));
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
expect(result.current.shouldRender).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(180);
|
||||
});
|
||||
expect(result.current.shouldRender).toBe(false);
|
||||
});
|
||||
});
|
||||
29
apps/web/lib/ui/useMountTransition.ts
Normal file
29
apps/web/lib/ui/useMountTransition.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// 弹层进出场:打开即挂载并在下一帧置为可见(触发入场过渡);关闭先置不可见播放出场,
|
||||
// 延迟 durationMs 后再卸载。调用方用 motion-safe: 门控过渡类——reduced-motion 下无动画,
|
||||
// 仍功能正常(仅多挂载 durationMs)。守住既有 focus-trap/scroll-lock:它们仍由 open 驱动,
|
||||
// 本 hook 只负责“延迟卸载”以便播放退场。
|
||||
export function useMountTransition(
|
||||
isOpen: boolean,
|
||||
durationMs: number,
|
||||
): { shouldRender: boolean; isVisible: boolean } {
|
||||
const [shouldRender, setShouldRender] = useState(isOpen);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShouldRender(true);
|
||||
// 下一帧再置可见,确保入场从初始(隐藏)态开始过渡。
|
||||
const raf = requestAnimationFrame(() => setIsVisible(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
setIsVisible(false);
|
||||
const timer = window.setTimeout(() => setShouldRender(false), durationMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isOpen, durationMs]);
|
||||
|
||||
return { shouldRender, isVisible };
|
||||
}
|
||||
Reference in New Issue
Block a user