185 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|