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

63 lines
3.2 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 } from './overlay.js';
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose;Tab 焦点陷阱 + 关闭归还焦点 ***/
export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
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) close(); // 卫语句:仅点遮罩本身关闭
},
});
const box = h(
'div',
{ class: `ua-modal ua-modal-${props.size ?? 'md'}`, role: 'dialog', 'aria-modal': 'true' }, // 对话框语义,供读屏识别
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title' }, 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; // 背景滚动锁解锁函数,全关才还原
const focusables = (): HTMLElement[] =>
Array.from(box.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
.filter((el) => !el.hasAttribute('disabled')); // 弹窗内可聚焦元素(排除禁用)
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;
}
});
overlay.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !props.open()) return; // 仅处理打开中的 Tab
const els = focusables();
if (els.length === 0) { e.preventDefault(); return; } // 无可聚焦元素:拦截 Tab 防焦点逃逸
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey && (document.activeElement === first || !box.contains(document.activeElement))) {
e.preventDefault(); last.focus(); // Shift+Tab 到首位时回绕到末尾
} else if (!e.shiftKey && (document.activeElement === last || !box.contains(document.activeElement))) {
e.preventDefault(); first.focus(); // Tab 到末位时回绕到开头
}
});
return overlay;
}