8.0 KiB
8.0 KiB
各框架无障碍审计示例
示例 1:Vue SFC 表单审计
<!-- BEFORE: src/components/LoginForm.vue -->
<template>
<form @submit="handleLogin">
<input type="text" placeholder="Email" v-model="email" />
<input type="password" placeholder="Password" v-model="password" />
<div v-if="error" style="color: red">{{ error }}</div>
<div @click="handleLogin">Sign In</div>
</form>
</template>
检测到的违规项:
| # | WCAG | 严重程度 | 问题描述 |
|---|---|---|---|
| 1 | 1.3.1 | 致命 | 输入框缺少关联的 <label> 元素 |
| 2 | 3.3.2 | 严重 | placeholder 文本被用作唯一标签(输入内容后消失) |
| 3 | 2.1.1 | 致命 | <div @click> 不支持键盘访问 |
| 4 | 4.1.3 | 严重 | 错误信息未向屏幕阅读器播报 |
| 5 | 3.3.1 | 严重 | 错误未以编程方式与输入框关联 |
<!-- AFTER: src/components/LoginForm.vue -->
<template>
<form @submit.prevent="handleLogin" aria-label="Sign in to your account">
<div class="field">
<label for="login-email">Email</label>
<input
id="login-email"
type="email"
v-model="email"
autocomplete="email"
required
:aria-describedby="emailError ? 'email-error' : undefined"
:aria-invalid="!!emailError"
/>
<span v-if="emailError" id="email-error" role="alert">
{{ emailError }}
</span>
</div>
<div class="field">
<label for="login-password">Password</label>
<input
id="login-password"
type="password"
v-model="password"
autocomplete="current-password"
required
:aria-describedby="passwordError ? 'password-error' : undefined"
:aria-invalid="!!passwordError"
/>
<span v-if="passwordError" id="password-error" role="alert">
{{ passwordError }}
</span>
</div>
<div v-if="error" role="alert" aria-live="assertive" class="form-error">
{{ error }}
</div>
<button type="submit">Sign In</button>
</form>
</template>
示例 2:Angular 模板审计
<!-- BEFORE: src/app/dashboard/dashboard.component.html -->
<div class="tabs">
<div *ngFor="let tab of tabs"
(click)="selectTab(tab)"
[class.active]="tab.active">
{{ tab.label }}
</div>
</div>
<div class="tab-content">
<div *ngIf="selectedTab">{{ selectedTab.content }}</div>
</div>
检测到的违规项:
| # | WCAG | 严重程度 | 问题描述 |
|---|---|---|---|
| 1 | 4.1.2 | 致命 | 标签页组件缺少 ARIA 角色(tablist、tab、tabpanel) |
| 2 | 2.1.1 | 致命 | 标签页不支持键盘导航(方向键、Home、End) |
| 3 | 2.4.11 | 严重 | 激活的标签页缺少可见焦点指示器 |
<!-- AFTER: src/app/dashboard/dashboard.component.html -->
<div class="tabs" role="tablist" aria-label="Dashboard sections">
<button
*ngFor="let tab of tabs; let i = index"
role="tab"
[id]="'tab-' + tab.id"
[attr.aria-selected]="tab.active"
[attr.aria-controls]="'panel-' + tab.id"
[attr.tabindex]="tab.active ? 0 : -1"
(click)="selectTab(tab)"
(keydown)="handleTabKeydown($event, i)"
class="tab-button"
[class.active]="tab.active">
{{ tab.label }}
</button>
</div>
<div
*ngIf="selectedTab"
role="tabpanel"
[id]="'panel-' + selectedTab.id"
[attr.aria-labelledby]="'tab-' + selectedTab.id"
tabindex="0"
class="tab-content">
{{ selectedTab.content }}
</div>
配套的键盘导航 TypeScript 代码:
// dashboard.component.ts
handleTabKeydown(event: KeyboardEvent, index: number): void {
const tabCount = this.tabs.length;
let newIndex = index;
switch (event.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabCount;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabCount) % tabCount;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabCount - 1;
break;
default:
return;
}
event.preventDefault();
this.selectTab(this.tabs[newIndex]);
// Move focus to the new tab button
const tabElement = document.getElementById(`tab-${this.tabs[newIndex].id}`);
tabElement?.focus();
}
示例 3:Next.js 页面级审计
// BEFORE: src/app/page.tsx
export default function Home() {
return (
<main>
<div className="text-4xl font-bold">Welcome to Acme</div>
<div className="mt-4">
Build better products with our platform.
</div>
<div className="mt-8 bg-blue-600 text-white px-6 py-3 rounded cursor-pointer"
onClick={() => router.push('/signup')}>
Get Started
</div>
</main>
);
}
检测到的违规项:
| # | WCAG | 严重程度 | 问题描述 |
|---|---|---|---|
| 1 | 1.3.1 | 严重 | 标题使用 <div> 而非 <h1>——缺乏语义结构 |
| 2 | 2.4.2 | 严重 | 页面缺少 <title>(Next.js metadata) |
| 3 | 2.1.1 | 致命 | CTA 使用 <div onClick>——不支持键盘访问 |
| 4 | 3.1.1 | 轻微 | <html> 缺少 lang 属性(检查 layout.tsx) |
// AFTER: src/app/page.tsx
import type { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'Acme - Build Better Products',
description: 'Build better products with the Acme platform.',
};
export default function Home() {
return (
<main>
<h1 className="text-4xl font-bold">Welcome to Acme</h1>
<p className="mt-4">
Build better products with our platform.
</p>
<Link
href="/signup"
className="mt-8 inline-block bg-blue-600 text-white px-6 py-3 rounded
hover:bg-blue-700 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Get Started
</Link>
</main>
);
}
// Also fix: src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
示例 4:Svelte 组件审计
<!-- BEFORE: src/lib/components/Accordion.svelte -->
<script>
let openIndex = -1;
function toggle(i) { openIndex = openIndex === i ? -1 : i; }
</script>
{#each items as item, i}
<div class="header" on:click={() => toggle(i)}>
{item.title}
</div>
{#if openIndex === i}
<div class="content">{item.body}</div>
{/if}
{/each}
检测到的违规项:
| # | WCAG | 严重程度 | 问题描述 |
|---|---|---|---|
| 1 | 4.1.2 | 致命 | 手风琴组件缺少 ARIA 角色和属性 |
| 2 | 2.1.1 | 致命 | 标题不支持键盘访问 |
| 3 | 2.5.8 | 轻微 | 点击目标可能小于 24×24 像素(WCAG 2.2 新增) |
<!-- AFTER: src/lib/components/Accordion.svelte -->
<script>
export let items = [];
let openIndex = -1;
function toggle(i) {
openIndex = openIndex === i ? -1 : i;
}
function handleKeydown(event, i) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle(i);
}
}
</script>
<div class="accordion">
{#each items as item, i}
<h3>
<button
class="accordion-header"
aria-expanded={openIndex === i}
aria-controls="panel-{i}"
id="header-{i}"
on:click={() => toggle(i)}
on:keydown={(e) => handleKeydown(e, i)}
>
{item.title}
<span class="icon" aria-hidden="true">
{openIndex === i ? '−' : '+'}
</span>
</button>
</h3>
<div
id="panel-{i}"
role="region"
aria-labelledby="header-{i}"
class="accordion-content"
class:open={openIndex === i}
hidden={openIndex !== i}
>
{item.body}
</div>
{/each}
</div>
<style>
.accordion-header {
min-height: 44px; /* WCAG 2.5.8 Target Size */
width: 100%;
padding: 12px 16px;
cursor: pointer;
text-align: left;
}
.accordion-header:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
</style>