- 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
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||
import { X } from "lucide-react";
|
||
|
||
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;
|
||
// 滑出方向:导航在左、本章助手在右。
|
||
side?: "left" | "right";
|
||
// 无障碍标签(role=dialog)。
|
||
label: string;
|
||
// 抽屉面板 id:供触发按钮 aria-controls 指向(无障碍关联)。
|
||
id?: string;
|
||
triggerRef?: RefObject<HTMLElement | null>;
|
||
children: ReactNode;
|
||
}
|
||
|
||
// 移动端侧滑抽屉(<lg;遮罩点击 / Esc 关闭 + 打开聚焦具名关闭按钮)。
|
||
// 复用命令面板的 a11y 模式(CommandPalette);桌面用静态布局,不渲染本抽屉。
|
||
export function Drawer({
|
||
open,
|
||
onClose,
|
||
side = "left",
|
||
label,
|
||
id,
|
||
triggerRef,
|
||
children,
|
||
}: DrawerProps) {
|
||
const panelRef = useRef<HTMLDivElement>(null);
|
||
const closeRef = useRef<HTMLButtonElement>(null);
|
||
|
||
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
|
||
|
||
useBodyScrollLock(open);
|
||
useRestoreFocus(open, triggerRef);
|
||
|
||
useEffect(() => {
|
||
// 面板真正挂载(shouldRender)且处于打开态时才装监听/落焦——
|
||
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
|
||
if (!open || !shouldRender) return;
|
||
const onKey = (e: KeyboardEvent): void => {
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
onClose();
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
||
closeRef.current?.focus();
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [open, shouldRender, onClose]);
|
||
|
||
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 motion-safe:transition-opacity motion-safe:duration-base ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
ref={panelRef}
|
||
id={id}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={label}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => {
|
||
// focus trap:Tab/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 motion-safe:transition-transform motion-safe:duration-base ease-standard ${sideClass} ${slideClass}`}
|
||
>
|
||
<div className="mb-2 flex justify-end px-2">
|
||
<Button
|
||
ref={closeRef}
|
||
onClick={onClose}
|
||
aria-label={`关闭${label}`}
|
||
variant="ghost"
|
||
size="icon"
|
||
>
|
||
<X className="h-5 w-5" aria-hidden="true" />
|
||
</Button>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|