# 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 ```tsx { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }} /> ``` ### Sau ```tsx { 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 ```tsx 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 ```tsx 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 ```tsx 0} onChange={toggleSelectAll} className="rounded border-input" /> ``` ### Sau ```tsx 0} onChange={toggleSelectAll} className="rounded border-input" /> ``` **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 ```tsx toggleSelect(item.listingId)} className="rounded border-input" /> ``` ### Sau (Phương án 1 - Đơn giản) ```tsx toggleSelect(item.listingId)} className="rounded border-input" /> ``` ### Sau (Phương án 2 - Tốt hơn với tiêu đề) ```tsx toggleSelect(item.listingId)} className="rounded border-input" /> ``` **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 ```tsx vi.mock('next/image', () => ({ default: (props: Record) => , })); ``` ### Sau (Phương án 1 - Đơn giản) ```tsx vi.mock('next/image', () => ({ default: (props: Record) => {props.alt, })); ``` ### Sau (Phương án 2 - Có Cảnh Báo) ```tsx vi.mock('next/image', () => ({ default: (props: Record) => { if (!props.alt) { console.warn('Image mock: Missing alt attribute', props); } return {props.alt; }, })); ``` **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 ```tsx
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', )} > ...

Kéo thả ảnh vào đây hoặc nhấp để chọn

JPG, PNG, WebP - Tối đa {maxFiles} ảnh, mỗi ảnh 10MB

{ if (e.target.files) addFiles(e.target.files); e.target.value = ''; }} />
``` ### Mã Đã Cải Tiến ```tsx
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', )} > ...

Kéo thả ảnh vào đây hoặc nhấp để chọn

JPG, PNG, WebP - Tối đa {maxFiles} ảnh, mỗi ảnh 10MB

{ if (e.target.files) addFiles(e.target.files); e.target.value = ''; }} />
``` **Các thay đổi**: - `role="button"` - Xác định div là một nút tương tác - `tabIndex={0}` - Cho phép div nhận tiêu điểm từ bàn phím - `aria-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 ```bash # 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 ```bash # 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 ```bash # 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 ```bash # 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**