72 lines
4.0 KiB
TypeScript
72 lines
4.0 KiB
TypeScript
import { effect, h } from '@ui-arch/frame';
|
||
import type { ModalProps } from './types.js';
|
||
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
|
||
import { button } from './button.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?.();
|
||
};
|
||
/*** 内置页脚:取消直接关;确认若返回 Promise,则按钮转「处理中…」并禁用,成功才收窗、失败留在窗口重试 ***/
|
||
const builtinFoot = (): HTMLElement => {
|
||
const ok = button({ label: props.confirmText ?? '确定' }) as HTMLButtonElement;
|
||
const cancel = button({ label: props.cancelText ?? '取消', variant: 'ghost', onClick: close }) as HTMLButtonElement;
|
||
const idle = ok.textContent; // 记下常态文案,异步结束后还原
|
||
ok.addEventListener('click', () => {
|
||
const out = props.onConfirm?.();
|
||
if (!(out instanceof Promise)) { close(); return; } // 同步确认:直接收窗
|
||
ok.disabled = true;
|
||
ok.textContent = '处理中…';
|
||
out.then(() => { if (props.open()) close(); }) // 成功才关;onConfirm 里已手动关掉就不重复触发
|
||
.catch(() => { /* 失败什么都不做:保持窗口打开让用户重试,提示交给调用方的 toast */ })
|
||
.finally(() => { ok.disabled = false; ok.textContent = idle; });
|
||
});
|
||
return h('div', { class: 'ua-modal-actions' }, cancel, ok);
|
||
};
|
||
const foot = props.footer ? h('div', { class: 'ua-modal-foot' }, props.footer) : props.confirmText || props.onConfirm ? h('div', { class: 'ua-modal-foot' }, builtinFoot()) : null; // footer 优先,其次内置按钮组
|
||
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),
|
||
foot, // 底部操作区:自定义 footer 或内置按钮组,都没有则不渲染
|
||
);
|
||
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;
|
||
}
|