fix(a11y): resolve serious accessibility issues on search page (GOO-110)

- Add aria-hidden="true" to all decorative inline SVGs (bookmark, view-mode, funnel, checkmark)
- Convert save-search popover to proper dialog: role="dialog", aria-modal, focus trap, Escape key, focus return to trigger
- Add aria-pressed on list/map/split view-mode toggle buttons
- Add aria-expanded + aria-controls on mobile filter toggle button
- Add role="status" + aria-label="Đang tải..." on Suspense fallback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 10:26:50 +07:00
parent 1d26393f16
commit f5118244b7
34 changed files with 2321 additions and 9 deletions

View File

@@ -0,0 +1,127 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('lucide-react', () => {
const icon = (name: string) => (props: Record<string, unknown>) => <span data-testid={`icon-${name}`} {...props} />;
return {
CheckCircle: icon('check'),
XCircle: icon('x'),
RefreshCw: icon('refresh'),
ChevronLeft: icon('chevron-left'),
ChevronRight: icon('chevron-right'),
ShieldCheck: icon('shield'),
X: icon('close'),
User: icon('user'),
};
});
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
vi.mock('@/components/design-system/status-chip', () => ({
StatusChip: ({ status }: { status: string }) => <span data-testid="status-chip">{status}</span>,
}));
const mockGetKycQueue = vi.fn();
const mockApproveKyc = vi.fn();
const mockRejectKyc = vi.fn();
vi.mock('@/lib/admin-api', () => ({
adminApi: {
getKycQueue: (...args: unknown[]) => mockGetKycQueue(...args),
approveKyc: (...args: unknown[]) => mockApproveKyc(...args),
rejectKyc: (...args: unknown[]) => mockRejectKyc(...args),
},
}));
import AdminKycPage from '../kyc/page';
const mockQueueData = {
data: [
{
userId: 'u1',
fullName: 'Nguyen Van A',
phone: '0912345678',
email: 'a@test.com',
role: 'AGENT',
kycStatus: 'PENDING',
kycData: { idType: 'CCCD', idNumber: '012345678901', frontImageUrl: 'https://img.test/front.jpg' },
createdAt: '2024-06-15T10:00:00Z',
},
{
userId: 'u2',
fullName: 'Tran Thi B',
phone: '0987654321',
email: null,
role: 'USER',
kycStatus: 'PENDING',
kycData: null,
createdAt: '2024-06-16T10:00:00Z',
},
],
total: 2,
page: 1,
limit: 20,
totalPages: 1,
};
describe('AdminKycPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetKycQueue.mockResolvedValue(mockQueueData);
mockApproveKyc.mockResolvedValue({});
mockRejectKyc.mockResolvedValue({});
});
it('renders heading and fetches queue', async () => {
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Duyệt KYC')).toBeInTheDocument();
});
expect(mockGetKycQueue).toHaveBeenCalledWith(1, 20);
});
it('renders queue items in table', async () => {
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
expect(screen.getByText('Tran Thi B')).toBeInTheDocument();
});
});
it('shows empty state when no requests', async () => {
mockGetKycQueue.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 });
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Không có yêu cầu KYC nào đang chờ')).toBeInTheDocument();
});
});
it('shows error state when fetch fails', async () => {
mockGetKycQueue.mockRejectedValue(new Error('Network error'));
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
expect(screen.getByText('Thử lại')).toBeInTheDocument();
});
it('refreshes queue on refresh button click', async () => {
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: /làm mới/i }));
expect(mockGetKycQueue).toHaveBeenCalledTimes(2);
});
});