使用Vue3 Teleport实现无障碍模态框

178 阅读3分钟

cover.png

一、传统弹窗的三大痛点

<!-- 传统弹窗结构 -->
<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>

核心要点

  1. @click.self修饰符确保仅点击遮罩层触发关闭
  2. role="dialog"声明对话框语义角色
  3. 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()
  }
})

实现原理

  1. 弹窗打开时锁定焦点在内容区域
  2. 关闭时恢复原始焦点位置
  3. 禁用背景元素的键盘导航

四、完整实现方案

<!-- 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 运行效果

image.png

可以看到,dialog最终渲染在了body之下,同时也支持了无障碍访问。

六、2个方案优势对比

特性传统方案Teleport方案
无障碍支持需手动实现原生ARIA属性
焦点管理复杂实现自动追踪
多弹窗层级手动控制自动堆叠
代码可维护性

性能提示:使用v-show替代v-if可保留组件状态,适合频繁开关的弹窗场景

通过Teleport与ARIA属性的结合,该方案已在实际项目中验证可达到WCAG 2.1 AA级无障碍标准,同时保持代码简洁和可维护性。大家可根据业务需求扩展键盘导航、语音朗读等高级功能。


分享完毕感谢阅读,希望能对你有所帮助。以上代码已经分享到Gitee上,如有所需,欢迎自取。
(完)