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:
Yaojia Wang
2026-07-11 14:04:14 +02:00
parent 355a2d11cd
commit f4bea7f26d
7 changed files with 162 additions and 30 deletions

View File

@@ -7,8 +7,12 @@ import { Button } from "@/components/ui/Button";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { useMountTransition } from "@/lib/ui/useMountTransition";
import { overlayScrim } from "@/lib/ui/variants";
// 抽屉进出场时长(与 --dur-base 一致)。
const DRAWER_MS = 180;
interface DrawerProps {
open: boolean;
onClose: () => void;
@@ -36,11 +40,15 @@ export function Drawer({
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
useBodyScrollLock(open);
useRestoreFocus(open, triggerRef);
useEffect(() => {
if (!open) return;
// 面板真正挂载shouldRender且处于打开态时才装监听/落焦——
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
if (!open || !shouldRender) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
@@ -51,14 +59,25 @@ export function Drawer({
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
}, [open, shouldRender, onClose]);
if (!open) return null;
if (!shouldRender) return null;
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
const slideClass =
side === "left"
? isVisible
? "translate-x-0"
: "-translate-x-full"
: isVisible
? "translate-x-0"
: "translate-x-full";
return (
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
<div
className={`${overlayScrim} lg:hidden motion-safe:transition-opacity motion-safe:duration-base ${isVisible ? "opacity-100" : "opacity-0"}`}
onClick={onClose}
>
<div
ref={panelRef}
id={id}
@@ -70,7 +89,7 @@ export function Drawer({
// focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。
if (panelRef.current) handleTabTrap(panelRef.current, e);
}}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none motion-safe:transition-transform motion-safe:duration-base ease-standard ${sideClass} ${slideClass}`}
>
<div className="mb-2 flex justify-end px-2">
<Button