import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; /** * OpsModal — 统一模态基件(设计规范 v2 §2.2) * * - variant: info(仅"确定")/ confirm(取消+确定)/ input(带输入框,返回字符串) * - danger: 主按钮渲染为红色(危险操作确认) * - 可访问性:焦点陷阱(Tab 在模态内循环)、关闭后焦点归还触发元素、 * Esc 关闭、遮罩点击关闭 * - 样式全部走 styles.css 的 --ops-* 令牌,亮暗主题随行 */ export type OpsModalVariant = "info" | "confirm" | "input"; export type OpsModalProps = { title: ReactNode; variant?: OpsModalVariant; /** 主按钮红色(删除/截断等危险确认) */ danger?: boolean; okLabel?: string; cancelLabel?: string; /** input 变体:输入框标签 */ label?: string; /** input 变体:默认值 */ defaultValue?: string; /** input 变体:占位符 */ placeholder?: string; /** 层级,默认 var(--ops-z-modal);确认框传 var(--ops-z-confirm) */ zIndex?: string | number; /** 宽版(max-w-2xl),DDL 等长文本展示用 */ wide?: boolean; /** 确认回调;input 变体携带输入值 */ onOk: (value?: string) => void; /** 取消回调(Esc / 遮罩点击 / 取消按钮),info 变体缺省时回落到 onOk */ onCancel?: () => void; children?: ReactNode; }; const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; export function OpsModal(props: OpsModalProps) { const { title, variant = "info", danger = false, okLabel = "确定", cancelLabel = "取消", label, defaultValue = "", placeholder, zIndex = "var(--ops-z-modal)", wide = false, onOk, onCancel, children, } = props; const overlayRef = useRef(null); const dialogRef = useRef(null); const inputRef = useRef(null); const [value, setValue] = useState(defaultValue); // 回调放 ref,避免键盘监听因回调变化反复挂载 const cbRef = useRef({ onOk, onCancel }); cbRef.current = { onOk, onCancel }; const cancel = () => (cbRef.current.onCancel ?? cbRef.current.onOk)(); const submit = () => cbRef.current.onOk(variant === "input" ? value : undefined); // 焦点管理:打开时聚焦输入框/主按钮,Tab 在模态内循环,Esc 关闭,卸载时焦点归还触发元素 useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; const prev = document.activeElement as HTMLElement | null; const focusables = () => Array.from(dialog.querySelectorAll(FOCUSABLE)).filter( (el) => el.offsetParent !== null || el === document.activeElement, ); const initial = variant === "input" && inputRef.current ? inputRef.current : focusables().pop(); initial?.focus(); if (variant === "input") inputRef.current?.select(); const onKey = (e: KeyboardEvent) => { // 模态可叠加(如确认框盖在通知模态上):仅最上层响应键盘 const overlays = document.querySelectorAll(".ops-modal-overlay"); if (overlays.length && overlays[overlays.length - 1] !== overlayRef.current) return; if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancel(); return; } if (e.key !== "Tab") return; const items = focusables(); if (!items.length) return; const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement as HTMLElement | null; if (e.shiftKey) { if (!active || !dialog.contains(active) || active === first) { e.preventDefault(); last.focus(); } } else if (!active || !dialog.contains(active) || active === last) { e.preventDefault(); first.focus(); } }; window.addEventListener("keydown", onKey, true); return () => { window.removeEventListener("keydown", onKey, true); // 焦点归还触发元素 prev?.focus?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{ // 仅点击遮罩本体(非对话框内容)才关闭 if (e.target === overlayRef.current) cancel(); }} >
{title}
{children} {variant === "input" && (
{ e.preventDefault(); submit(); }} > {label && ( )} setValue(e.target.value)} />
)}
{variant !== "info" && ( )}
); }