9.9 KiB
Sửa Lỗi Trợ Năng - Hướng Dẫn Triển Khai Chi Tiết
Sửa #1: aria-label cho Input Tải Tệp Lên
Tệp: apps/web/components/listings/image-upload.tsx
Dòng: 118
Trước
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}}
/>
Sau
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
aria-label="Chọn ảnh để tải lên"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}}
/>
Lý do: Các input ẩn cần aria-label để trình đọc màn hình có thể thông báo mục đích của chúng khi được lấy tiêu điểm.
Sửa #2: aria-label cho Input Hộp Thoại Tìm Kiếm
Tệp: apps/web/app/[locale]/(public)/search/page.tsx
Dòng: 189
Trước
<input
type="text"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="Tên tìm kiếm (VD: Chung cư Q7 dưới 3 tỷ)"
className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
maxLength={100}
onKeyDown={(e) => e.key === 'Enter' && handleSaveSearch()}
/>
Sau
<input
type="text"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="Tên tìm kiếm (VD: Chung cư Q7 dưới 3 tỷ)"
aria-label="Tên bộ lọc tìm kiếm"
className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
maxLength={100}
onKeyDown={(e) => e.key === 'Enter' && handleSaveSearch()}
/>
Lý do: Các input văn bản cần aria-label khi không có phần tử nhãn liên kết. Placeholder không thể thay thế cho aria-label.
Sửa #3: Quản Trị Kiểm Duyệt - Checkbox Chọn Tất Cả
Tệp: apps/web/app/[locale]/(admin)/admin/moderation/page.tsx
Dòng: 222
Trước
<TableHead className="w-10">
<input
type="checkbox"
checked={selected.size === result.data.length && result.data.length > 0}
onChange={toggleSelectAll}
className="rounded border-input"
/>
</TableHead>
Sau
<TableHead className="w-10">
<input
type="checkbox"
aria-label="Chọn tất cả tin đăng"
checked={selected.size === result.data.length && result.data.length > 0}
onChange={toggleSelectAll}
className="rounded border-input"
/>
</TableHead>
Lý do: Checkbox trong tiêu đề bảng cần aria-label để phân biệt với các checkbox trên từng hàng.
Sửa #4: Quản Trị Kiểm Duyệt - Checkbox Từng Hàng
Tệp: apps/web/app/[locale]/(admin)/admin/moderation/page.tsx
Dòng: 242
Trước
<TableCell>
<input
type="checkbox"
checked={selected.has(item.listingId)}
onChange={() => toggleSelect(item.listingId)}
className="rounded border-input"
/>
</TableCell>
Sau (Phương án 1 - Đơn giản)
<TableCell>
<input
type="checkbox"
aria-label={`Chọn tin đăng: ${item.listingId}`}
checked={selected.has(item.listingId)}
onChange={() => toggleSelect(item.listingId)}
className="rounded border-input"
/>
</TableCell>
Sau (Phương án 2 - Tốt hơn với tiêu đề)
<TableCell>
<input
type="checkbox"
aria-label={`Chọn tin đăng: ${item.title || item.listingId}`}
checked={selected.has(item.listingId)}
onChange={() => toggleSelect(item.listingId)}
className="rounded border-input"
/>
</TableCell>
Lý do: Mỗi checkbox cần aria-label duy nhất bao gồm ngữ cảnh về tin đăng mà nó đại diện.
Sửa #5: Component Ảnh Giả Lập trong Kiểm Thử
Tệp: apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx
Dòng: 46
Trước
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
Sau (Phương án 1 - Đơn giản)
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} alt={props.alt || ''} />,
}));
Sau (Phương án 2 - Có Cảnh Báo)
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => {
if (!props.alt) {
console.warn('Image mock: Missing alt attribute', props);
}
return <img {...props} alt={props.alt || 'image'} />;
},
}));
Lý do: Bản giả lập nên bắt buộc thuộc tính alt để phát hiện các ảnh thiếu alt trong kiểm thử trước khi đưa lên môi trường production.
Sửa #6 (Cải Tiến): Trợ Năng Kéo-Thả Tải Ảnh Lên
Tệp: apps/web/components/listings/image-upload.tsx
Dòng: 86-128
Mã Hiện Tại
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
)}
>
<svg>...</svg>
<p className="text-sm font-medium">Kéo thả ảnh vào đây hoặc nhấp để chọn</p>
<p className="mt-1 text-xs text-muted-foreground">
JPG, PNG, WebP - Tối đa {maxFiles} ảnh, mỗi ảnh 10MB
</p>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}}
/>
</div>
Mã Đã Cải Tiến
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
aria-label="Khu vực kéo thả hoặc nhấp để tải ảnh lên"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
inputRef.current?.click();
}
}}
className={cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
)}
>
<svg>...</svg>
<p className="text-sm font-medium">Kéo thả ảnh vào đây hoặc nhấp để chọn</p>
<p className="mt-1 text-xs text-muted-foreground">
JPG, PNG, WebP - Tối đa {maxFiles} ảnh, mỗi ảnh 10MB
</p>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
aria-label="Chọn ảnh để tải lên"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}}
/>
</div>
Các thay đổi:
role="button"- Xác định div là một nút tương táctabIndex={0}- Cho phép div nhận tiêu điểm từ bàn phímaria-label- Mô tả mục đích cho trình đọc màn hình- Trình xử lý
onKeyDown- Cho phép kích hoạt bằng Enter/Space - Kiểu
focus-visible- Hiển thị chỉ báo tiêu điểm khi điều hướng bằng bàn phím
Lý do: Làm cho vùng kéo-thả có thể truy cập hoàn toàn bằng bàn phím cho những người dùng không thể sử dụng chuột.
Kiểm Tra Sau Khi Triển Khai
1. Kiểm Tra Trình Đọc Màn Hình
# Sử dụng VoiceOver (Mac), NVDA (Windows), hoặc JAWS
# Điều hướng đến từng phần tử đã sửa và xác nhận:
# - Input được thông báo với aria-label của nó
# - Checkbox được thông báo với aria-label của nó
# - Mục đích rõ ràng qua thông báo của trình đọc màn hình
2. Kiểm Tra Điều Hướng Bằng Bàn Phím
# Tab qua trang
# Xác nhận:
# - Tất cả các phần tử tương tác có thể truy cập bằng Tab
# - Tiêu điểm hiển thị rõ trên tất cả các phần tử
# - Enter/Space kích hoạt các nút và checkbox
# - Vùng kéo-thả tải ảnh có thể nhận tiêu điểm và kích hoạt bằng bàn phím
3. Kiểm Tra Tự Động
# Chạy axe
npm run test:a11y
# Hoặc dùng Lighthouse
npx lighthouse https://localhost:3000 --view
# Plugin ESLint JSX Accessibility nên phát hiện các vấn đề này:
npm run lint
4. Kiểm Tra Trực Quan
# Xác nhận bằng công cụ dành cho nhà phát triển trong trình duyệt:
# - Kiểm tra từng input để xác nhận thuộc tính aria-label tồn tại
# - Kiểm tra kiểu tiêu điểm phù hợp
# - Xác nhận màu vòng tiêu điểm đáp ứng yêu cầu độ tương phản
Tóm Tắt Các Thay Đổi
| Vấn đề | Tệp | Dòng | Loại | Mức độ nghiêm trọng |
|---|---|---|---|---|
| Input tệp thiếu aria-label | image-upload.tsx | 118 | aria-label | CAO |
| Input tìm kiếm thiếu aria-label | search/page.tsx | 189 | aria-label | CAO |
| Checkbox tiêu đề thiếu aria-label | moderation/page.tsx | 222 | aria-label | CAO |
| Checkbox hàng thiếu aria-label | moderation/page.tsx | 242 | aria-label | CAO |
| Ảnh giả lập thiếu alt | search.spec.tsx | 46 | thuộc tính alt | TRUNG BÌNH |
| Vùng kéo-thả không thể truy cập bằng bàn phím | image-upload.tsx | 86-128 | cải tiến | TRUNG BÌNH |
Thời Gian Triển Khai Ước Tính
- Sửa #1: 2 phút
- Sửa #2: 2 phút
- Sửa #3: 2 phút
- Sửa #4: 3 phút (cần tìm item.title trong ngữ cảnh)
- Sửa #5: 2 phút
- Sửa #6: 10 phút
- Kiểm tra: 15-20 phút
Tổng cộng: ~35-45 phút