Files
goodgo-platform/docs/audits/ACCESSIBILITY_DETAILED_FIXES.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

9.9 KiB

Sửa Lỗi Trợ Năng - Hướng Dẫn Triển Khai Chi Tiết

Sửa #1: aria-label cho Input Tải Tệp Lên

Tệp: apps/web/components/listings/image-upload.tsx
Dòng: 118

Trước

<input
  ref={inputRef}
  type="file"
  accept="image/jpeg,image/png,image/webp"
  multiple
  className="hidden"
  onChange={(e) => {
    if (e.target.files) addFiles(e.target.files);
    e.target.value = '';
  }}
/>

Sau

<input
  ref={inputRef}
  type="file"
  accept="image/jpeg,image/png,image/webp"
  multiple
  className="hidden"
  aria-label="Chọn ảnh để tải lên"
  onChange={(e) => {
    if (e.target.files) addFiles(e.target.files);
    e.target.value = '';
  }}
/>

Lý do: Các input ẩn cần aria-label để trình đọc màn hình có thể thông báo mục đích của chúng khi được lấy tiêu điểm.


Sửa #2: aria-label cho Input Hộp Thoại Tìm Kiếm

Tệp: apps/web/app/[locale]/(public)/search/page.tsx
Dòng: 189

Trước

<input
  type="text"
  value={saveName}
  onChange={(e) => setSaveName(e.target.value)}
  placeholder="Tên tìm kiếm (VD: Chung cư Q7 dưới 3 tỷ)"
  className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
  maxLength={100}
  onKeyDown={(e) => e.key === 'Enter' && handleSaveSearch()}
/>

Sau

<input
  type="text"
  value={saveName}
  onChange={(e) => setSaveName(e.target.value)}
  placeholder="Tên tìm kiếm (VD: Chung cư Q7 dưới 3 tỷ)"
  aria-label="Tên bộ lọc tìm kiếm"
  className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
  maxLength={100}
  onKeyDown={(e) => e.key === 'Enter' && handleSaveSearch()}
/>

Lý do: Các input văn bản cần aria-label khi không có phần tử nhãn liên kết. Placeholder không thể thay thế cho aria-label.


Sửa #3: Quản Trị Kiểm Duyệt - Checkbox Chọn Tất Cả

Tệp: apps/web/app/[locale]/(admin)/admin/moderation/page.tsx
Dòng: 222

Trước

<TableHead className="w-10">
  <input
    type="checkbox"
    checked={selected.size === result.data.length && result.data.length > 0}
    onChange={toggleSelectAll}
    className="rounded border-input"
  />
</TableHead>

Sau

<TableHead className="w-10">
  <input
    type="checkbox"
    aria-label="Chọn tất cả tin đăng"
    checked={selected.size === result.data.length && result.data.length > 0}
    onChange={toggleSelectAll}
    className="rounded border-input"
  />
</TableHead>

Lý do: Checkbox trong tiêu đề bảng cần aria-label để phân biệt với các checkbox trên từng hàng.


Sửa #4: Quản Trị Kiểm Duyệt - Checkbox Từng Hàng

Tệp: apps/web/app/[locale]/(admin)/admin/moderation/page.tsx
Dòng: 242

Trước

<TableCell>
  <input
    type="checkbox"
    checked={selected.has(item.listingId)}
    onChange={() => toggleSelect(item.listingId)}
    className="rounded border-input"
  />
</TableCell>

Sau (Phương án 1 - Đơn giản)

<TableCell>
  <input
    type="checkbox"
    aria-label={`Chọn tin đăng: ${item.listingId}`}
    checked={selected.has(item.listingId)}
    onChange={() => toggleSelect(item.listingId)}
    className="rounded border-input"
  />
</TableCell>

Sau (Phương án 2 - Tốt hơn với tiêu đề)

<TableCell>
  <input
    type="checkbox"
    aria-label={`Chọn tin đăng: ${item.title || item.listingId}`}
    checked={selected.has(item.listingId)}
    onChange={() => toggleSelect(item.listingId)}
    className="rounded border-input"
  />
</TableCell>

Lý do: Mỗi checkbox cần aria-label duy nhất bao gồm ngữ cảnh về tin đăng mà nó đại diện.


Sửa #5: Component Ảnh Giả Lập trong Kiểm Thử

Tệp: apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx
Dòng: 46

Trước

vi.mock('next/image', () => ({
  default: (props: Record<string, unknown>) => <img {...props} />,
}));

Sau (Phương án 1 - Đơn giản)

vi.mock('next/image', () => ({
  default: (props: Record<string, unknown>) => <img {...props} alt={props.alt || ''} />,
}));

Sau (Phương án 2 - Có Cảnh Báo)

vi.mock('next/image', () => ({
  default: (props: Record<string, unknown>) => {
    if (!props.alt) {
      console.warn('Image mock: Missing alt attribute', props);
    }
    return <img {...props} alt={props.alt || 'image'} />;
  },
}));

Lý do: Bản giả lập nên bắt buộc thuộc tính alt để phát hiện các ảnh thiếu alt trong kiểm thử trước khi đưa lên môi trường production.


Sửa #6 (Cải Tiến): Trợ Năng Kéo-Thả Tải Ảnh Lên

Tệp: apps/web/components/listings/image-upload.tsx
Dòng: 86-128

Mã Hiện Tại

