Files
opsxdb/src/OpsModal.tsx
T

185 lines
6.0 KiB
TypeScript

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<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLElement>(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 (
<div
ref={overlayRef}
className="ops-modal-overlay"
style={{ zIndex }}
onMouseDown={(e) => {
// 仅点击遮罩本体(非对话框内容)才关闭
if (e.target === overlayRef.current) cancel();
}}
>
<div
ref={dialogRef}
className={`ops-modal w-full ${wide ? "max-w-2xl" : "max-w-md"}`}
role="alertdialog"
aria-modal="true"
aria-label={typeof title === "string" ? title : undefined}
>
<div className="ops-modal-header">{title}</div>
<div className="ops-modal-body">
{children}
{variant === "input" && (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
{label && (
<label
htmlFor="ops-modal-input"
className="mb-1 block"
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}
>
{label}
</label>
)}
<input
id="ops-modal-input"
ref={inputRef}
type="text"
className="ops-input w-full"
value={value}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
/>
</form>
)}
</div>
<div className="ops-modal-footer">
{variant !== "info" && (
<button type="button" className="ops-btn-secondary min-w-[72px]" onClick={cancel}>
{cancelLabel}
</button>
)}
<button
type="button"
className={`${danger ? "ops-btn-danger" : "ops-btn-primary"} min-w-[72px]`}
onClick={submit}
>
{okLabel}
</button>
</div>
</div>
</div>
);
}