use-focus-trap.js 3.02 KB
/**
 * 焦点陷阱 —— 弹层打开期间把键盘困在容器里,`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<HTMLElement|null>} 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)
  })
}