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; }