第64天:Element Plus 键盘导航与屏幕阅读器支持
学习目标
- 掌握键盘导航的设计原则和实现方法
- 学习屏幕阅读器的工作原理和适配技巧
- 理解焦点管理和键盘陷阱的概念
- 实现完整的键盘和屏幕阅读器支持方案
知识点概览
1. 键盘导航基础
1.1 键盘导航原则
- 可达性:所有交互元素都应该可以通过键盘访问
- 可预测性:键盘行为应该符合用户期望
- 高效性:提供快捷键和跳转功能
- 可见性:清晰的焦点指示器
1.2 标准键盘快捷键
typescript
// types/keyboard.ts
// 键盘事件类型定义
export interface KeyboardEventMap {
// 导航键
'Tab': () => void
'Shift+Tab': () => void
'ArrowUp': () => void
'ArrowDown': () => void
'ArrowLeft': () => void
'ArrowRight': () => void
'Home': () => void
'End': () => void
'PageUp': () => void
'PageDown': () => void
// 激活键
'Enter': () => void
'Space': () => void
// 功能键
'Escape': () => void
'Delete': () => void
'Backspace': () => void
// 组合键
'Ctrl+A': () => void
'Ctrl+C': () => void
'Ctrl+V': () => void
'Ctrl+Z': () => void
}
// 键盘导航管理器
export class KeyboardNavigationManager {
private handlers: Map<string, () => void> = new Map()
private element: HTMLElement
private isActive: boolean = false
constructor(element: HTMLElement) {
this.element = element
this.bindEvents()
}
// 注册键盘事件处理器
register<K extends keyof KeyboardEventMap>(key: K, handler: KeyboardEventMap[K]): void {
this.handlers.set(key, handler)
}
// 注销键盘事件处理器
unregister(key: keyof KeyboardEventMap): void {
this.handlers.delete(key)
}
// 激活键盘导航
activate(): void {
this.isActive = true
this.element.addEventListener('keydown', this.handleKeydown)
}
// 停用键盘导航
deactivate(): void {
this.isActive = false
this.element.removeEventListener('keydown', this.handleKeydown)
}
// 键盘事件处理
private handleKeydown = (event: KeyboardEvent): void => {
if (!this.isActive) return
const key = this.getKeyString(event)
const handler = this.handlers.get(key)
if (handler) {
event.preventDefault()
handler()
}
}
// 获取键盘组合字符串
private getKeyString(event: KeyboardEvent): string {
const parts: string[] = []
if (event.ctrlKey) parts.push('Ctrl')
if (event.altKey) parts.push('Alt')
if (event.shiftKey) parts.push('Shift')
if (event.metaKey) parts.push('Meta')
parts.push(event.key)
return parts.join('+')
}
// 绑定事件
private bindEvents(): void {
// 默认绑定一些通用的键盘事件
this.element.addEventListener('focus', () => {
this.activate()
})
this.element.addEventListener('blur', () => {
this.deactivate()
})
}
// 销毁
destroy(): void {
this.deactivate()
this.handlers.clear()
}
}
2. 焦点管理系统
2.1 焦点管理器
typescript
// utils/focus-manager.ts
export interface FocusableElement extends HTMLElement {
focus(options?: FocusOptions): void
blur(): void
}
export interface FocusManagerOptions {
container?: HTMLElement
autoRestore?: boolean
trapFocus?: boolean
initialFocus?: HTMLElement | string
fallbackFocus?: HTMLElement | string
}
export class FocusManager {
private container: HTMLElement
private options: FocusManagerOptions
private previousActiveElement: Element | null = null
private focusableElements: FocusableElement[] = []
private currentIndex: number = -1
private isTrapping: boolean = false
constructor(container: HTMLElement, options: FocusManagerOptions = {}) {
this.container = container
this.options = {
autoRestore: true,
trapFocus: false,
...options
}
this.updateFocusableElements()
}
// 更新可聚焦元素列表
updateFocusableElements(): void {
const selector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]'
].join(', ')
this.focusableElements = Array.from(
this.container.querySelectorAll(selector)
).filter(el => {
return this.isVisible(el as HTMLElement) && !this.isDisabled(el as HTMLElement)
}) as FocusableElement[]
}
// 检查元素是否可见
private isVisible(element: HTMLElement): boolean {
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
element.offsetParent !== null
)
}
// 检查元素是否被禁用
private isDisabled(element: HTMLElement): boolean {
return element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true'
}
// 开始焦点管理
start(): void {
this.previousActiveElement = document.activeElement
if (this.options.trapFocus) {
this.trapFocus()
}
this.setInitialFocus()
}
// 停止焦点管理
stop(): void {
if (this.options.trapFocus) {
this.untrapFocus()
}
if (this.options.autoRestore && this.previousActiveElement) {
(this.previousActiveElement as HTMLElement).focus()
}
}
// 设置初始焦点
private setInitialFocus(): void {
let initialElement: HTMLElement | null = null
if (this.options.initialFocus) {
if (typeof this.options.initialFocus === 'string') {
initialElement = this.container.querySelector(this.options.initialFocus)
} else {
initialElement = this.options.initialFocus
}
}
if (!initialElement && this.focusableElements.length > 0) {
initialElement = this.focusableElements[0]
}
if (initialElement) {
initialElement.focus()
this.currentIndex = this.focusableElements.indexOf(initialElement)
}
}
// 陷阱焦点
private trapFocus(): void {
this.isTrapping = true
document.addEventListener('keydown', this.handleTrapKeydown)
}
// 取消陷阱焦点
private untrapFocus(): void {
this.isTrapping = false
document.removeEventListener('keydown', this.handleTrapKeydown)
}
// 处理陷阱键盘事件
private handleTrapKeydown = (event: KeyboardEvent): void => {
if (!this.isTrapping || event.key !== 'Tab') return
this.updateFocusableElements()
if (this.focusableElements.length === 0) {
event.preventDefault()
return
}
const firstElement = this.focusableElements[0]
const lastElement = this.focusableElements[this.focusableElements.length - 1]
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
}
// 聚焦到下一个元素
focusNext(): boolean {
this.updateFocusableElements()
if (this.focusableElements.length === 0) return false
this.currentIndex = (this.currentIndex + 1) % this.focusableElements.length
this.focusableElements[this.currentIndex].focus()
return true
}
// 聚焦到上一个元素
focusPrevious(): boolean {
this.updateFocusableElements()
if (this.focusableElements.length === 0) return false
this.currentIndex = this.currentIndex <= 0
? this.focusableElements.length - 1
: this.currentIndex - 1
this.focusableElements[this.currentIndex].focus()
return true
}
// 聚焦到第一个元素
focusFirst(): boolean {
this.updateFocusableElements()
if (this.focusableElements.length === 0) return false
this.currentIndex = 0
this.focusableElements[this.currentIndex].focus()
return true
}
// 聚焦到最后一个元素
focusLast(): boolean {
this.updateFocusableElements()
if (this.focusableElements.length === 0) return false
this.currentIndex = this.focusableElements.length - 1
this.focusableElements[this.currentIndex].focus()
return true
}
}
2.2 键盘导航组件
vue
<!-- KeyboardNavigationProvider.vue -->
<template>
<div
ref="containerRef"
:class="containerClasses"
:tabindex="containerTabindex"
@keydown="handleKeydown"
@focus="handleFocus"
@blur="handleBlur"
>
<slot :focus-manager="focusManager" />
<!-- 跳过链接 -->
<a
v-if="showSkipLink"
ref="skipLinkRef"
:href="skipLinkTarget"
class="skip-link"
@click="handleSkipLink"
>
{{ skipLinkText }}
</a>
<!-- 键盘提示 -->
<div
v-if="showKeyboardHints && isKeyboardUser"
class="keyboard-hints"
role="status"
aria-live="polite"
>
<div class="keyboard-hint" v-for="hint in keyboardHints" :key="hint.key">
<kbd>{{ hint.key }}</kbd> {{ hint.description }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, provide } from 'vue'
import { FocusManager, type FocusManagerOptions } from '@/utils/focus-manager'
import { KeyboardNavigationManager } from '@/utils/keyboard-navigation'
interface KeyboardHint {
key: string
description: string
}
interface Props {
// 焦点管理选项
trapFocus?: boolean
autoRestore?: boolean
initialFocus?: string
// 键盘导航选项
enableArrowKeys?: boolean
enableHomeEnd?: boolean
enablePageKeys?: boolean
// 跳过链接
showSkipLink?: boolean
skipLinkText?: string
skipLinkTarget?: string
// 键盘提示
showKeyboardHints?: boolean
customHints?: KeyboardHint[]
// 样式
containerTabindex?: number
}
const props = withDefaults(defineProps<Props>(), {
trapFocus: false,
autoRestore: true,
enableArrowKeys: true,
enableHomeEnd: true,
enablePageKeys: false,
showSkipLink: false,
skipLinkText: '跳到主要内容',
skipLinkTarget: '#main-content',
showKeyboardHints: false,
containerTabindex: -1
})
const emit = defineEmits<{
focus: [event: FocusEvent]
blur: [event: FocusEvent]
keydown: [event: KeyboardEvent]
navigate: [direction: 'next' | 'previous' | 'first' | 'last']
}>()
// 响应式数据
const containerRef = ref<HTMLElement>()
const skipLinkRef = ref<HTMLAnchorElement>()
const focusManager = ref<FocusManager>()
const keyboardManager = ref<KeyboardNavigationManager>()
const isKeyboardUser = ref(false)
const isFocused = ref(false)
// 计算属性
const containerClasses = computed(() => ({
'keyboard-navigation-container': true,
'keyboard-navigation-container--focused': isFocused.value,
'keyboard-navigation-container--keyboard-user': isKeyboardUser.value
}))
const keyboardHints = computed((): KeyboardHint[] => {
const defaultHints: KeyboardHint[] = []
if (props.enableArrowKeys) {
defaultHints.push(
{ key: '↑↓', description: '上下导航' },
{ key: '←→', description: '左右导航' }
)
}
if (props.enableHomeEnd) {
defaultHints.push(
{ key: 'Home', description: '跳到开始' },
{ key: 'End', description: '跳到结束' }
)
}
defaultHints.push(
{ key: 'Tab', description: '下一个元素' },
{ key: 'Shift+Tab', description: '上一个元素' },
{ key: 'Enter/Space', description: '激活' },
{ key: 'Esc', description: '取消/关闭' }
)
return [...defaultHints, ...(props.customHints || [])]
})
// 方法
const initializeManagers = () => {
if (!containerRef.value) return
// 初始化焦点管理器
const focusOptions: FocusManagerOptions = {
container: containerRef.value,
trapFocus: props.trapFocus,
autoRestore: props.autoRestore,
initialFocus: props.initialFocus
}
focusManager.value = new FocusManager(containerRef.value, focusOptions)
// 初始化键盘导航管理器
keyboardManager.value = new KeyboardNavigationManager(containerRef.value)
// 注册键盘事件
registerKeyboardEvents()
}
const registerKeyboardEvents = () => {
if (!keyboardManager.value || !focusManager.value) return
const km = keyboardManager.value
const fm = focusManager.value
if (props.enableArrowKeys) {
km.register('ArrowDown', () => {
fm.focusNext()
emit('navigate', 'next')
})
km.register('ArrowUp', () => {
fm.focusPrevious()
emit('navigate', 'previous')
})
km.register('ArrowRight', () => {
fm.focusNext()
emit('navigate', 'next')
})
km.register('ArrowLeft', () => {
fm.focusPrevious()
emit('navigate', 'previous')
})
}
if (props.enableHomeEnd) {
km.register('Home', () => {
fm.focusFirst()
emit('navigate', 'first')
})
km.register('End', () => {
fm.focusLast()
emit('navigate', 'last')
})
}
if (props.enablePageKeys) {
km.register('PageDown', () => {
// 实现分页导航逻辑
for (let i = 0; i < 5; i++) {
if (!fm.focusNext()) break
}
emit('navigate', 'next')
})
km.register('PageUp', () => {
// 实现分页导航逻辑
for (let i = 0; i < 5; i++) {
if (!fm.focusPrevious()) break
}
emit('navigate', 'previous')
})
}
}
const handleKeydown = (event: KeyboardEvent) => {
// 检测键盘用户
isKeyboardUser.value = true
emit('keydown', event)
}
const handleFocus = (event: FocusEvent) => {
isFocused.value = true
if (focusManager.value) {
focusManager.value.start()
}
emit('focus', event)
}
const handleBlur = (event: FocusEvent) => {
// 检查焦点是否仍在容器内
const relatedTarget = event.relatedTarget as HTMLElement
if (!containerRef.value?.contains(relatedTarget)) {
isFocused.value = false
if (focusManager.value) {
focusManager.value.stop()
}
}
emit('blur', event)
}
const handleSkipLink = (event: Event) => {
event.preventDefault()
const target = document.querySelector(props.skipLinkTarget)
if (target) {
(target as HTMLElement).focus()
}
}
// 检测鼠标用户
const handleMouseMove = () => {
isKeyboardUser.value = false
}
// 生命周期
onMounted(() => {
initializeManagers()
// 监听鼠标移动以检测鼠标用户
document.addEventListener('mousemove', handleMouseMove)
})
onUnmounted(() => {
if (focusManager.value) {
focusManager.value.stop()
}
if (keyboardManager.value) {
keyboardManager.value.destroy()
}
document.removeEventListener('mousemove', handleMouseMove)
})
// 提供给子组件
provide('keyboardNavigation', {
focusManager,
keyboardManager,
isKeyboardUser
})
</script>
<style scoped>
.keyboard-navigation-container {
position: relative;
outline: none;
}
.keyboard-navigation-container--focused {
/* 容器获得焦点时的样式 */
}
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
border-radius: 4px;
z-index: 1000;
transition: top 0.3s;
}
.skip-link:focus {
top: 6px;
}
.keyboard-hints {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px;
border-radius: 8px;
font-size: 12px;
z-index: 1000;
max-width: 200px;
}
.keyboard-hint {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.keyboard-hint:last-child {
margin-bottom: 0;
}
kbd {
background: #333;
border: 1px solid #555;
border-radius: 3px;
padding: 2px 4px;
margin-right: 8px;
font-family: monospace;
font-size: 10px;
min-width: 20px;
text-align: center;
}
/* 高对比度模式 */
@media (prefers-contrast: high) {
.keyboard-navigation-container--focused {
outline: 3px solid;
}
.keyboard-hints {
background: black;
border: 2px solid white;
}
}
/* 减少动画模式 */
@media (prefers-reduced-motion: reduce) {
.skip-link {
transition: none;
}
}
</style>
3. 屏幕阅读器支持
3.1 屏幕阅读器工具类
typescript
// utils/screen-reader.ts
export interface ScreenReaderAnnouncement {
message: string
priority: 'polite' | 'assertive'
delay?: number
}
export class ScreenReaderManager {
private static instance: ScreenReaderManager
private announceContainer: HTMLElement
private politeRegion: HTMLElement
private assertiveRegion: HTMLElement
private constructor() {
this.createAnnounceRegions()
}
static getInstance(): ScreenReaderManager {
if (!ScreenReaderManager.instance) {
ScreenReaderManager.instance = new ScreenReaderManager()
}
return ScreenReaderManager.instance
}
// 创建公告区域
private createAnnounceRegions(): void {
// 创建容器
this.announceContainer = document.createElement('div')
this.announceContainer.className = 'sr-announce-container'
this.announceContainer.style.cssText = `
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
`
// 创建 polite 区域
this.politeRegion = document.createElement('div')
this.politeRegion.setAttribute('aria-live', 'polite')
this.politeRegion.setAttribute('aria-atomic', 'true')
this.politeRegion.className = 'sr-polite-region'
// 创建 assertive 区域
this.assertiveRegion = document.createElement('div')
this.assertiveRegion.setAttribute('aria-live', 'assertive')
this.assertiveRegion.setAttribute('aria-atomic', 'true')
this.assertiveRegion.className = 'sr-assertive-region'
this.announceContainer.appendChild(this.politeRegion)
this.announceContainer.appendChild(this.assertiveRegion)
document.body.appendChild(this.announceContainer)
}
// 公告消息
announce(announcement: ScreenReaderAnnouncement): void {
const { message, priority, delay = 0 } = announcement
setTimeout(() => {
const region = priority === 'assertive' ? this.assertiveRegion : this.politeRegion
// 清空区域
region.textContent = ''
// 稍后设置消息(确保屏幕阅读器能检测到变化)
setTimeout(() => {
region.textContent = message
}, 10)
// 清空消息(避免重复读取)
setTimeout(() => {
region.textContent = ''
}, 1000)
}, delay)
}
// 公告状态变化
announceStateChange(element: HTMLElement, state: string, value: any): void {
const elementName = this.getElementName(element)
const message = `${elementName} ${state} ${value}`
this.announce({
message,
priority: 'polite'
})
}
// 公告导航变化
announceNavigation(from: string, to: string, total?: number, current?: number): void {
let message = `从 ${from} 导航到 ${to}`
if (total && current) {
message += `,第 ${current} 项,共 ${total} 项`
}
this.announce({
message,
priority: 'polite'
})
}
// 公告错误信息
announceError(error: string): void {
this.announce({
message: `错误:${error}`,
priority: 'assertive'
})
}
// 公告成功信息
announceSuccess(message: string): void {
this.announce({
message: `成功:${message}`,
priority: 'polite'
})
}
// 获取元素名称
private getElementName(element: HTMLElement): string {
// 优先级:aria-label > aria-labelledby > title > textContent
const ariaLabel = element.getAttribute('aria-label')
if (ariaLabel) return ariaLabel
const labelledBy = element.getAttribute('aria-labelledby')
if (labelledBy) {
const labelElement = document.getElementById(labelledBy)
if (labelElement) return labelElement.textContent || ''
}
const title = element.getAttribute('title')
if (title) return title
return element.textContent || element.tagName.toLowerCase()
}
// 销毁
destroy(): void {
if (this.announceContainer && this.announceContainer.parentNode) {
this.announceContainer.parentNode.removeChild(this.announceContainer)
}
}
}
// 屏幕阅读器组合函数
export function useScreenReader() {
const screenReader = ScreenReaderManager.getInstance()
const announce = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
screenReader.announce({ message, priority })
}
const announceStateChange = (element: HTMLElement, state: string, value: any) => {
screenReader.announceStateChange(element, state, value)
}
const announceNavigation = (from: string, to: string, total?: number, current?: number) => {
screenReader.announceNavigation(from, to, total, current)
}
const announceError = (error: string) => {
screenReader.announceError(error)
}
const announceSuccess = (message: string) => {
screenReader.announceSuccess(message)
}
return {
announce,
announceStateChange,
announceNavigation,
announceError,
announceSuccess
}
}
3.2 屏幕阅读器友好的数据表格
vue
<!-- AccessibleTable.vue -->
<template>
<div class="accessible-table-container">
<!-- 表格标题和描述 -->
<div v-if="caption || description" class="table-header">
<h3 v-if="caption" :id="captionId" class="table-caption">
{{ caption }}
</h3>
<p v-if="description" :id="descriptionId" class="table-description">
{{ description }}
</p>
</div>
<!-- 表格控制 -->
<div v-if="showControls" class="table-controls">
<div class="table-info" role="status" aria-live="polite">
显示第 {{ startIndex + 1 }} - {{ endIndex }} 项,共 {{ totalItems }} 项
</div>
<div class="table-actions">
<el-button
size="small"
@click="selectAll"
:aria-label="allSelected ? '取消全选' : '全选'"
>
{{ allSelected ? '取消全选' : '全选' }}
</el-button>
<el-button
size="small"
@click="exportData"
aria-label="导出表格数据"
>
导出
</el-button>
</div>
</div>
<!-- 表格 -->
<table
:id="tableId"
class="accessible-table"
role="table"
:aria-labelledby="captionId"
:aria-describedby="descriptionId"
:aria-rowcount="totalItems"
:aria-colcount="columns.length"
>
<!-- 表头 -->
<thead>
<tr role="row">
<th
v-if="selectable"
scope="col"
class="selection-column"
:aria-label="'选择列'"
>
<el-checkbox
v-model="allSelected"
:indeterminate="someSelected"
@change="handleSelectAll"
:aria-label="allSelected ? '取消全选' : '全选所有行'"
/>
</th>
<th
v-for="(column, index) in columns"
:key="column.key"
scope="col"
:class="getColumnClasses(column)"
:aria-sort="getSortDirection(column.key)"
:tabindex="column.sortable ? 0 : -1"
@click="column.sortable && handleSort(column.key)"
@keydown="handleHeaderKeydown($event, column)"
>
<div class="column-header">
<span class="column-title">{{ column.title }}</span>
<el-icon v-if="column.sortable" class="sort-icon">
<ArrowUp v-if="sortKey === column.key && sortOrder === 'asc'" />
<ArrowDown v-else-if="sortKey === column.key && sortOrder === 'desc'" />
<Sort v-else />
</el-icon>
</div>
<!-- 列描述(屏幕阅读器) -->
<span v-if="column.description" class="sr-only">
{{ column.description }}
</span>
</th>
</tr>
</thead>
<!-- 表体 -->
<tbody>
<tr
v-for="(row, rowIndex) in paginatedData"
:key="getRowKey(row, rowIndex)"
role="row"
:class="getRowClasses(row, rowIndex)"
:aria-rowindex="startIndex + rowIndex + 1"
:aria-selected="isRowSelected(row)"
@click="handleRowClick(row, rowIndex)"
@keydown="handleRowKeydown($event, row, rowIndex)"
:tabindex="rowIndex === focusedRowIndex ? 0 : -1"
>
<td v-if="selectable" class="selection-cell">
<el-checkbox
:model-value="isRowSelected(row)"
@change="handleRowSelect(row, $event)"
:aria-label="`选择第 ${startIndex + rowIndex + 1} 行`"
/>
</td>
<td
v-for="(column, colIndex) in columns"
:key="column.key"
:class="getCellClasses(column, row)"
:aria-describedby="getCellDescribedBy(column, row)"
role="gridcell"
:tabindex="getCellTabindex(rowIndex, colIndex)"
@focus="handleCellFocus(rowIndex, colIndex)"
>
<slot
:name="column.key"
:row="row"
:column="column"
:value="row[column.key]"
:row-index="rowIndex"
:col-index="colIndex"
>
{{ formatCellValue(row[column.key], column) }}
</slot>
</td>
</tr>
<!-- 空状态 -->
<tr v-if="paginatedData.length === 0" class="empty-row">
<td :colspan="columns.length + (selectable ? 1 : 0)" class="empty-cell">
<div class="empty-content">
<p>暂无数据</p>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div v-if="showPagination" class="table-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="totalItems"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { ArrowUp, ArrowDown, Sort } from '@element-plus/icons-vue'
import { useScreenReader } from '@/utils/screen-reader'
import { AriaUtils } from '@/utils/aria'
interface TableColumn {
key: string
title: string
description?: string
sortable?: boolean
width?: string
align?: 'left' | 'center' | 'right'
formatter?: (value: any) => string
}
interface Props {
data: any[]
columns: TableColumn[]
caption?: string
description?: string
selectable?: boolean
showControls?: boolean
showPagination?: boolean
pageSize?: number
rowKey?: string | ((row: any) => string)
}
const props = withDefaults(defineProps<Props>(), {
selectable: false,
showControls: true,
showPagination: true,
pageSize: 20,
rowKey: 'id'
})
const emit = defineEmits<{
'row-click': [row: any, index: number]
'selection-change': [selectedRows: any[]]
'sort-change': [key: string, order: 'asc' | 'desc' | null]
}>()
// 屏幕阅读器
const { announce, announceStateChange } = useScreenReader()
// 响应式数据
const tableId = ref(AriaUtils.generateId('table'))
const captionId = ref(AriaUtils.generateId('caption'))
const descriptionId = ref(AriaUtils.generateId('desc'))
const currentPage = ref(1)
const sortKey = ref<string | null>(null)
const sortOrder = ref<'asc' | 'desc' | null>(null)
const selectedRows = ref<any[]>([])
const focusedRowIndex = ref(0)
const focusedColIndex = ref(0)
// 计算属性
const totalItems = computed(() => props.data.length)
const startIndex = computed(() => (currentPage.value - 1) * props.pageSize)
const endIndex = computed(() => Math.min(startIndex.value + props.pageSize, totalItems.value))
const sortedData = computed(() => {
if (!sortKey.value || !sortOrder.value) {
return props.data
}
return [...props.data].sort((a, b) => {
const aVal = a[sortKey.value!]
const bVal = b[sortKey.value!]
if (aVal < bVal) return sortOrder.value === 'asc' ? -1 : 1
if (aVal > bVal) return sortOrder.value === 'asc' ? 1 : -1
return 0
})
})
const paginatedData = computed(() => {
return sortedData.value.slice(startIndex.value, endIndex.value)
})
const allSelected = computed({
get: () => selectedRows.value.length === props.data.length && props.data.length > 0,
set: (value: boolean) => {
if (value) {
selectedRows.value = [...props.data]
} else {
selectedRows.value = []
}
}
})
const someSelected = computed(() => {
return selectedRows.value.length > 0 && selectedRows.value.length < props.data.length
})
// 方法
const getRowKey = (row: any, index: number): string => {
if (typeof props.rowKey === 'function') {
return props.rowKey(row)
}
return row[props.rowKey] || index.toString()
}
const isRowSelected = (row: any): boolean => {
return selectedRows.value.some(selected =>
getRowKey(selected, 0) === getRowKey(row, 0)
)
}
const handleSort = (key: string) => {
if (sortKey.value === key) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : sortOrder.value === 'desc' ? null : 'asc'
} else {
sortKey.value = key
sortOrder.value = 'asc'
}
if (sortOrder.value === null) {
sortKey.value = null
}
emit('sort-change', key, sortOrder.value)
// 公告排序变化
const column = props.columns.find(col => col.key === key)
if (column) {
const orderText = sortOrder.value === 'asc' ? '升序' : sortOrder.value === 'desc' ? '降序' : '取消排序'
announce(`${column.title} 列已按 ${orderText} 排序`)
}
}
const getSortDirection = (key: string): string | undefined => {
if (sortKey.value !== key) return 'none'
return sortOrder.value || 'none'
}
const handleSelectAll = (value: boolean) => {
allSelected.value = value
emit('selection-change', selectedRows.value)
announce(value ? '已选择所有行' : '已取消选择所有行')
}
const handleRowSelect = (row: any, selected: boolean) => {
if (selected) {
selectedRows.value.push(row)
} else {
const index = selectedRows.value.findIndex(selected =>
getRowKey(selected, 0) === getRowKey(row, 0)
)
if (index > -1) {
selectedRows.value.splice(index, 1)
}
}
emit('selection-change', selectedRows.value)
}
const handleRowClick = (row: any, index: number) => {
emit('row-click', row, index)
}
const handlePageChange = (page: number) => {
currentPage.value = page
announce(`已跳转到第 ${page} 页`)
}
const handleSizeChange = (size: number) => {
props.pageSize = size
currentPage.value = 1
announce(`每页显示 ${size} 项`)
}
// 键盘导航
const handleHeaderKeydown = (event: KeyboardEvent, column: TableColumn) => {
if ((event.key === 'Enter' || event.key === ' ') && column.sortable) {
event.preventDefault()
handleSort(column.key)
}
}
const handleRowKeydown = (event: KeyboardEvent, row: any, rowIndex: number) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault()
handleRowClick(row, rowIndex)
break
case 'ArrowDown':
event.preventDefault()
if (rowIndex < paginatedData.value.length - 1) {
focusedRowIndex.value = rowIndex + 1
nextTick(() => {
const nextRow = document.querySelector(`[aria-rowindex="${startIndex.value + rowIndex + 2}"]`) as HTMLElement
nextRow?.focus()
})
}
break
case 'ArrowUp':
event.preventDefault()
if (rowIndex > 0) {
focusedRowIndex.value = rowIndex - 1
nextTick(() => {
const prevRow = document.querySelector(`[aria-rowindex="${startIndex.value + rowIndex}"]`) as HTMLElement
prevRow?.focus()
})
}
break
}
}
// 其他方法省略...
</script>
<style scoped>
/* 样式省略... */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
实践练习
练习 1:创建无障碍下拉菜单
开发一个完全支持键盘导航和屏幕阅读器的下拉菜单:
- 完整的键盘导航支持
- 焦点管理和陷阱
- 屏幕阅读器公告
练习 2:实现无障碍标签页组件
构建一个符合 ARIA 规范的标签页组件:
- 标签页键盘导航
- 内容面板焦点管理
- 状态变化公告
练习 3:开发无障碍树形组件
设计一个支持完整键盘操作的树形组件:
- 树节点导航
- 展开/折叠操作
- 多选支持
学习资源
作业
- 完成所有实践练习
- 使用多种屏幕阅读器测试你的组件
- 进行完整的键盘导航测试
- 编写无障碍测试自动化脚本
下一步学习计划
接下来我们将学习 Element Plus 国际化与无障碍综合实践,将前面学到的国际化和无障碍知识进行综合应用,构建一个完整的多语言无障碍应用。