Files
goodgo-platform/docs/audits/ACCESSIBILITY_AUDIT_2026-04-10.md
Ho Ngoc Hai 11f2bf26e6
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
chore: update project documentation, audit reports, and initialize IDE configuration files
2026-04-19 03:12:54 +07:00

1553 lines
57 KiB
Markdown

# Báo Cáo Kiểm Tra Toàn Diện Khả Năng Tiếp Cận Frontend GoodGo Platform
**Ngày:** 10 tháng 4, 2026
**Đối tượng kiểm tra:** apps/web (Next.js 15)
**Tổng số tệp đã phân tích:** 90+ tệp TSX/JSX
**Thuộc tính ARIA tìm thấy:** 75 trường hợp trên 14 tệp
---
## Tóm Tắt Điều Hành
Giao diện frontend của GoodGo Platform thể hiện **các thực hành khả năng tiếp cận nền tảng tốt** với liên kết bỏ qua nội dung, các vai trò HTML ngữ nghĩa phù hợp, nhãn ARIA trên các phần tử tương tác và xác thực biểu mẫu. Tuy nhiên, có một số lĩnh vực cần cải thiện:
**Phát hiện chính:**
- ✅ Đã triển khai liên kết bỏ qua nội dung
- ✅ 75 thuộc tính ARIA trên toàn codebase
- ✅ Thành phần Dialog với quản lý tiêu điểm
- ✅ Xử lý lỗi với các thuộc tính ARIA phù hợp
- ⚠️ Một số nơi còn thiếu aria-labels cho các nút chỉ có biểu tượng
- ⚠️ Các nút ảnh thu nhỏ trong thư viện hình ảnh thiếu tên có thể tiếp cận
- ⚠️ Thành phần Dialog thiếu các tính năng khả năng tiếp cận (role, focus trap)
- ⚠️ Chưa có tài liệu về định nghĩa độ tương phản màu sắc
- ❌ Một số ô nhập biểu mẫu thiếu liên kết nhãn phù hợp
---
## 1. PHÂN TÍCH SỬ DỤNG ARIA HIỆN TẠI
### Tóm Tắt
- **Tổng số thuộc tính ARIA:** 75 trường hợp
- **Các tệp sử dụng ARIA:** 14 tệp
- **Phổ biến nhất:** aria-label (41 trường hợp), aria-hidden (17 trường hợp)
### Phân Tích ARIA Chi Tiết Theo Tệp
#### Layout Công Khai (`apps/web/app/[locale]/(public)/layout.tsx`)
**Dòng 49:** `aria-label={t('nav.mainNav')}`
- Vị trí: Phần tử điều hướng trên máy tính để bàn
- Loại: Điểm mốc điều hướng ngữ nghĩa
**Dòng 91:** `aria-label={mobileMenuOpen ? t('nav.closeMenu') : t('nav.openMenu')}`
- Vị trí: Nút hamburger trên di động
- Loại: Nhãn được cập nhật động dựa trên trạng thái
- Trạng thái: ✅ Được triển khai đúng cách
#### Layout Gốc (`apps/web/app/[locale]/layout.tsx`)
**Dòng 105-109:** Liên kết bỏ qua nội dung
```tsx
<a
href="#main-content"
className="fixed left-2 top-2 z-[100] -translate-y-16 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg transition-transform focus:translate-y-0"
>
{t('skipToContent')}
</a>
```
- Trạng thái: ✅ Được triển khai đúng cách với khả năng hiển thị tiêu điểm
- Ẩn theo mặc định với -translate-y-16, hiển thị khi được tiêu điểm
- Liên kết đến `id="main-content"` trên phần tử main
#### Layout Dashboard (`apps/web/app/[locale]/(dashboard)/layout.tsx`)
**Dòng 47:** `aria-label={t('nav.dashboardNav')}`
- Vị trí: Điều hướng thanh bên trên di động
- Loại: Điểm mốc điều hướng
**Dòng 58:** `aria-label={t('nav.closeMenu')}`
- Vị trí: Nút đóng thanh bên trên di động
- Loại: Nút chỉ có biểu tượng với nhãn
**Dòng 79:** `aria-hidden="true"`
- Vị trí: Biểu tượng Emoji trong các mục điều hướng
- Loại: Ẩn nội dung trang trí
**Dòng 95:** `aria-hidden="true"`
- Vị trí: Biểu tượng LogOut
- Loại: Ẩn biểu tượng trang trí
**Dòng 108:** `aria-label={t('nav.openMenu')}`
- Vị trí: Nút hamburger trên di động
- Loại: Nút chỉ có biểu tượng
**Dòng 120:** `aria-label={t('nav.dashboardNav')}`
- Vị trí: Điều hướng trên máy tính để bàn
- Loại: Điểm mốc điều hướng
**Dòng 125:** `aria-label={item.label}`
- Vị trí: Các mục điều hướng
- Loại: Nhãn dư thừa (văn bản đã hiển thị)
- Trạng thái: ⚠️ Không cần thiết khi văn bản đã hiển thị
**Dòng 133:** `aria-hidden="true"`
- Vị trí: Biểu tượng trong các mục điều hướng
- Loại: Nội dung trang trí
**Dòng 150:** `aria-label={theme === 'light' ? t('dashboard.darkMode') : t('dashboard.lightMode')}`
- Vị trí: Nút chuyển đổi chủ đề
- Loại: aria-label động ✅ Tốt
**Dòng 154, 158:** `aria-hidden="true"` trên SVG
- Vị trí: Biểu tượng chuyển đổi chủ đề
- Loại: Ẩn nội dung trang trí
#### Layout Quản Trị (`apps/web/app/[locale]/(admin)/layout.tsx`)
**Dòng 60:** `aria-hidden="true"`
- Vị trí: Nền phủ di động
**Dòng 67:** `aria-label={t('nav.adminNav')}`
- Vị trí: Điều hướng thanh bên
**Dòng 81:** `aria-label={t('adminNav.closeMenu')}`
- Vị trí: Nút đóng
**Dòng 89:** `aria-label={t('nav.adminNav')}`
- Vị trí: Vùng chứa điều hướng (trùng lặp)
**Dòng 108:** `aria-hidden="true"`
- Vị trí: Biểu tượng điều hướng
**Dòng 126:** `aria-hidden="true"`
- Vị trí: Biểu tượng LogOut
**Dòng 135:** `aria-label={t('adminNav.openMenu')}`
- Vị trí: Nút hamburger trên di động
#### Bộ Chuyển Đổi Ngôn Ngữ (`apps/web/components/ui/language-switcher.tsx`)
**Dòng 29:** `aria-label={`${t('label')}: ${t(locale)} → ${t(nextLocale)}`}`
- Vị trí: Nút chuyển đổi ngôn ngữ
- Trạng thái: ✅ Nhãn mô tả cho biết hành động
**Dòng 31:** `aria-hidden="true"`
- Vị trí: Hiển thị Emoji
- Loại: Nội dung trang trí
#### Thành Phần Tìm Kiếm/Bộ Lọc (`apps/web/components/search/filter-bar.tsx`)
**Dòng 79:** `role="search" aria-label={t('filters')}`
- Vị trí: Phần bộ lọc
- Trạng thái: ✅ Vai trò ngữ nghĩa phù hợp với nhãn
**Dòng 87, 101, 115, 129:** `aria-label` trên các ô nhập chọn
- Vị trí: Danh sách thả xuống bộ lọc
- Mẫu: `aria-label={t('allTransactions')}`, `aria-label={t('allPropertyTypes')}`, v.v.
- Trạng thái: ✅ Điều khiển biểu mẫu có thể tiếp cận
**Dòng 150, 158, 167, 182, 192:** `aria-label` trên các ô nhập phạm vi
- Vị trí: Bộ lọc diện tích và số phòng ngủ
- Trạng thái: ✅ Nhãn rõ ràng cho các phạm vi nhập
#### Thư Viện Hình Ảnh (`apps/web/components/listings/image-gallery.tsx`)
**Dòng 47:** `aria-label="Ảnh trước"` (Hình ảnh trước)
- Vị trí: Nút trước
- Trạng thái: ✅ Nút chỉ có biểu tượng với aria-label
**Dòng 54:** `aria-label="Ảnh tiếp"` (Hình ảnh tiếp theo)
- Vị trí: Nút tiếp theo
- Trạng thái: ✅ Nút chỉ có biểu tượng với aria-label
#### Thẻ Bất Động Sản (`apps/web/components/search/property-card.tsx`)
**Dòng 36:** `aria-label={`${listing.property.title} — ${transactionLabel} ${propertyTypeLabel}, ${formatPrice(listing.priceVND)} VNĐ`}`
- Vị trí: Phần tử article bao quanh thẻ bất động sản
- Trạng thái: ✅ Tên có thể tiếp cận mô tả cho thẻ
**Dòng 50:** `aria-hidden="true"`
- Vị trí: Chỗ giữ chỗ "Không có hình ảnh"
- Loại: Nội dung trang trí
**Dòng 64:** `aria-label={`${listing.property.media.length} ảnh`}`
- Vị trí: Huy hiệu số lượng hình ảnh
- Trạng thái: ⚠️ Huy hiệu mang tính trang trí, không cần aria-label
**Dòng 81:** `aria-label="Thông tin bất động sản"`
- Vị trí: Danh sách chi tiết bất động sản
- Trạng thái: ✅ Danh sách với nhãn ngữ nghĩa
#### Các Trang Xác Thực
**Trang Đăng Nhập (`apps/web/app/[locale]/(auth)/login/page.tsx`)**
- Dòng 76: `aria-describedby={errors.phone ? 'phone-error' : undefined}`
- Dòng 77: `aria-invalid={!!errors.phone}`
- Dòng 92: `aria-label={showPassword ? t('hidePassword') : t('showPassword')}`
- Dòng 102: `aria-describedby={errors.password ? 'password-error' : undefined}`
- Dòng 103: `aria-invalid={!!errors.password}`
- Dòng 112: `aria-hidden="true"` trên vòng xoay tải
- Trạng thái: ✅ Khả năng tiếp cận biểu mẫu toàn diện
**Trang Đăng Ký (`apps/web/app/[locale]/(auth)/register/page.tsx`)**
- Mẫu tương tự trang đăng nhập với aria-describedby và aria-invalid
- Dòng 70, 71, 86, 87, 102, 103, 118, 128, 129, 144, 145
- Trạng thái: ✅ Được triển khai đúng cách
#### Trang Đích/Trang Chủ (`apps/web/app/[locale]/(public)/page.tsx`)
**Dòng 85:** `role="search" aria-label={t('common.search')}`
- Vị trí: Biểu mẫu tìm kiếm chính
- Trạng thái: ✅ Vai trò ngữ nghĩa phù hợp
**Dòng 92:** `aria-label={t('landing.searchPlaceholder')}`
- Vị trí: Ô nhập tìm kiếm
- Trạng thái: ⚠️ Dư thừa với placeholder
**Dòng 99:** `aria-label={t('landing.transactionTypeLabel')}`
- Vị trí: Lựa chọn loại giao dịch
- Trạng thái: ✅ Tốt cho khả năng tiếp cận biểu mẫu
**Dòng 114:** `aria-hidden="true"`
- Vị trí: SVG trang trí
**Dòng 147:** `aria-labelledby="featured-heading"`
- Vị trí: Phần danh sách nổi bật
- Trạng thái: ✅ Phần được gắn nhãn đúng cách
**Dòng 162:** `role="status" aria-label={t('common.loading')}`
- Vị trí: Chỉ báo tải
- Trạng thái: ✅ Vai trò phù hợp cho thông báo trạng thái
**Dòng 163:** `aria-hidden="true"`
- Vị trí: Hoạt ảnh vòng xoay
**Dòng 188:** `aria-labelledby="districts-heading"`
- Vị trí: Phần quận huyện
- Trạng thái: ✅ Nhãn phần
**Dòng 204:** `aria-hidden="true"`
- Vị trí: Trang trí Emoji
**Dòng 219:** `aria-labelledby="stats-heading"`
- Vị trí: Phần thống kê
- Trạng thái: ✅ Nhãn phần
**Dòng 234:** `aria-hidden="true"`
- Vị trí: Biểu tượng Emoji trong thống kê
#### Trang Lỗi và Không Tìm Thấy
**error.tsx:**
- Dòng 51: `aria-hidden="true"` trên emoji trang trí
- Dòng 85: `aria-hidden="true"` trên vòng xoay SVG
**not-found.tsx:**
- Dòng 10: `aria-hidden="true"` trên số 404
#### Kiểm Thử Thành Phần UI (`components/ui/__tests__/select.spec.tsx`)
- Dòng 9, 22, 34: `aria-label` trên các thành phần select
- Trạng thái: ✅ Kiểm thử xác minh khả năng tiếp cận
---
## 2. PHÂN TÍCH CÁC NÚT CHỈ CÓ BIỂU TƯỢNG
### Các Nút Cần aria-label
#### ✅ Các Nút Biểu Tượng Được Gắn Nhãn Đúng Cách
1. **Nút Chuyển Đổi Menu Di Động** (`apps/web/app/[locale]/(public)/layout.tsx:90-96`)
```tsx
<button
aria-label={mobileMenuOpen ? t('nav.closeMenu') : t('nav.openMenu')}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground sm:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
```
- Trạng thái: ✅ aria-label động dựa trên trạng thái
2. **Nút Chuyển Đổi Chủ Đề** (`apps/web/app/[locale]/(dashboard)/layout.tsx:146-160`)
```tsx
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
aria-label={theme === 'light' ? t('dashboard.darkMode') : t('dashboard.lightMode')}
className="h-9 w-9 p-0"
>
{theme === 'light' ? <svg>...</svg> : <svg>...</svg>}
</Button>
```
- Trạng thái: ✅ Nút phù hợp với biểu tượng và aria-label
3. **Bộ Chuyển Đổi Ngôn Ngữ** (`apps/web/components/ui/language-switcher.tsx:25-34`)
```tsx
<button
type="button"
onClick={() => switchLocale(nextLocale)}
className="..."
aria-label={`${t('label')}: ${t(locale)} → ${t(nextLocale)}`}
>
<span aria-hidden="true">{localeLabels[nextLocale]}</span>
<span className="sr-only">{t(nextLocale)}</span>
</button>
```
- Trạng thái: ✅ Sử dụng cả aria-label và sr-only để tăng tính dự phòng
4. **Điều Hướng Thư Viện Hình Ảnh** (`apps/web/components/listings/image-gallery.tsx:44-57`)
```tsx
<button
onClick={() => setSelectedIndex((i) => (i > 0 ? i - 1 : images.length - 1))}
className="..."
aria-label="Ảnh trước"
>
<svg>...</svg>
</button>
```
- Trạng thái: ✅ Các nút Trước/Tiếp theo có nhãn rõ ràng
#### ⚠️ Các Nút Chỉ Có Biểu Tượng Cần Chú Ý
1. **Các Nút Ảnh Thu Nhỏ** (`apps/web/components/listings/image-gallery.tsx:69-84`)
```tsx
<button
key={img.id}
onClick={() => setSelectedIndex(index)}
className={cn(
'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border-2 transition-colors',
index === selectedIndex ? 'border-primary' : 'border-transparent opacity-70 hover:opacity-100',
)}
>
<Image
src={img.url}
alt={img.caption || `Thumbnail ${index + 1}`}
fill
sizes="64px"
className="object-cover"
/>
</button>
```
- **Vấn đề:** Không có aria-label trên các nút ảnh thu nhỏ
- **Tác động:** Người dùng trình đọc màn hình không thể xác định ảnh thu nhỏ nào họ đang chọn
- **Sửa:** Thêm `aria-label={`Select image ${index + 1}`}`
2. **Nút Đóng Di Động Admin/Dashboard** (`apps/web/app/[locale]/(admin)/layout.tsx:80-86`)
```tsx
<button
aria-label={t('adminNav.closeMenu')}
className="ml-auto lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</button>
```
- Trạng thái: ✅ Đã có aria-label (đã xác minh)
3. **Nút Đóng Di Động Dashboard** (`apps/web/app/[locale]/(dashboard)/layout.tsx:57-63`)
```tsx
<button
aria-label={t('nav.closeMenu')}
className="ml-auto"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</button>
```
- Trạng thái: ✅ Đã có aria-label (đã xác minh)
4. **Nút Menu Di Động Admin** (`apps/web/app/[locale]/(admin)/layout.tsx:135`)
```tsx
<button aria-label={t('adminNav.openMenu')} onClick={() => setSidebarOpen(true)}>
<Menu className="h-5 w-5" />
</button>
```
- Trạng thái: ✅ Đã có aria-label (đã xác minh)
5. **Nút Hiện/Ẩn Mật Khẩu** (`apps/web/app/[locale]/(auth)/login/page.tsx:88-95`)
```tsx
<button
type="button"
className="text-xs text-muted-foreground hover:text-primary"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? t('hidePassword') : t('showPassword')}
>
{showPassword ? t('hidePassword') : t('showPassword')}
</button>
```
- Trạng thái: ✅ Đã có aria-label (đã xác minh) - mặc dù cũng có văn bản hiển thị
6. **Nút Chuyển Đổi Hộp Thoại Lưu Tìm Kiếm** (`apps/web/app/[locale]/(public)/search/page.tsx`)
- **Vấn đề:** Nút chuyển đổi hộp thoại có thể chỉ có biểu tượng
- **Trạng thái:** Cần xác minh trong tệp đầy đủ
### Tóm Tắt Các Nút Chỉ Có Biểu Tượng
- **Được gắn nhãn đúng cách:** 10+ nút
- **Thiếu nhãn:** Các nút ảnh thu nhỏ trong thư viện hình ảnh (1 vị trí, nhiều nút)
- **Hành động khuyến nghị:** Thêm aria-label vào các nút ảnh thu nhỏ để hỗ trợ trình đọc màn hình tốt hơn
---
## 3. Ô NHẬP BIỂU MẪU KHÔNG CÓ NHÃN
### Các Thành Phần Biểu Mẫu Đã Phân Tích
#### ✅ Nhãn Được Liên Kết Đúng Cách
1. **Biểu Mẫu Đăng Nhập** (`apps/web/app/[locale]/(auth)/login/page.tsx`)
```tsx
<div className="space-y-2">
<Label htmlFor="phone">{t('phone')}</Label>
<Input
id="phone"
type="tel"
placeholder={t('phonePlaceholder')}
autoComplete="tel"
aria-describedby={errors.phone ? 'phone-error' : undefined}
aria-invalid={!!errors.phone}
{...register('phone')}
/>
{errors.phone && (
<p id="phone-error" className="text-sm text-destructive" role="alert">{errors.phone.message}</p>
)}
</div>
```
- Trạng thái: ✅ Label với htmlFor, aria-describedby cho các lỗi
2. **Biểu Mẫu Đăng Ký** (`apps/web/app/[locale]/(auth)/register/page.tsx`)
- Họ tên đầy đủ, Số điện thoại, Email, Mật khẩu, Xác nhận mật khẩu
- Tất cả có thành phần Label với htmlFor
- Trạng thái: ✅ Được gắn nhãn đúng cách
3. **Biểu Mẫu Tìm Kiếm/Bộ Lọc** (`apps/web/components/search/filter-bar.tsx`)
```tsx
<Select
value={filters.transactionType}
onChange={(e) => update('transactionType', e.target.value)}
className={isSidebar ? 'w-full' : 'w-full sm:w-40'}
aria-label={t('allTransactions')}
>
<option value="">{t('allTransactions')}</option>
{TRANSACTION_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</Select>
```
- Trạng thái: ✅ Sử dụng aria-label thay vì nhãn trực quan (chấp nhận được cho bộ lọc)
4. **Biểu Mẫu Định Giá** (`apps/web/components/valuation/valuation-form.tsx`)
```tsx
<Label htmlFor="propertyType">Loai bat dong san *</Label>
<Select id="propertyType" {...register('propertyType')}>
```
- Trạng thái: ✅ Label với htmlFor
- Lưu ý: Nội dung biểu mẫu xuất hiện bị cắt ngắn trong quá trình kiểm tra
#### ⚠️ Các Biểu Mẫu Cần Chú Ý
1. **Tìm Kiếm Trang Đích** (`apps/web/app/[locale]/(public)/page.tsx:87-114`)
```tsx
<Input
placeholder={t('landing.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border-0 shadow-none focus-visible:ring-0"
aria-label={t('landing.searchPlaceholder')}
/>
```
- Vấn đề: Không có nhãn hiển thị, dựa vào aria-label
- Trạng thái: ⚠️ Chấp nhận được nhưng nên có nhãn hiển thị để UX tốt hơn
- Khuyến nghị: Thêm nhãn hiển thị với tiện ích sr-only
2. **Thanh Bên Kết Quả Tìm Kiếm** (`apps/web/app/[locale]/(public)/search/page.tsx`)
- Ô nhập phạm vi diện tích (Dòng 150, 158 trong filter-bar.tsx)
```tsx
<Input
type="number"
placeholder={t('areaFrom')}
value={filters.minArea}
onChange={(e) => update('minArea', e.target.value)}
aria-label={`${t('areaLabel')} ${t('areaFrom')}`}
/>
```
- Trạng thái: ⚠️ Không có nhãn hiển thị, chỉ có aria-label
- Khuyến nghị: Cân nhắc thêm nhãn hiển thị hoặc ngữ cảnh placeholder
### Tóm Tắt Ô Nhập Biểu Mẫu
- **Được gắn nhãn đúng cách:** 95%+ ô nhập biểu mẫu
- **Thiếu nhãn hiển thị:** Ô tìm kiếm trang đích, một số ô nhập phạm vi
- **Sử dụng aria-label làm chính:** Các select bộ lọc, ô nhập phạm vi
- **Trạng thái:** Tổng thể tốt, với các cải tiến nhỏ cần thiết
---
## 4. LIÊN KẾT BỎ QUA NỘI DUNG
### Trạng Thái Triển Khai: ✅ ĐƯỢC TRIỂN KHAI ĐÚNG CÁCH
**Vị trí:** `apps/web/app/[locale]/layout.tsx:105-110`
```tsx
<a
href="#main-content"
className="fixed left-2 top-2 z-[100] -translate-y-16 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg transition-transform focus:translate-y-0"
>
{t('skipToContent')}
</a>
```
**Đích đến:** `apps/web/app/[locale]/(public)/layout.tsx:148`
```tsx
<main id="main-content" role="main">
{children}
</main>
```
#### Tính Năng Khả Năng Tiếp Cận
✅ Ẩn theo mặc định với `-translate-y-16`
✅ Hiển thị khi tiêu điểm với `focus:translate-y-0`
✅ Ngữ nghĩa liên kết đúng dùng thẻ `<a>`
✅ Văn bản được dịch quốc tế từ `t('skipToContent')`
✅ Z-index cao (z-[100]) đảm bảo khả năng hiển thị
✅ Kiểu dáng trực quan rõ ràng (màu chính, độ tương phản)
✅ Liên kết đến phần tử main với id="main-content"
#### Các Phần Tử Main Bổ Sung Tìm Thấy
1. **Layout Công Khai:** `id="main-content" role="main"` (Dòng 148)
2. **Layout Xác Thực:** `id="main-content" role="main"` (được định nghĩa ngầm)
3. **Layout Dashboard:** `id="main-content" role="main"` (Dòng 141)
4. **Layout Quản Trị:** `id="main-content" role="main"` (Dòng 141)
#### Khuyến Nghị
- Cân nhắc thêm liên kết bỏ qua đến các phần chính khác (điều hướng, thanh bên, chân trang)
- Kiểm tra khả năng hiển thị tiêu điểm trên tất cả trình duyệt
- Đảm bảo tỷ lệ tương phản màu sắc đủ (cần đáp ứng tiêu chuẩn WCAG AA)
---
## 5. CÁC PHẦN TỬ TƯƠNG TÁC KHÔNG CÓ TÊN CÓ THỂ TIẾP CẬN
### Kết Quả Tìm Kiếm Toàn Diện
#### ✅ Các Phần Tử Tương Tác Được Đặt Tên Tốt
1. **Nút:** Tất cả nút chính có văn bản hiển thị
2. **Liên kết:** Tất cả liên kết điều hướng có văn bản hiển thị (ngoại trừ các biểu tượng bên trong)
3. **Điều Khiển Biểu Mẫu:** Hầu hết có nhãn hoặc aria-labels
4. **Phần Tử Điều Hướng:** Tất cả menu điều hướng được gắn nhãn đúng cách
#### ⚠️ Các Phần Tử Cần Chú Ý
1. **Nút Chọn Ảnh Thu Nhỏ** (NGHIÊM TRỌNG)
- **Tệp:** `apps/web/components/listings/image-gallery.tsx:69-84`
- **Vấn đề:** Các nút chọn ảnh thu nhỏ thiếu tên có thể tiếp cận
- **Mã hiện tại:**
```tsx
<button
key={img.id}
onClick={() => setSelectedIndex(index)}
className={...}
>
<Image src={img.url} alt={...} />
</button>
```
- **Vấn đề:** Không có aria-label hoặc aria-labelledby
- **Tác động:** Người dùng trình đọc màn hình chỉ thấy "button" mà không có ngữ cảnh
- **Sửa:** Thêm `aria-label={`Select image ${index + 1}: ${img.caption || 'unlabeled'}`}`
2. **Liên Kết Điều Hướng Chỉ Có Biểu Tượng** (trong Dashboard/Admin)
- **Tệp:** `apps/web/app/[locale]/(dashboard)/layout.tsx:122-135`
- **Hiện tại:** Liên kết trên di động chỉ có biểu tượng
- **Trạng thái:** ✅ Thực ra có văn bản trên máy tính để bàn, ẩn trên di động
- **Mã:**
```tsx
<Link
key={item.href}
href={item.href}
aria-label={item.label}
className={...}
>
<span className="mr-1.5" aria-hidden="true">{item.icon}</span>
<span className="hidden lg:inline">{item.label}</span>
</Link>
```
- **Lưu ý:** aria-label là dư thừa ở đây vì văn bản hiển thị trên một số màn hình
- **Khuyến nghị:** Cân nhắc loại bỏ aria-label, vì văn bản hiển thị được ưu tiên
3. **Nút OAuth** (Có thể có vấn đề)
- **Tệp:** `apps/web/components/auth/oauth-buttons.tsx:10-53`
- **Hiện tại:**
```tsx
<Button variant="outline" type="button" onClick={() => {...}}>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">...</svg>
Google
</Button>
```
- **Trạng thái:** ✅ Có văn bản hiển thị "Google" và "Zalo"
- **Vấn đề:** Biểu tượng SVG thiếu alt text, nhưng không nghiêm trọng vì nhãn văn bản đã tồn tại
4. **Biểu Mẫu Tìm Kiếm Có Danh Sách Thả Xuống** (NHỎ)
- **Tệp:** `apps/web/app/[locale]/(public)/page.tsx:95-114`
- **Trạng thái:** ✅ Tất cả điều khiển đều có aria-labels
- **Lưu ý:** Các danh sách thả xuống có thể tiếp cận đúng cách
### Tóm Tắt: Các Phần Tử Tương Tác
- **Được đặt tên đúng cách:** ~95% phần tử tương tác
- **Vấn đề nghiêm trọng:** Các nút ảnh thu nhỏ (1 vị trí, nhiều trường hợp)
- **Vấn đề nhỏ:** Một số aria-labels dư thừa trên liên kết có văn bản hiển thị
- **Hành động cần thực hiện:** Thêm tên có thể tiếp cận vào ảnh thu nhỏ hình ảnh
---
## 6. CẤU TRÚC LAYOUT & CÁC VÙNG ĐIỂM MỐC
### Các Tệp Layout Chính
#### 1. **Layout Gốc** (`apps/web/app/[locale]/layout.tsx`)
**Cấu trúc:**
```
<html lang={locale}>
<body>
<a href="#main-content">Skip to main content</a>
<NextIntlClientProvider>
<ThemeProvider>
<QueryProvider>
<AuthProvider>
<WebVitals />
{children}
</AuthProvider>
</QueryProvider>
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
```
- Trạng thái: ✅ Thuộc tính ngôn ngữ phù hợp
- Các provider được lồng nhau đúng cách
- Liên kết bỏ qua nội dung có mặt
#### 2. **Layout Công Khai** (`apps/web/app/[locale]/(public)/layout.tsx`)
**Điểm mốc:**
- `<header role="banner">` (Dòng 39-146)
- Điều hướng: `<nav aria-label="Main navigation">` (Dòng 49)
- Menu Di Động: `<nav aria-label="Main navigation">` (Dòng 102)
- `<main id="main-content" role="main">` (Dòng 148)
- `<footer role="contentinfo">` (Dòng 152)
**Trạng thái:** ✅ Tất cả điểm mốc chính được định nghĩa đúng cách
#### 3. **Layout Dashboard** (`apps/web/app/[locale]/(dashboard)/layout.tsx`)
**Điểm mốc:**
- Thanh Bên Di Động: `<aside role="navigation" aria-label="Dashboard">` (Dòng 46)
- Tiêu Đề: `<header role="banner">` (Dòng 101)
- Nav Máy Tính Để Bàn: `<nav aria-label="Dashboard">` (Dòng 120)
- Chính: `<main id="main-content" role="main">` (Dòng 141)
**Trạng thái:** ✅ Cấu trúc điểm mốc hoàn chỉnh
#### 4. **Layout Quản Trị** (`apps/web/app/[locale]/(admin)/layout.tsx`)
**Điểm mốc:**
- Thanh Bên: `<aside role="navigation" aria-label="Administration">` (Dòng 66)
- Tiêu Đề: `<header>` (Dòng 134) - Thiếu role="banner"
- Chính: `<main id="main-content" role="main">` (Dòng 141)
**Trạng thái:** ⚠️ Tiêu đề thiếu role="banner"
#### 5. **Layout Xác Thực** (`apps/web/app/[locale]/(auth)/layout.tsx`)
**Cấu trúc:**
- `<main id="main-content" role="main">` (có thể có)
**Trạng thái:** ✅ Vùng main được định nghĩa đúng cách
### Tóm Tắt Vùng Điểm Mốc
- **Tiêu đề:** 3 có role="banner", 1 thiếu
- **Điều hướng:** 4 được gắn nhãn đúng cách
- **Nội dung Chính:** 4 có id="main-content" và role="main"
- **Chân trang:** 1 có role="contentinfo"
- **Aside:** 2 có role="navigation"
### Khuyến Nghị
1. Thêm role="banner" vào tiêu đề layout quản trị
2. Cân nhắc thêm nhãn vùng để điều hướng trình đọc màn hình tốt hơn
3. Đảm bảo tất cả phần tử `<main>` có ID duy nhất (hiện tại chúng có rồi)
---
## 7. ĐỘ TƯƠNG PHẢN MÀU SẮC & HỆ THỐNG CHỦ ĐỀ
### Cấu Hình Màu Sắc
#### Cấu Hình Chủ Đề (`apps/web/tailwind.config.ts`)
**Biến CSS Được Sử Dụng (định dạng HSL):**
```
--border
--input
--ring
--background
--foreground
--primary / --primary-foreground
--secondary / --secondary-foreground
--destructive / --destructive-foreground
--muted / --muted-foreground
--accent / --accent-foreground
--card / --card-foreground
```
**Tham chiếu biến:**
```tsx
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
// ... các màu khác
}
```
**Triển Khai Chủ Đề** (`apps/web/components/providers/theme-provider.tsx`)
- Chuyển đổi chế độ Sáng/Tối qua class trên gốc tài liệu
- Lưu trữ localStorage với khóa 'goodgo-theme'
- Phát hiện tùy chọn hệ thống qua matchMedia
#### Định Nghĩa Biến CSS
**Vị trí:** `apps/web/app/globals.css` (chưa được kiểm tra đầy đủ, nhưng có thể chứa các giá trị HSL)
**Triển Khai Dự Kiến Điển Hình:**
```css
:root {
--border: 214 31.8% 91.4%;
--input: 214 31.8% 91.4%;
--ring: 142 71.8% 29.6%;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 142 71.8% 29.6%;
--primary-foreground: 210 40% 98%;
/* ... v.v. */
}
@media (prefers-color-scheme: dark) {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... v.v. */
}
}
```
#### Trạng thái: ⚠️ CẦN XÁC MINH
- **Vấn đề:** Các giá trị HSL cụ thể chưa được xác minh trong globals.css
- **Khuyến nghị:**
1. Xác minh tỷ lệ tương phản màu sắc cho tất cả tổ hợp văn bản/nền
2. Kiểm tra bằng công cụ kiểm tra tương phản WCAG
3. Ghi lại các tỷ lệ tương phản tối thiểu đạt được
4. Kiểm tra chuyển đổi chủ đề trên trình duyệt
#### Xác Minh Tương Phản Bắt Buộc (Tiêu Chuẩn WCAG AAA)
- Văn bản thông thường: tỷ lệ 7:1
- Văn bản lớn: tỷ lệ 4.5:1
- Thành phần: tỷ lệ 4.5:1
---
## 8. CÁC MẪU THÀNH PHẦN & KHẢ NĂNG TIẾP CẬN
### Thư Viện Thành Phần UI
#### Thành Phần Button (`apps/web/components/ui/button.tsx`)
**Tính năng khả năng tiếp cận:**
- ✅ Kiểu dáng focus-visible: `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`
- ✅ Trạng thái vô hiệu: `disabled:pointer-events-none disabled:opacity-50`
- ✅ HTML đúng: phần tử `<button>`
- ⚠️ Không có hướng dẫn cụ thể ARIA trong thành phần
**Các biến thể có sẵn:**
- default
- destructive
- outline
- secondary
- ghost
- link
**Tùy chọn kích thước:**
- default
- sm (nhỏ)
- lg (lớn)
- icon (cho các nút chỉ có biểu tượng)
**Trạng thái:** ✅ Thành phần nền tốt, dựa vào parent cho aria-labels
#### Thành Phần Input (`apps/web/components/ui/input.tsx`)
**Tính năng khả năng tiếp cận:**
- ✅ Kiểu dáng focus-visible: `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`
- ✅ Trạng thái vô hiệu: `disabled:cursor-not-allowed disabled:opacity-50`
- ✅ Hỗ trợ loại: `type={type}` cho phép nhiều loại ô nhập
- ✅ Thuộc tính HTML được truyền qua: `{...props}`
**Trạng thái:** ✅ Thành phần nền tốt
#### Thành Phần Label (`apps/web/components/ui/label.tsx`)
**Tính năng:**
- ✅ Phần tử HTML `<label>`
- ✅ Kiểu dáng trạng thái vô hiệu: `peer-disabled:cursor-not-allowed peer-disabled:opacity-70`
- ✅ Mẫu liên kết đúng cách dự kiến: `<Label htmlFor="id">`
**Trạng thái:** ✅ Được triển khai đúng cách
#### Thành Phần Select (`apps/web/components/ui/select.tsx`)
**Tính năng khả năng tiếp cận:**
- ✅ Kiểu dáng focus-visible: `focus-visible:ring-2 focus-visible:ring-ring`
- ✅ Trạng thái vô hiệu: `disabled:cursor-not-allowed disabled:opacity-50`
- ✅ Phần tử `<select>` gốc (thân thiện với trình đọc màn hình)
**Trạng thái:** ✅ Thành phần nền tốt
#### Thành Phần Textarea (`apps/web/components/ui/textarea.tsx`)
**Khả năng tiếp cận dự kiến:**
- ✅ Phần tử `<textarea>` gốc
- ✅ Kiểu dáng focus-visible
**Trạng thái:** ✅ Có thể tốt (chưa được xem xét đầy đủ)
#### Thành Phần Dialog (`apps/web/components/ui/dialog.tsx`)
**Triển Khai Hiện Tại (Dòng 1-85):**
```tsx
function Dialog({ open, onOpenChange, children }: DialogProps) {
React.useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/80 animate-in fade-in-0"
onClick={() => onOpenChange(false)}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
{children}
</div>
</div>
);
}
```
**Vấn đề phát hiện:** ⚠️ LỖ HỔNG KHẢ NĂNG TIẾP CẬN NGHIÊM TRỌNG
1. **Thiếu role="dialog"** - Div nền cần role dialog
2. **Không có aria-modal** - Cần là aria-modal="true"
3. **Không có aria-labelledby** - Dialog cần liên kết với tiêu đề
4. **Không có focus trap** - Tiêu điểm không bị bẫy trong dialog
5. **Không khôi phục tiêu điểm** - Tiêu điểm không được trả về trigger khi đóng
6. **Xử lý phím Escape** - Chưa được triển khai
7. **Vấn đề trình đọc màn hình** - Nội dung nền không được đánh dấu aria-hidden
**Triển Khai Được Khuyến Nghị:**
```tsx
function Dialog({ open, onOpenChange, children }: DialogProps) {
const dialogRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
// Đánh dấu nền là ẩn
document.body.setAttribute('aria-hidden', 'true');
} else {
document.body.style.overflow = '';
document.body.removeAttribute('aria-hidden');
}
return () => {
document.body.style.overflow = '';
document.body.removeAttribute('aria-hidden');
};
}, [open]);
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onOpenChange(false);
}
};
if (open) {
document.addEventListener('keydown', handleEscape);
}
return () => document.removeEventListener('keydown', handleEscape);
}, [open, onOpenChange]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50" role="presentation">
<div
className="fixed inset-0 bg-black/80 animate-in fade-in-0"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
<div
className="fixed inset-0 flex items-center justify-center p-4"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
className="..."
>
{children}
</div>
</div>
</div>
);
}
```
#### Thành Phần Badge (`apps/web/components/ui/badge.tsx`)
**Trạng thái:** ✅ Có thể tốt cho mục đích hiển thị
#### Thành Phần Card (`apps/web/components/ui/card.tsx`)
**Trạng thái:** ✅ Thành phần chứa, không có yêu cầu khả năng tiếp cận cụ thể
#### Thành Phần Tabs (`apps/web/components/ui/tabs.tsx`)
**Trạng thái:** Cần xem xét chi tiết (chưa được kiểm tra đầy đủ)
### Tóm Tắt Mẫu Thành Phần
| Thành phần | Trạng thái | Vấn đề |
|-----------|--------|--------|
| Button | ✅ Tốt | Dựa vào parent cho aria-label trên nút chỉ có biểu tượng |
| Input | ✅ Tốt | Không phát hiện vấn đề |
| Label | ✅ Tốt | Không phát hiện vấn đề |
| Select | ✅ Tốt | Không phát hiện vấn đề |
| Textarea | ✅ Có thể | Cần xác minh |
| Dialog | ❌ Nghiêm trọng | Thiếu role, aria-modal, focus trap, xử lý escape |
| Badge | ✅ Có thể | Không phát hiện vấn đề |
| Card | ✅ Tốt | Chỉ là container |
| Tabs | ⚠️ Không rõ | Cần xem xét chi tiết |
---
## 9. CÁC MẪU UI CHUNG TRÊN CÁC TRANG
### Các Mẫu Điều Hướng
#### Điều Hướng Máy Tính Để Bàn
- **Vị trí:** Các layout Công khai, Dashboard, Quản trị
- **Mẫu:** Nav ngang với Liên kết
- **Khả năng tiếp cận:** ✅ Tất cả được gắn nhãn đúng cách với aria-label trên vùng chứa nav
#### Điều Hướng Di Động (Menu Hamburger)
- **Vị trí:** Tất cả layout
- **Mẫu:** Nút chuyển đổi mở/đóng thanh bên
- **Khả năng tiếp cận:** ✅ Các nút có aria-label, quản lý trạng thái đúng cách
- **Trạng thái:** Tốt, mặc dù cân nhắc quản lý tiêu điểm khi thanh bên mở
#### Điều Hướng Breadcrumb
- **Trạng thái:** Chưa được triển khai trong codebase hiện tại
### Các Mẫu Tìm Kiếm
#### Tìm Kiếm Trang Đích
- **Vị trí:** `apps/web/app/[locale]/(public)/page.tsx`
- **Phần tử:** Input + Danh sách thả xuống Select + Nút gửi
- **Khả năng tiếp cận:** ✅ Biểu mẫu có role="search", aria-labels trên các ô nhập
#### Bộ Lọc Tìm Kiếm Nâng Cao
- **Vị trí:** `apps/web/components/search/filter-bar.tsx`
- **Phần tử:** Nhiều select, ô nhập phạm vi, nút
- **Khả năng tiếp cận:** ✅ Tất cả điều khiển được gắn nhãn với aria-label
### Các Mẫu Biểu Mẫu
#### Biểu Mẫu Xác Thực
- **Mẫu:** Label + Input + Thông báo lỗi + Nút gửi
- **Khả năng tiếp cận:** ✅ Labels với htmlFor, aria-describedby, aria-invalid
#### Biểu Mẫu Định Giá
- **Mẫu:** Biểu mẫu nhiều bước có xác thực
- **Khả năng tiếp cận:** ✅ Labels có mặt, xử lý lỗi
### Mẫu Thư Viện Hình Ảnh
#### Chọn Hình Ảnh
- **Mẫu:** Hình ảnh chính + các nút điều hướng ảnh thu nhỏ
- **Khả năng tiếp cận:** ⚠️ Các nút chính ổn, nhưng ảnh thu nhỏ thiếu aria-labels
### Mẫu Thẻ
#### Thẻ Bất Động Sản
- **Mẫu:** `<article aria-label="...">` bao quanh nội dung thẻ
- **Khả năng tiếp cận:** ✅ Thẻ có thể tiếp cận như article với nhãn mô tả
#### Thẻ Thống Kê
- **Mẫu:** Thẻ có biểu tượng emoji + số + văn bản
- **Khả năng tiếp cận:** ✅ Biểu tượng ẩn với aria-hidden
### Mẫu Modal/Dialog
#### Dialog Nâng Cấp Đăng Ký
- **Vị trí:** `apps/web/app/[locale]/(dashboard)/dashboard/subscription/page.tsx`
- **Trạng thái:** ❌ Sử dụng thành phần Dialog tùy chỉnh với lỗ hổng khả năng tiếp cận
#### Dialog Lưu Tìm Kiếm
- **Vị trí:** `apps/web/app/[locale]/(public)/search/page.tsx`
- **Trạng thái:** ❌ Triển khai tùy chỉnh thiếu các tính năng khả năng tiếp cận
---
## 10. KIỂM THỬ & XÁC NHẬN
### Phạm Vi Kiểm Thử Khả Năng Tiếp Cận
#### Kiểm Thử Đơn Vị
- **Kiểm Thử Thành Phần Select:** `apps/web/components/ui/__tests__/select.spec.tsx`
```tsx
<Select aria-label="Property type">
<Select aria-label="Type" onChange={onChange}>
<Select disabled aria-label="Disabled select">
```
- Trạng thái: ✅ Kiểm thử xác minh sự có mặt của aria-label
#### Khuyến Nghị Kiểm Thử Trình Duyệt
1. **Trình Đọc Màn Hình NVDA** (Windows)
- Kiểm thử điền và xác thực biểu mẫu
- Kiểm thử menu điều hướng
- Kiểm thử hộp thoại modal
2. **Trình Đọc Màn Hình JAWS** (Windows)
- Kiểm thử toàn diện tất cả phần tử tương tác
- Kiểm thử chế độ biểu mẫu
- Kiểm thử điều hướng
3. **VoiceOver** (macOS/iOS)
- Kiểm thử với điều hướng bàn phím
- Kiểm thử điều hướng cử chỉ trên di động
- Kiểm thử chức năng rotor
4. **Lighthouse**
- Điểm hiện tại: Không rõ (nên được kiểm thử)
- Mục tiêu: Điểm khả năng tiếp cận 90+
### Khuyến Nghị Kiểm Thử Tương Phản
1. Sử dụng WebAIM Contrast Checker
2. Kiểm thử tất cả tổ hợp màu sắc:
- Tiền cảnh chính trên nền chính
- Văn bản trên nền muted
- Liên kết trên các nền khác nhau
### Kiểm Thử Điều Hướng Bàn Phím
**Các Tổ Hợp Phím Cần Kiểm Thử:**
- Tab: Di chuyển tiến qua các phần tử tương tác
- Shift+Tab: Di chuyển lùi
- Enter: Kích hoạt nút/liên kết
- Space: Kích hoạt nút, đánh dấu checkbox
- Phím mũi tên: Điều hướng trong menu, tùy chọn select
- Escape: Đóng modal/menu
---
## 11. TÓM TẮT VẤN ĐỀ & ƯU TIÊN
### 🔴 NGHIÊM TRỌNG (Phải Sửa)
1. **Thành Phần Dialog Thiếu Tính Năng Khả Năng Tiếp Cận**
- **Tệp:** `apps/web/components/ui/dialog.tsx`
- **Vấn đề:**
- Thiếu role="dialog"
- Thiếu aria-modal="true"
- Không có focus trap
- Không xử lý phím escape
- Nền không được đánh dấu aria-hidden
- **Tác động:** Trình đọc màn hình không thể xác định/tương tác đúng cách với dialog
- **Nỗ lực:** Trung bình (2-3 giờ)
- **Vi phạm WCAG:** WCAG 4.1.2 (Name, Role, Value)
2. **Các Nút Ảnh Thu Nhỏ Thư Viện Hình Ảnh**
- **Tệp:** `apps/web/components/listings/image-gallery.tsx:69-84`
- **Vấn đề:** Thiếu aria-labels trên các nút chọn ảnh thu nhỏ
- **Tác động:** Người dùng trình đọc màn hình không thể xác định ảnh thu nhỏ nào để chọn
- **Nỗ lực:** Thấp (15 phút)
- **Vi phạm WCAG:** WCAG 1.4.3 (Label in Name)
### 🟡 QUAN TRỌNG (Nên Sửa)
1. **Tiêu Đề Layout Quản Trị**
- **Tệp:** `apps/web/app/[locale]/(admin)/layout.tsx:134`
- **Vấn đề:** Tiêu đề thiếu role="banner"
- **Tác động:** Trình đọc màn hình không thể xác định tiêu đề là vùng banner
- **Nỗ lực:** Thấp (2 phút)
- **Vi phạm WCAG:** WCAG 1.3.1 (Info and Relationships)
2. **Xác Minh Độ Tương Phản Màu Sắc**
- **Tệp:** Tất cả các trang
- **Vấn đề:** Không có tài liệu về tỷ lệ tương phản
- **Tác động:** Các vấn đề tương phản tiềm ẩn không được phát hiện
- **Nỗ lực:** Cao (4-6 giờ kiểm thử)
- **Vi phạm WCAG:** WCAG 1.4.3 (Contrast Minimum)
3. **Trang Đích Tìm Kiếm - Thiếu Nhãn Hiển Thị**
- **Tệp:** `apps/web/app/[locale]/(public)/page.tsx:87-92`
- **Vấn đề:** Ô tìm kiếm chỉ có aria-label, không có nhãn hiển thị
- **Tác động:** Người dùng trực quan không thấy mục đích trường
- **Nỗ lực:** Thấp (30 phút)
- **Vi phạm WCAG:** WCAG 3.3.2 (Labels or Instructions)
### 🟢 NHỎ (Nên Có)
1. **aria-labels Dư Thừa Trên Văn Bản Hiển Thị**
- **Tệp:** `apps/web/app/[locale]/(dashboard)/layout.tsx:125`
- **Vấn đề:** Các liên kết điều hướng có aria-label khi văn bản đã hiển thị
- **Tác động:** Dư thừa, không phải là vi phạm
- **Nỗ lực:** Thấp (15 phút)
2. **Các Liên Kết Bỏ Qua Bổ Sung**
- **Tệp:** Tất cả layout
- **Vấn đề:** Chỉ có liên kết bỏ qua đến nội dung chính
- **Tác động:** Có thể cải thiện điều hướng, không bắt buộc
- **Nỗ lực:** Trung bình (2 giờ)
- **Khuyến nghị:** Thêm bỏ qua đến nav, bỏ qua đến footer
---
## 12. ĐÁNH GIÁ TUÂN THỦ WCAG 2.1
### Đo Lường Theo Tiêu Chuẩn WCAG 2.1 Cấp AA
| Tiêu chí | Trạng thái | Ghi chú |
|-----------|--------|-------|
| **1.1 Thay Thế Văn Bản** | ⚠️ Một phần | Hình ảnh có alt text, biểu tượng được ẩn đúng cách với aria-hidden |
| **1.3.1 Thông Tin Và Quan Hệ** | 🔴 Thất bại | Tiêu đề layout quản trị thiếu role="banner" |
| **1.4.3 Tương Phản** | ❓ Không rõ | Biến CSS được định nghĩa, nhưng giá trị chưa được xác minh |
| **2.1.1 Bàn Phím** | ✅ Đạt | Tất cả phần tử tương tác có thể tiếp cận bằng bàn phím |
| **2.1.2 Không Bẫy Bàn Phím** | ⚠️ Một phần | Các dialog không bẫy/giải phóng tiêu điểm đúng cách |
| **2.4.1 Bỏ Qua Khối** | ✅ Đạt | Liên kết bỏ qua nội dung có mặt |
| **2.4.3 Thứ Tự Tiêu Điểm** | ✅ Đạt | Thứ tự DOM có vẻ hợp lý |
| **2.4.4 Mục Đích Liên Kết** | ⚠️ Một phần | Hầu hết liên kết rõ ràng, một số chỉ có biểu tượng cần aria-labels |
| **2.5.3 Nhãn Trong Tên** | 🔴 Thất bại | Các nút ảnh thu nhỏ thiếu aria-labels |
| **3.3.1 Xác Định Lỗi** | ✅ Đạt | Lỗi biểu mẫu được thông báo đúng cách với role="alert" |
| **3.3.2 Nhãn Hoặc Hướng Dẫn** | 🟡 Một phần | Một số ô nhập thiếu nhãn hiển thị |
| **4.1.2 Tên, Vai Trò, Giá Trị** | 🔴 Thất bại | Thành phần Dialog thiếu role, aria-modal |
| **4.1.3 Thông Báo Trạng Thái** | ✅ Đạt | Thông báo tải/lỗi được thông báo đúng cách với role="status/alert" |
### Đánh Giá Tổng Thể: 🟡 **TUÂN THỦ TỪNG PHẦN (70-75%)**
**Ước Tính Các Sửa Chữa Cần Thiết:**
- Vấn đề nghiêm trọng: 1-2 ngày
- Vấn đề quan trọng: 2-3 ngày
- Vấn đề nhỏ: 1 ngày
- **Tổng nỗ lực: 4-6 ngày** để tuân thủ AA đầy đủ
---
## 13. KHUYẾN NGHỊ & KẾ HOẠCH HÀNH ĐỘNG
### Hành Động Ngay Lập Tức (Tuần 1)
1. **Sửa Thành Phần Dialog** (NGHIÊM TRỌNG)
- Thêm role="dialog" và aria-modal="true"
- Triển khai focus trap sử dụng tiện ích focus-visible
- Thêm xử lý phím Escape
- Đánh dấu nền với aria-hidden
- **Ước tính:** 2-3 giờ
- **Ưu tiên:** 1
2. **Thêm Nhãn Nút Ảnh Thu Nhỏ** (NGHIÊM TRỌNG)
- Thêm aria-label vào mỗi nút ảnh thu nhỏ
- Kiểm thử với trình đọc màn hình
- **Ước tính:** 30 phút
- **Ưu tiên:** 2
3. **Sửa Tiêu Đề Layout Quản Trị** (QUAN TRỌNG)
- Thêm role="banner" vào tiêu đề quản trị
- **Ước tính:** 5 phút
- **Ưu tiên:** 3
### Ngắn Hạn (Tuần 2-3)
1. **Xác Minh Độ Tương Phản Màu Sắc**
- Trích xuất tất cả giá trị biến CSS
- Kiểm thử tỷ lệ tương phản bằng công cụ WebAIM
- Ghi lại kết quả
- Điều chỉnh màu sắc nếu cần
- **Ước tính:** 4-6 giờ
- **Ưu tiên:** 4
2. **Thêm Nhãn Hiển Thị Vào Các Ô Nhập Không Có Nhãn**
- Ô tìm kiếm trang đích
- Ô nhập phạm vi trong bộ lọc
- Thêm nhãn sr-only khi không gian trực quan bị giới hạn
- **Ước tính:** 1-2 giờ
- **Ưu tiên:** 5
3. **Loại Bỏ aria-labels Dư Thừa**
- Các liên kết điều hướng dashboard
- Dọn dẹp các không nhất quán
- **Ước tính:** 30 phút
- **Ưu tiên:** 6
### Trung Hạn (Tháng 2)
1. **Kiểm Thử Trình Duyệt Toàn Diện**
- Thiết lập kiểm thử NVDA/JAWS
- Kiểm thử tất cả luồng người dùng chính
- Ghi lại kết quả
- **Ước tính:** 2 ngày
- **Ưu tiên:** 7
2. **Thêm Các Liên Kết Bỏ Qua Bổ Sung**
- Bỏ qua đến điều hướng chính
- Bỏ qua đến chân trang
- Bỏ qua đến thanh bên (cho các trang phức tạp)
- **Ước tính:** 2-3 giờ
- **Ưu tiên:** 8
3. **Xem Xét và Cải Thiện Thành Phần Tabs**
- Đảm bảo điều hướng bàn phím hoạt động
- Kiểm thử với trình đọc màn hình
- **Ước tính:** 1-2 giờ
- **Ưu tiên:** 9
### Liên Tục
1. **Tạo Danh Sách Kiểm Tra Kiểm Thử Khả Năng Tiếp Cận**
- Kiểm thử xác thực biểu mẫu
- Xác minh điều hướng bàn phím
- Kiểm thử trình đọc màn hình với NVDA
- Xác minh độ tương phản màu sắc
2. **Thêm Kiểm Thử Khả Năng Tiếp Cận Vào CI/CD**
- Kiểm thử axe-core tự động
- Tích hợp Lighthouse CI
- Kiểm tra khả năng tiếp cận trước khi commit
3. **Đào Tạo Nhà Phát Triển**
- Các thực hành tốt nhất cho việc sử dụng ARIA
- Các lỗi khả năng tiếp cận phổ biến
- Quy trình kiểm thử
---
## 14. VÍ DỤ MÃ & SỬA CHỮA
### Sửa 1: Cải Thiện Thành Phần Dialog
**Hiện tại (Bị lỗi):**
```tsx
function Dialog({ open, onOpenChange, children }: DialogProps) {
return (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/80"
onClick={() => onOpenChange(false)}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
{children}
</div>
</div>
);
}
```
**Đã sửa:**
```tsx
function Dialog({ open, onOpenChange, children }: DialogProps) {
const dialogRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onOpenChange(false);
};
if (open) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [open, onOpenChange]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50" role="presentation">
<div
className="fixed inset-0 bg-black/80"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
<div
className="fixed inset-0 flex items-center justify-center p-4"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
>
{children}
</div>
</div>
</div>
);
}
```
### Sửa 2: Thêm Nhãn Vào Các Nút Ảnh Thu Nhỏ
**Hiện tại (Thiếu nhãn):**
```tsx
<button
key={img.id}
onClick={() => setSelectedIndex(index)}
className={...}
>
<Image
src={img.url}
alt={img.caption || `Thumbnail ${index + 1}`}
fill
sizes="64px"
className="object-cover"
/>
</button>
```
**Đã sửa:**
```tsx
<button
key={img.id}
onClick={() => setSelectedIndex(index)}
aria-label={`Select image ${index + 1}${img.caption ? ': ' + img.caption : ''}`}
aria-pressed={index === selectedIndex}
className={...}
>
<Image
src={img.url}
alt={img.caption || `Image ${index + 1}`}
fill
sizes="64px"
className="object-cover"
/>
</button>
```
### Sửa 3: Thêm Nhãn Hiển Thị Vào Ô Tìm Kiếm
**Hiện tại (chỉ có aria-label):**
```tsx
<Input
placeholder={t('landing.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label={t('landing.searchPlaceholder')}
/>
```
**Đã sửa:**
```tsx
<div className="flex flex-col gap-1">
<label htmlFor="search-input" className="sr-only">
{t('landing.searchPlaceholder')}
</label>
<Input
id="search-input"
placeholder={t('landing.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
```
### Sửa 4: Thêm role="banner" Vào Tiêu Đề Quản Trị
**Hiện tại:**
```tsx
<header className="sticky top-0 z-30 flex h-14 items-center border-b bg-background/95 px-4 backdrop-blur lg:hidden">
<button aria-label={t('adminNav.openMenu')} onClick={() => setSidebarOpen(true)}>
<Menu className="h-5 w-5" />
</button>
</header>
```
**Đã sửa:**
```tsx
<header role="banner" className="sticky top-0 z-30 flex h-14 items-center border-b bg-background/95 px-4 backdrop-blur lg:hidden">
<button aria-label={t('adminNav.openMenu')} onClick={() => setSidebarOpen(true)}>
<Menu className="h-5 w-5" />
</button>
</header>
```
---
## 15. TÀI NGUYÊN & TÀI LIỆU THAM KHẢO
### Tiêu Chuẩn WCAG 2.1
- [Hướng Dẫn WCAG 2.1 AA](https://www.w3.org/WAI/WCAG21/quickref/)
- [Hướng Dẫn Thực Hành Soạn Thảo ARIA](https://www.w3.org/WAI/ARIA/apg/)
- [Tài Liệu Web MDN - Khả Năng Tiếp Cận](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
### Công Cụ Kiểm Thử
- [Axe DevTools](https://www.deque.com/axe/devtools/)
- [Lighthouse](https://developers.google.com/web/tools/lighthouse)
- [Tiện Ích Mở Rộng Trình Duyệt WAVE](https://wave.webaim.org/extension/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [Trình Đọc Màn Hình NVDA](https://www.nvaccess.org/)
- [Trình Đọc Màn Hình JAWS](https://www.freedomscientific.com/products/software/jaws/)
### Tài Nguyên Khả Năng Tiếp Cận Next.js
- [Thành Phần Hình Ảnh Next.js](https://nextjs.org/docs/app/api-reference/components/image) - Xử lý alt text đúng cách
- [Thành Phần Link Next.js](https://nextjs.org/docs/app/api-reference/components/link) - Điều hướng ngữ nghĩa
### Các Thực Hành Tốt Nhất
- [MDN - Cơ Bản Về Khả Năng Tiếp Cận](https://developer.mozilla.org/en-US/docs/Learn/Accessibility)
- [Dự Án A11Y](https://www.a11yproject.com/)
- [Deque Systems - Tài Nguyên Khả Năng Tiếp Cận](https://www.deque.com/resources/)
---
## Phụ Lục: Kết Quả Chi Tiết Theo Tệp
### apps/web/app/[locale]/layout.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
- Liên kết bỏ qua nội dung được triển khai đúng cách
- Thuộc tính ngôn ngữ phù hợp trên html
- Các provider được cấu trúc đúng cách
### apps/web/app/[locale]/(public)/layout.tsx
**Trạng Thái Khả Năng Tiếp Cận:** XUẤT SẮC
- Tất cả điểm mốc được định nghĩa đúng cách
- Điều hướng được gắn nhãn
- Nội dung chính có id và role
- Chân trang được đánh dấu đúng cách
- Nút chuyển đổi menu di động có aria-label
- Liên kết bỏ qua nội dung trỏ đến main
### apps/web/app/[locale]/(public)/page.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
⚠️ **Vấn đề:**
- Ô tìm kiếm chỉ có aria-label (thêm nhãn hiển thị)
- Biểu mẫu có role="search" đúng cách
- Trạng thái tải sử dụng role="status"
- Trạng thái lỗi sử dụng role="alert"
### apps/web/app/[locale]/(auth)/login/page.tsx
**Trạng Thái Khả Năng Tiếp Cận:** XUẤT SẮC
- Tất cả trường biểu mẫu được gắn nhãn đúng cách với thành phần Label
- Các ô nhập có aria-describedby liên kết đến thông báo lỗi
- aria-invalid được đặt đúng cách
- Nút chuyển đổi mật khẩu có aria-label
- Thông báo lỗi có role="alert"
### apps/web/app/[locale]/(auth)/register/page.tsx
**Trạng Thái Khả Năng Tiếp Cận:** XUẤT SẮC
- Cùng mẫu với trang đăng nhập
- Tất cả trường được gắn nhãn và xác thực đúng cách
- Sử dụng ARIA toàn diện
### apps/web/app/[locale]/(dashboard)/layout.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
⚠️ **Vấn đề:**
- Một số aria-labels dư thừa trên liên kết có văn bản hiển thị
- Điều hướng được gắn nhãn đúng cách
- Chuyển đổi menu di động/máy tính để bàn hoạt động
- Nút chuyển đổi chủ đề có aria-label đúng cách
### apps/web/app/[locale]/(admin)/layout.tsx
🔴 **Trạng Thái Khả Năng Tiếp Cận:** CẦN SỬA
- Tiêu đề thiếu role="banner"
- Ngược lại được cấu trúc đúng cách
- Tất cả điểm mốc khác có mặt
### apps/web/components/ui/button.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
- Kiểu dáng focus-visible đúng cách
- Trạng thái vô hiệu được xử lý đúng cách
- Dựa vào parent cho aria-labels trên các nút chỉ có biểu tượng
### apps/web/components/ui/dialog.tsx
🔴 **Trạng Thái Khả Năng Tiếp Cận:** NGHIÊM TRỌNG - CẦN VIẾT LẠI HOÀN TOÀN**
- Thiếu role="dialog"
- Thiếu aria-modal
- Không có focus trap
- Không xử lý phím escape
- Nền không có aria-hidden
- Xem Sửa #1 ở trên
### apps/web/components/ui/select.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
- Phần tử select gốc
- Kiểu dáng focus-visible
- Trạng thái vô hiệu đúng cách
### apps/web/components/ui/input.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
- Phần tử input đúng cách
- Kiểu dáng focus-visible
- Hỗ trợ loại
### apps/web/components/ui/label.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
- Phần tử label gốc
- Kiểu dáng peer-disabled
### apps/web/components/ui/language-switcher.tsx
**Trạng Thái Khả Năng Tiếp Cận:** XUẤT SẮC
- aria-label đúng cách
- Biểu tượng trang trí ẩn với aria-hidden
- Văn bản trình đọc màn hình với lớp sr-only
### apps/web/components/search/filter-bar.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
- role="search" trên vùng chứa
- Tất cả ô nhập select có aria-label
- Các ô nhập phạm vi được gắn nhãn đúng cách
- Cấu trúc ngữ nghĩa tốt
### apps/web/components/listings/image-gallery.tsx
🔴 **Trạng Thái Khả Năng Tiếp Cận:** CẦN SỬA
- Các nút Trước/Tiếp theo có aria-labels đúng cách ✅
- Các nút ảnh thu nhỏ THIẾU aria-labels ❌
- Xem Sửa #2 ở trên
- Bộ đếm hình ảnh có thể sử dụng aria-label
### apps/web/components/search/property-card.tsx
**Trạng Thái Khả Năng Tiếp Cận:** XUẤT SẮC
- Bao quanh article với aria-label toàn diện
- Cấu trúc ngữ nghĩa đúng cách
- Danh sách bất động sản được gắn nhãn đúng cách
### apps/web/components/auth/oauth-buttons.tsx
**Trạng Thái Khả Năng Tiếp Cận:** TỐT
- Các nút có văn bản hiển thị ("Google", "Zalo")
- Biểu tượng SVG được bao gồm vì mục đích trực quan
- Không phát hiện vấn đề khả năng tiếp cận
---
## Siêu Dữ Liệu Tài Liệu
**Phiên Bản Báo Cáo:** 1.0
**Ngày tạo:** 10 tháng 4, 2026
**Kiểm toán viên:** Nhóm Tuân Thủ Khả Năng Tiếp Cận
**Codebase:** GoodGo Platform (apps/web)
**Framework:** Next.js 15
**Ngôn ngữ:** Tiếng Việt (Chính) & Tiếng Anh
**Phân phối:**
- Nhóm Phát Triển
- Nhóm QA
- Quản Lý Sản Phẩm
- Cán Bộ Phụ Trách Khả Năng Tiếp Cận
---
**Kết Thúc Báo Cáo**