<div
  onDragOver={handleDragOver}
  onDragLeave={handleDragLeave}
  onDrop={handleDrop}
  onClick={() => inputRef.current?.click()}
  className={cn(
    'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors',
    isDragging
      ? 'border-primary bg-primary/5'
      : 'border-muted-foreground/25 hover:border-primary/50',
  )}
>
  <svg>...</svg>
  <p className="text-sm font-medium">Kéo thả nh vào đây hoặc nhấp để chọn</p>
  <p className="mt-1 text-xs text-muted-foreground">
    JPG, PNG, WebP - Tối đa {maxFiles} nh, mỗi nh 10MB
  </p>
  <input
    ref={inputRef}
    type="file"
    accept="image/jpeg,image/png,image/webp"
    multiple
    className="hidden"
    onChange={(e) => {
      if (e.target.files) addFiles(e.target.files);
      e.target.value = '';
    }}
  />
</div>

Mã Đã Cải Tiến

<div
  onDragOver={handleDragOver}
  onDragLeave={handleDragLeave}
  onDrop={handleDrop}
  onClick={() => inputRef.current?.click()}
  role="button"
  tabIndex={0}
  aria-label="Khu vực kéo thả hoặc nhấp để tải ảnh lên"
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      inputRef.current?.click();
    }
  }}
  className={cn(
    'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
    isDragging
      ? 'border-primary bg-primary/5'
      : 'border-muted-foreground/25 hover:border-primary/50',
  )}
>
  <svg>...</svg>
  <p className="text-sm font-medium">Kéo thả nh vào đây hoặc nhấp để chọn</p>
  <p className="mt-1 text-xs text-muted-foreground">
    JPG, PNG, WebP - Tối đa {maxFiles} nh, mỗi nh 10MB
  </p>
  <input
    ref={inputRef}
    type="file"
    accept="image/jpeg,image/png,image/webp"
    multiple
    className="hidden"
    aria-label="Chọn ảnh để tải lên"
    onChange={(e) => {
      if (e.target.files) addFiles(e.target.files);
      e.target.value = '';
    }}
  />
</div>

Các thay đổi:

  • role="button" - Xác định div là một nút tương tác
  • tabIndex={0} - Cho phép div nhận tiêu điểm từ bàn phím
  • aria-label - Mô tả mục đích cho trình đọc màn hình
  • Trình xử lý onKeyDown - Cho phép kích hoạt bằng Enter/Space
  • Kiểu focus-visible - Hiển thị chỉ báo tiêu điểm khi điều hướng bằng bàn phím

Lý do: Làm cho vùng kéo-thả có thể truy cập hoàn toàn bằng bàn phím cho những người dùng không thể sử dụng chuột.


Kiểm Tra Sau Khi Triển Khai

1. Kiểm Tra Trình Đọc Màn Hình

# Sử dụng VoiceOver (Mac), NVDA (Windows), hoặc JAWS
# Điều hướng đến từng phần tử đã sửa và xác nhận:
# - Input được thông báo với aria-label của nó
# - Checkbox được thông báo với aria-label của nó
# - Mục đích rõ ràng qua thông báo của trình đọc màn hình

2. Kiểm Tra Điều Hướng Bằng Bàn Phím

# Tab qua trang
# Xác nhận:
# - Tất cả các phần tử tương tác có thể truy cập bằng Tab
# - Tiêu điểm hiển thị rõ trên tất cả các phần tử
# - Enter/Space kích hoạt các nút và checkbox
# - Vùng kéo-thả tải ảnh có thể nhận tiêu điểm và kích hoạt bằng bàn phím

3. Kiểm Tra Tự Động

# Chạy axe
npm run test:a11y

# Hoặc dùng Lighthouse
npx lighthouse https://localhost:3000 --view

# Plugin ESLint JSX Accessibility nên phát hiện các vấn đề này:
npm run lint

4. Kiểm Tra Trực Quan

# Xác nhận bằng công cụ dành cho nhà phát triển trong trình duyệt:
# - Kiểm tra từng input để xác nhận thuộc tính aria-label tồn tại
# - Kiểm tra kiểu tiêu điểm phù hợp
# - Xác nhận màu vòng tiêu điểm đáp ứng yêu cầu độ tương phản

Tóm Tắt Các Thay Đổi

Vấn đề Tệp Dòng Loại Mức độ nghiêm trọng
Input tệp thiếu aria-label image-upload.tsx 118 aria-label CAO
Input tìm kiếm thiếu aria-label search/page.tsx 189 aria-label CAO
Checkbox tiêu đề thiếu aria-label moderation/page.tsx 222 aria-label CAO
Checkbox hàng thiếu aria-label moderation/page.tsx 242 aria-label CAO
Ảnh giả lập thiếu alt search.spec.tsx 46 thuộc tính alt TRUNG BÌNH
Vùng kéo-thả không thể truy cập bằng bàn phím image-upload.tsx 86-128 cải tiến TRUNG BÌNH

Thời Gian Triển Khai Ước Tính

  • Sửa #1: 2 phút
  • Sửa #2: 2 phút
  • Sửa #3: 2 phút
  • Sửa #4: 3 phút (cần tìm item.title trong ngữ cảnh)
  • Sửa #5: 2 phút
  • Sửa #6: 10 phút
  • Kiểm tra: 15-20 phút

Tổng cộng: ~35-45 phút