57 KiB
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
<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-labeltrê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
-
Nút Chuyển Đổi Menu Di Động (
apps/web/app/[locale]/(public)/layout.tsx:90-96)<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
-
Nút Chuyển Đổi Chủ Đề (
apps/web/app/[locale]/(dashboard)/layout.tsx:146-160)<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
-
Bộ Chuyển Đổi Ngôn Ngữ (
apps/web/components/ui/language-switcher.tsx:25-34)<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
-
Điều Hướng Thư Viện Hình Ảnh (
apps/web/components/listings/image-gallery.tsx:44-57)<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ú Ý
-
Các Nút Ảnh Thu Nhỏ (
apps/web/components/listings/image-gallery.tsx:69-84)<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}}
-
Nút Đóng Di Động Admin/Dashboard (
apps/web/app/[locale]/(admin)/layout.tsx:80-86)<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)
-
Nút Đóng Di Động Dashboard (
apps/web/app/[locale]/(dashboard)/layout.tsx:57-63)<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)
-
Nút Menu Di Động Admin (
apps/web/app/[locale]/(admin)/layout.tsx:135)<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)
-
Nút Hiện/Ẩn Mật Khẩu (
apps/web/app/[locale]/(auth)/login/page.tsx:88-95)<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ị
-
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
-
Biểu Mẫu Đăng Nhập (
apps/web/app/[locale]/(auth)/login/page.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
-
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
-
Biểu Mẫu Tìm Kiếm/Bộ Lọc (
apps/web/components/search/filter-bar.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)
-
Biểu Mẫu Định Giá (
apps/web/components/valuation/valuation-form.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ú Ý
-
Tìm Kiếm Trang Đích (
apps/web/app/[locale]/(public)/page.tsx:87-114)<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
-
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)
<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
<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
<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
- Layout Công Khai:
id="main-content" role="main"(Dòng 148) - Layout Xác Thực:
id="main-content" role="main"(được định nghĩa ngầm) - Layout Dashboard:
id="main-content" role="main"(Dòng 141) - 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
- Nút: Tất cả nút chính có văn bản hiển thị
- 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)
- Điều Khiển Biểu Mẫu: Hầu hết có nhãn hoặc aria-labels
- 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ú Ý
-
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:
<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'}}
- Tệp:
-
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ã:
<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
- Tệp:
-
Nút OAuth (Có thể có vấn đề)
- Tệp:
apps/web/components/auth/oauth-buttons.tsx:10-53 - Hiện tại:
<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
- Tệp:
-
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ệp:
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)
- Điều hướng:
<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ị
- Thêm role="banner" vào tiêu đề layout quản trị
- 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
- Đả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:
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:
: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ị:
- 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
- Kiểm tra bằng công cụ kiểm tra tương phản WCAG
- Ghi lại các tỷ lệ tương phản tối thiểu đạt được
- 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):
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
- Thiếu role="dialog" - Div nền cần role dialog
- Không có aria-modal - Cần là aria-modal="true"
- Không có aria-labelledby - Dialog cần liên kết với tiêu đề
- Không có focus trap - Tiêu điểm không bị bẫy trong dialog
- Không khôi phục tiêu điểm - Tiêu điểm không được trả về trigger khi đóng
- Xử lý phím Escape - Chưa được triển khai
- 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ị:
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<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
-
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
-
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
-
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
-
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
- Sử dụng WebAIM Contrast Checker
- 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)
-
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)
- Tệp:
-
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)
- Tệp:
🟡 QUAN TRỌNG (Nên Sửa)
-
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)
- Tệp:
-
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)
-
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)
- Tệp:
🟢 NHỎ (Nên Có)
-
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)
- Tệp:
-
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)
-
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
-
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
-
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)
-
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
-
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
-
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)
-
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
-
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
-
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
-
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
-
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
-
Đà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):
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:
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):
<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:
<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):
<Input
placeholder={t('landing.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label={t('landing.searchPlaceholder')}
/>
Đã sửa:
<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:
<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:
<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
Công Cụ Kiểm Thử
- Axe DevTools
- Lighthouse
- Tiện Ích Mở Rộng Trình Duyệt WAVE
- WebAIM Contrast Checker
- Trình Đọc Màn Hình NVDA
- Trình Đọc Màn Hình JAWS
Tài Nguyên Khả Năng Tiếp Cận Next.js
- Thành Phần Hình Ảnh Next.js - Xử lý alt text đúng cách
- Thành Phần Link Next.js - Điều hướng ngữ nghĩa
Các Thực Hành Tốt Nhất
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