use-focus-trap.js
3.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/**
* 焦点陷阱 —— 弹层打开期间把键盘困在容器里,`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)
})
}