Files
UI/packages/skin/ui/src/modal.ts
T
2026-10-26 13:51:40 +08:00

54 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { effect, h } from '@ui-arch/frame';
import type { ModalProps } from './types.js';
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
let seq = 0; // 实例序号:给标题生成唯一 id,供 aria-labelledby 关联
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose;Tab 焦点陷阱 + 关闭归还焦点 ***/
export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
const titleId = `ua-modal-title-${++seq}`; // 标题 id:对话框的可访问名
const close = () => {
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
props.open(false); // 写回信号触发隐藏
props.onClose?.();
};
const overlay = h('div', {
class: 'ua-modal-overlay',
onClick: (e: Event) => {
if (e.target !== overlay) return; // 卫语句:只处理点在遮罩本身的
if (props.closeOnOverlay === false) return; // 显式禁止时防误关,× 与 Esc 仍可用
close();
},
});
const box = h(
'div',
{ class: `ua-modal ua-modal-${props.size ?? 'md'}`, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId }, // 对话框语义 + 标题关联
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title', id: titleId }, props.title), h('button', { type: 'button', class: 'ua-modal-close', 'aria-label': '关闭弹窗', onClick: close }, '×')),
h('div', { class: 'ua-modal-body' }, ...children),
props.footer ? h('div', { class: 'ua-modal-foot' }, props.footer) : null, // 底部操作区:右对齐,仅传了才渲染
);
overlay.append(box);
let lastFocus: HTMLElement | null = null; // 打开前焦点宿主,关闭时归还
let release: (() => void) | null = null; // Esc 栈注销函数,关闭时释放
let releaseLock: (() => void) | null = null; // 背景滚动锁解锁函数,全关才还原
effect(() => {
const open = props.open();
overlay.classList.toggle('ua-open', open); // 显隐跟随信号
if (open) {
lastFocus = document.activeElement as HTMLElement | null; // 记录打开前焦点
release ??= pushOverlay(close); // 入 Esc 栈:多弹层时只关最上层
releaseLock ??= lockScroll(); // 模态期锁背景滚动
(box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
} else {
release?.(); // 出 Esc 栈
release = null;
releaseLock?.(); // 解锁背景滚动
releaseLock = null;
lastFocus?.focus(); // 关闭时归还焦点
lastFocus = null;
}
});
trapFocus(box, () => props.open()); // Tab 焦点陷阱:与 drawer 共用同一份实现
return overlay;
}