57 lines
2.9 KiB
TypeScript
57 lines
2.9 KiB
TypeScript
import { effect, h } from '@ui-arch/frame';
|
||
import type { ModalProps } from './types.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; // 打开前焦点宿主,关闭时归还
|
||
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; // 记录打开前焦点
|
||
(box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||
} else if (lastFocus) {
|
||
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 到末位时回绕到开头
|
||
}
|
||
});
|
||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape' && props.open()) close(); // Esc 关闭当前打开的弹窗
|
||
});
|
||
return overlay;
|
||
}
|