"use client"; /** * 通用图层(overlay / modal)。 * * 为什么自建而不引第三方弹窗库:全站只有「实验详情预览」这一处需要图层,为一个 * 需求引入 react-modal / focus-trap 之类依赖不划算。但下面四件事直接影响可用性, * 必须自己做对(不是装饰): * ① **锁 body 滚动**:不锁的话滚轮会滚到背后的列表,用户会以为图层没盖住; * ② **ESC 关闭 + 点遮罩关闭**:且点内容区不关(否则在图层里选中文字、拖滚动条 * 都会误关);遮罩判定用 mousedown 而不是 click —— 从内容区按下、拖到遮罩上 * 再松手时 click 的目标是两者的共同祖先(遮罩),用 click 会误判为「点了遮罩」; * ③ **焦点管理**:打开时焦点进对话框,关闭后**还给触发它的按钮** —— 否则键盘 * 用户关掉图层后焦点回到 ,再按 Tab 会从页首重新走一遍; * ④ **role="dialog" + aria-modal + aria-labelledby**:屏幕阅读器才知道「这是一个 * 对话框、它的标题是什么、背景内容应当被屏蔽」。 * * 用 createPortal 挂到 document.body:图层必须脱离列表页的层叠上下文与 overflow * 裁剪 —— 放在带 transform / overflow:hidden 的祖先里会被裁掉或压不住子元素。 * * 样式走 `components/Modal.module.css`(CSS Module),刻意不写进 app/globals.css: * 全局样式表被多处共用,改它容易与并行的改动互相覆盖;图层样式只服务本组件。 */ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Icon } from "@/components/icons"; import styles from "./Modal.module.css"; export function Modal({ title, onClose, tools, children, returnFocusTo, }: { /** 标题栏文案:同时作为 aria-labelledby 指向的可见标题 */ title: ReactNode; onClose: () => void; /** 标题栏右侧动作(如「在新页面打开完整归档」);关闭按钮由本组件自己追加 */ tools?: ReactNode; children: ReactNode; /** * 关闭后要把焦点还给谁。 * 调用方显式传入触发按钮最可靠(例如每行的「详情」按钮);不传时退化为 * 「打开那一刻 document.activeElement 是谁」,对点击入口通常也成立。 */ returnFocusTo?: HTMLElement | null; }) { const titleId = useId(); const dialogRef = useRef(null); /** * createPortal 需要 document,而 Next 会对客户端组件做一次 SSR: * 直接 portal 会在服务端拿不到 document 而抛错。首帧不渲染,挂载后再上图层。 */ const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); /** * onClose 每次父组件渲染都是新函数;用 ref 保存,下面的副作用就能只在挂载时 * 绑定一次键盘监听 —— 反复解绑/重绑期间正好按下的 ESC 会被漏掉。 */ const closeRef = useRef(onClose); useEffect(() => { closeRef.current = onClose; }); useEffect(() => { if (!mounted) return; const restore = returnFocusTo ?? (document.activeElement as HTMLElement | null); // 锁滚动:保存原值而不是关闭时写 "",否则会覆盖页面本来就设置过的 overflow const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; // 打开时把焦点移进对话框:tabIndex={-1} 让它可聚焦,但不会进 Tab 序列 dialogRef.current?.focus(); function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { // 图层是 aria-modal:ESC 只应关掉图层,不应冒泡去触发页面的其它快捷键 e.preventDefault(); e.stopPropagation(); closeRef.current(); return; } if (e.key !== "Tab") return; // 焦点陷阱:不拦住的话 Tab 会跑到图层背后的列表里(视觉上"消失"了) const root = dialogRef.current; if (!root) return; const items = Array.from( root.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' ) ); if (items.length === 0) { e.preventDefault(); root.focus(); return; } const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement; if (e.shiftKey && (active === first || active === root)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } } document.addEventListener("keydown", onKeyDown, true); return () => { document.removeEventListener("keydown", onKeyDown, true); document.body.style.overflow = prevOverflow; // 触发按钮可能已随列表刷新消失(例如刚删掉这一行):只有还在文档里才回焦, // 否则 focus() 无效,焦点会掉回 (键盘用户会"掉回页首")。 if (restore && restore.isConnected) restore.focus(); }; // 只在挂载/卸载时执行;returnFocusTo 只在打开那一刻读一次,不参与后续渲染 // eslint-disable-next-line react-hooks/exhaustive-deps }, [mounted]); if (!mounted) return null; return createPortal(
{ if (e.target === e.currentTarget) closeRef.current(); }} >

{title}

{tools}
{children}
, document.body ); }