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>
This commit is contained in:
354
docs/audits/ACCESSIBILITY_DETAILED_FIXES.md
Normal file
354
docs/audits/ACCESSIBILITY_DETAILED_FIXES.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Accessibility Code Fixes - Detailed Implementation Guide
|
||||
|
||||
## Fix #1: File Upload Input aria-label
|
||||
|
||||
**File**: `apps/web/components/listings/image-upload.tsx`
|
||||
**Line**: 118
|
||||
|
||||
### Before
|
||||
```tsx
|
||||
<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
|
||||
```tsx
|
||||
<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
|
||||
```tsx
|
||||
<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
|
||||
```tsx
|
||||
<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
|
||||
```tsx
|
||||
<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
|
||||
```tsx
|
||||
<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
|
||||
```tsx
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.listingId)}
|
||||
onChange={() => toggleSelect(item.listingId)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
</TableCell>
|
||||
```
|
||||
|
||||
### After (Option 1 - Simple)
|
||||
```tsx
|
||||
<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)
|
||||
```tsx
|
||||
<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
|
||||
```tsx
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
```
|
||||
|
||||
### After (Option 1 - Simple)
|
||||
```tsx
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} alt={props.alt || ''} />,
|
||||
}));
|
||||
```
|
||||
|
||||
### After (Option 2 - With Warning)
|
||||
```tsx
|
||||
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
|
||||
```tsx
|
||||
<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
|
||||
```tsx
|
||||
<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
|
||||
```bash
|
||||
# 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
|
||||
```bash
|
||||
# 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
|
||||
```bash
|
||||
# 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
|
||||
```bash
|
||||
# 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**
|
||||
|
||||
Reference in New Issue
Block a user