diff --git a/.ua/index.json b/.ua/index.json index e15295b..6fd1601 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-23T09:08:47.698Z", + "generatedAt": "2026-10-23T09:10:27.467Z", "repoRoot": ".", "packages": [ { diff --git a/packages/skin/ui/src/modal.ts b/packages/skin/ui/src/modal.ts index 3ced53c..19f79ed 100644 --- a/packages/skin/ui/src/modal.ts +++ b/packages/skin/ui/src/modal.ts @@ -1,7 +1,7 @@ import { effect, h } from '@ui-arch/frame'; import type { ModalProps } from './types.js'; -/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose ***/ +/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose;Tab 焦点陷阱 + 关闭归还焦点 ***/ export function modal(props: ModalProps, ...children: unknown[]): HTMLElement { const close = () => { if (!props.open()) return; // 卫语句:已关闭不重复触发回调 @@ -16,16 +16,38 @@ export function modal(props: ModalProps, ...children: unknown[]): HTMLElement { }); const box = h( 'div', - { class: `ua-modal ua-modal-${props.size ?? 'md'}` }, + { 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('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) (box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素 + 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 关闭当前打开的弹窗