/** * 焦点陷阱 —— 弹层打开期间把键盘困在容器里,`Modal.vue` 与 `Drawer.vue` 共用这一份。 * * 只装四件真正共用的事: * 1. 打开时挂 window keydown 监听、关闭/卸载时摘掉; * 2. ESC → 调 `onEscape`(关不关、怎么关是各弹层自己的事); * 3. Tab / Shift+Tab 在容器内可聚焦元素之间循环(首尾相接); * 4. 打开时聚焦容器,关闭时把焦点还给打开前那个元素。 * * 不碰 backdrop、样式、v-model —— Modal 的 `closeOnBackdrop`、Drawer 的 `width` 是各自的 props, * 不进这里。这里也不是「通用 overlay 基类」,多抽一层只有间接、没有收益。 */ import { watch, nextTick, onBeforeUnmount } from 'vue' /** 容器内被当作可聚焦的元素(Tab 循环的取值范围)。 */ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])' /** * 把键盘焦点困在 `containerRef` 里,直到 `isOpen()` 变假。 * * @param {import('vue').Ref} containerRef 弹层根元素(需 `tabindex="-1"` 才能被聚焦)。 * @param {() => boolean} isOpen 开关的取值函数(如 `() => props.open`)。 * @param {() => void} onEscape 按下 ESC 时调用(通常是弹层的 close)。 * @returns {void} */ export function useFocusTrap(containerRef, isOpen, onEscape) { /** @type {HTMLElement|null} 打开前持有焦点的元素,关闭时把焦点还给它。 */ let restoreTo = null /** * ESC 关闭;Tab/Shift+Tab 在容器可聚焦元素间循环。 * @param {KeyboardEvent} e * @returns {void} */ function onKeydown(e) { if (e.key === 'Escape') { onEscape() return } if (e.key !== 'Tab' || !containerRef.value) return const focusable = containerRef.value.querySelectorAll(FOCUSABLE_SELECTOR) if (focusable.length === 0) return const first = /** @type {HTMLElement} */ (focusable[0]) const last = /** @type {HTMLElement} */ (focusable[focusable.length - 1]) const active = document.activeElement if (e.shiftKey && active === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && active === last) { e.preventDefault() first.focus() } } /** * 把焦点还给打开弹层前那个元素(它可能已被移出文档,此时不还)。 * @returns {void} */ function restoreFocus() { if (restoreTo && restoreTo !== document.body && restoreTo.isConnected) { restoreTo.focus() } restoreTo = null } watch( isOpen, (open) => { if (open) { restoreTo = /** @type {HTMLElement|null} */ (document.activeElement) window.addEventListener('keydown', onKeydown) nextTick(() => { containerRef.value?.focus() }) } else { window.removeEventListener('keydown', onKeydown) restoreFocus() } }, { immediate: true }, ) onBeforeUnmount(() => { window.removeEventListener('keydown', onKeydown) }) }