"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
);
}