chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)
This commit is contained in:
1 parent
a9510235bb
commit
456fa6954b
203 files changed
+18787
-48
No files matched your search
@@ -0,0 +1,184 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user