一、传统弹窗的三大痛点
<!-- 传统弹窗结构 -->
<div class="parent-container">
<div class="modal-mask">
<div class="modal-content">
<!-- 弹窗内容 -->
</div>
</div>
</div>
主要问题:
- 传统实现方式会导致z-index层级冲突
- 样式作用域污染
- 屏幕阅读器无法正确识别弹窗语义
二、基于Vue3 Teleport基础实现
<template>
<Teleport to="body">
<div
v-show="modelValue"
class="modal-mask"
role="dialog"
aria-modal="true"
@click.self="handleClose"
>
<div class="modal-content">
<slot name="title" />
<slot />
<button @click="handleClose">关闭</button>
</div>
</div>
</Teleport>
</template>
<script setup>
defineProps({
modelValue: Boolean
})
const emit = defineEmits(['update:modelValue'])
const handleClose = () => {
emit('update:modelValue', false)
}
</script>
核心要点:
@click.self修饰符确保仅点击遮罩层触发关闭role="dialog"声明对话框语义角色aria-modal="true"阻止屏幕阅读器读取背景内容
三、无障碍增强实现
3.1 ARIA属性绑定
<template>
<div
:aria-labelledby="titleId"
:aria-describedby="descId"
>
<h2 :id="titleId"><slot name="title" /></h2>
<p :id="descId"><slot name="description" /></p>
</div>
</template>
<script setup>
import { useId } from '@vueuse/core'
const titleId = useId()
const descId = useId()
</script>
技术细节:
- 使用vue3
useId生成唯一ID避免重复 aria-labelledby关联标题元素aria-describedby关联内容描述
3.2 焦点管理
const contentRef = ref(null)
let lastActiveElement = null
watch(() => props.modelValue, (newVal) => {
if (newVal) {
lastActiveElement = document.activeElement
nextTick(() => {
contentRef.value?.focus()
contentRef.value?.setAttribute('tabindex', -1)
})
} else {
lastActiveElement?.focus()
}
})
实现原理:
- 弹窗打开时锁定焦点在内容区域
- 关闭时恢复原始焦点位置
- 禁用背景元素的键盘导航
四、完整实现方案
<!-- AccessibleModal.vue -->
<template>
<Teleport to="body">
<div
v-show="modelValue"
class="modal-mask"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
@click.self="handleClose"
@keydown.esc="handleClose"
>
<div
ref="contentRef"
class="modal-content"
tabindex="-1"
@keydown.tab="handleTab"
>
<h2 :id="titleId">
<slot name="title">默认标题</slot>
</h2>
<div class="modal-body">
<slot />
</div>
<div class="modal-footer">
<button @click="handleClose">关闭</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch, nextTick, onMounted, useId } from 'vue'
const props = defineProps({
modelValue: Boolean
})
const emit = defineEmits(['update:modelValue'])
const titleId = useId()
const contentRef = ref(null)
let lastActiveElement = null
const handleClose = () => {
emit('update:modelValue', false)
}
// 焦点管理
watch(() => props.modelValue, (newVal) => {
if (newVal) {
lastActiveElement = document.activeElement
nextTick(() => {
contentRef.value?.focus()
})
} else {
lastActiveElement?.focus()
}
})
// 键盘导航限制
const handleTab = (e) => {
const focusable = contentRef.value.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
last.focus()
e.preventDefault()
} else if (!e.shiftKey && document.activeElement === last) {
first.focus()
e.preventDefault()
}
}
// 初始焦点设置
onMounted(() => {
if (props.modelValue) {
contentRef.value?.focus()
}
})
</script>
<style scoped>
.modal-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
min-width: 300px;
max-width: 90vw;
outline: none;
}
.modal-body {
margin: 1.5rem 0;
}
.modal-footer {
text-align: right;
}
button {
padding: 0.5rem 1rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
五、组件使用示例
5.1 基础使用
<template>
<button @click="showModal = true">打开设置</button>
<AccessibleModal v-model="showModal">
<template #title>用户偏好设置</template>
<div class="settings-form">
<label>主题颜色:<input type="color" /></label>
</div>
</AccessibleModal>
</template>
5.2 多弹窗场景
<template>
<AccessibleModal v-model="showHelp" class="help-modal">
<template #title>帮助中心</template>
<p>这里是帮助文档内容...</p>
</AccessibleModal>
<AccessibleModal v-model="showAlert" class="alert-modal">
<p class="warning-text">⚠️ 数据保存失败!</p>
</AccessibleModal>
</template>
使用实践:
- 通过CSS类名定制不同场景样式
- 使用具名插槽分离标题和内容区域
- 多个弹窗自动按声明顺序堆叠
5.3 运行效果
可以看到,dialog最终渲染在了body之下,同时也支持了无障碍访问。
六、2个方案优势对比
| 特性 | 传统方案 | Teleport方案 |
|---|---|---|
| 无障碍支持 | 需手动实现 | 原生ARIA属性 |
| 焦点管理 | 复杂实现 | 自动追踪 |
| 多弹窗层级 | 手动控制 | 自动堆叠 |
| 代码可维护性 | 低 | 高 |
性能提示:使用v-show替代v-if可保留组件状态,适合频繁开关的弹窗场景
通过Teleport与ARIA属性的结合,该方案已在实际项目中验证可达到WCAG 2.1 AA级无障碍标准,同时保持代码简洁和可维护性。大家可根据业务需求扩展键盘导航、语音朗读等高级功能。
分享完毕感谢阅读,希望能对你有所帮助。以上代码已经分享到Gitee上,如有所需,欢迎自取。
(完)