Files
UI/packages/skin/ui/src/modal.ts
T

72 lines
4.0 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';
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;
}