Files
goodgo-platform/docs/audits/ACCESSIBILITY_DETAILED_FIXES.md
Ho Ngoc Hai 59272e9321 chore(docs): consolidate 22 audit files from root into docs/audits/
Root directory had accumulated audit/exploration markdown files cluttering
the project root. Moved all audit-related files to docs/audits/ with a
README.md index, and updated cross-references in K6_LOAD_TESTING_GUIDE.md
and README_FRONTEND_DOCS.md.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 23:16:00 +07:00

8.9 KiB

Accessibility Code Fixes - Detailed Implementation Guide

Fix #1: File Upload Input aria-label

File: apps/web/components/listings/image-upload.tsx
Line: 118

Before

<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 = '';
  }}
/>

After

<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 = '';
  }}
/>

Why: Hidden inputs need aria-label so screen readers can announce their purpose when focused.


Fix #2: Search Dialog Input aria-label

File: apps/web/app/[locale]/(public)/search/page.tsx
Line: 189

Before

<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()}
/>

After

<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()}
/>

Why: Text inputs need aria-label when no associated label element exists. Placeholder is not a substitute for aria-label.


Fix #3: Admin Moderation - Select All Checkbox

File: apps/web/app/[locale]/(admin)/admin/moderation/page.tsx
Line: 222

Before

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

After

<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>

Why: Checkbox in table headers need aria-label to distinguish them from row checkboxes.


Fix #4: Admin Moderation - Row Checkboxes

File: apps/web/app/[locale]/(admin)/admin/moderation/page.tsx
Line: 242

Before

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

After (Option 1 - Simple)

<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>

After (Option 2 - Better with title)

<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>

Why: Each checkbox needs unique aria-label that includes context about what listing it represents.


Fix #5: Test Mock Image Component

File: apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx
Line: 46

Before

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

After (Option 1 - Simple)

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

After (Option 2 - With Warning)

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'} />;
  },
}));

Why: Mock should enforce alt attribute to catch missing alts in tests before production.


Fix #6 (Enhancement): Image Upload Drag-Drop Accessibility

File: apps/web/components/listings/image-upload.tsx
Lines: 86-128

Current Code

<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>

Enhanced Code

<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>

Changes:

  • role="button" - Identifies div as an interactive button
  • tabIndex={0} - Makes div keyboard accessible
  • aria-label - Describes the purpose to screen readers
  • onKeyDown handler - Allows Enter/Space to activate
  • focus-visible styles - Shows focus indicator for keyboard navigation

Why: Makes drag-drop area fully keyboard accessible for users who can't use a mouse.


Testing After Implementation

1. Screen Reader Testing

# Use VoiceOver (Mac), NVDA (Windows), or JAWS
# Navigate to each fixed element and verify:
# - Input is announced with its aria-label
# - Checkbox is announced with its aria-label
# - Purpose is clear from screen reader announcement

2. Keyboard Navigation Testing

# Tab through the page
# Verify:
# - All interactive elements are reachable via Tab
# - Focus is visible on all elements
# - Enter/Space activates buttons and checkboxes
# - Image upload drag area is focused and can be activated with keyboard

3. Automated Testing

# Run axe
npm run test:a11y

# Or use Lighthouse
npx lighthouse https://localhost:3000 --view

# ESLint JSX Accessibility Plugin should catch these issues:
npm run lint

4. Visual Testing

# Verify with browser dev tools:
# - Inspect each input to confirm aria-label attribute exists
# - Check for proper focus styles
# - Verify focus ring colors meet contrast requirements

Summary of Changes

Issue File Line Type Severity
File input missing aria-label image-upload.tsx 118 aria-label HIGH
Search input missing aria-label search/page.tsx 189 aria-label HIGH
Header checkbox missing aria-label moderation/page.tsx 222 aria-label HIGH
Row checkboxes missing aria-label moderation/page.tsx 242 aria-label HIGH
Mock Image missing alt search.spec.tsx 46 alt attribute MEDIUM
Drag-drop area not keyboard accessible image-upload.tsx 86-128 enhancement MEDIUM

Estimated Implementation Time

  • Fix #1: 2 minutes
  • Fix #2: 2 minutes
  • Fix #3: 2 minutes
  • Fix #4: 3 minutes (need to find item.title in context)
  • Fix #5: 2 minutes
  • Fix #6: 10 minutes
  • Testing: 15-20 minutes

Total: ~35-45 minutes