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,120 @@
/* eslint-disable import-x/order */
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next/dynamic', () => ({
default: () => {
const Mock = () => <div data-testid="chart-placeholder">Chart</div>;
Mock.displayName = 'MockChart';
return Mock;
},
}));
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
const mockUseMarketReport = vi.fn();
const mockUseHeatmap = vi.fn();
const mockUseListingsSearch = vi.fn();
vi.mock('@/lib/hooks/use-analytics', () => ({
useMarketReport: (...args: unknown[]) => mockUseMarketReport(...args),
useHeatmap: (...args: unknown[]) => mockUseHeatmap(...args),
}));
vi.mock('@/lib/hooks/use-listings', () => ({
useListingsSearch: (...args: unknown[]) => mockUseListingsSearch(...args),
}));
vi.mock('@/components/listings/listing-status-badge', () => ({
ListingStatusBadge: ({ status }: { status: string }) => <span data-testid="status-badge">{status}</span>,
}));
import DashboardPage from '../page';
const fullData = {
marketReport: {
districts: [
{ district: 'Quan 1', totalListings: 100, avgPriceM2: 120000000, medianPrice: '15000000000', daysOnMarket: 45, yoyChange: 5.2, inventoryLevel: 50 },
],
},
heatmap: { dataPoints: [{ district: 'Quan 1', avgPriceM2: 120000000, totalListings: 100, lat: 10.77, lng: 106.7 }] },
listings: {
data: [{
id: '1', status: 'ACTIVE', transactionType: 'SALE', priceVND: '5000000000', viewCount: 10,
saveCount: 2, inquiryCount: 3, publishedAt: '2024-01-01', createdAt: '2024-01-01',
pricePerM2: null, rentPriceMonthly: null, commissionPct: null,
property: {
id: 'p1', propertyType: 'APARTMENT', title: 'Căn hộ Quận 7', description: 'Test',
address: '123 Nguyễn Hữu Thọ', ward: 'Tân Hưng', district: 'Quận 7',
city: 'Hồ Chí Minh', areaM2: 75, bedrooms: 2, bathrooms: 2, floors: null,
direction: null, yearBuilt: null, legalStatus: null, amenities: null, projectName: null, media: [],
},
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
agent: null,
}],
total: 1, page: 1, limit: 6, totalPages: 1,
},
};
describe('DashboardPage — deep tests', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state with placeholders', () => {
mockUseMarketReport.mockReturnValue({ data: undefined, isLoading: true });
mockUseHeatmap.mockReturnValue({ data: undefined, isLoading: true });
mockUseListingsSearch.mockReturnValue({ data: undefined, isLoading: true });
render(<DashboardPage />);
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
});
it('renders stat cards with computed values', () => {
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
render(<DashboardPage />);
expect(screen.getByText('Tin đăng của tôi')).toBeInTheDocument();
expect(screen.getByText('Lượt xem')).toBeInTheDocument();
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
expect(screen.getByText('Giá TB thị trường')).toBeInTheDocument();
});
it('renders recent listings with property title', () => {
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
render(<DashboardPage />);
expect(screen.getByText('Tin đăng gần đây')).toBeInTheDocument();
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
});
it('renders "Đăng tin mới" link', () => {
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
render(<DashboardPage />);
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
});
it('renders empty listings state', () => {
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
mockUseListingsSearch.mockReturnValue({ data: { data: [], total: 0, page: 1, limit: 6, totalPages: 0 }, isLoading: false });
render(<DashboardPage />);
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,153 @@
/* 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', () => ({
Check: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
}));
const mockFetchProfile = vi.fn();
const mockUseAuthStore = vi.fn();
vi.mock('@/lib/auth-store', () => ({
useAuthStore: (...args: unknown[]) => mockUseAuthStore(...args),
}));
vi.mock('@/lib/api-client', () => ({
apiClient: { patch: vi.fn().mockResolvedValue({}) },
}));
import KycPage from '../kyc/page';
function setupStore(overrides: Record<string, unknown> = {}) {
const store = {
user: {
id: 'user-1',
fullName: 'Nguyen Van A',
phone: '0912345678',
kycStatus: 'NONE',
...overrides,
},
fetchProfile: mockFetchProfile,
};
mockUseAuthStore.mockImplementation((selector?: (s: typeof store) => unknown) => {
if (typeof selector === 'function') return selector(store);
return store;
});
}
describe('KycPage — deep tests', () => {
beforeEach(() => {
vi.clearAllMocks();
setupStore();
});
it('renders heading and NONE status', () => {
render(<KycPage />);
expect(screen.getByText('Xác minh danh tính (KYC)')).toBeInTheDocument();
expect(screen.getByText('Chưa xác minh')).toBeInTheDocument();
});
it('renders step 1 with document type selector and number input', () => {
render(<KycPage />);
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
expect(screen.getByLabelText(/số giấy tờ/i)).toBeInTheDocument();
});
it('shows error and stays on step 1 when doc number is empty', async () => {
render(<KycPage />);
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByText(/vui lòng nhập số giấy tờ/i)).toBeInTheDocument();
});
});
it('advances from step 1 → step 2 after filling doc number', async () => {
render(<KycPage />);
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByLabelText(/ảnh mặt trước/i)).toBeInTheDocument();
});
});
it('shows error on step 2 when front image is missing', async () => {
render(<KycPage />);
// Step 1 → 2
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByTestId('kyc-front-input')).toBeInTheDocument();
});
// Try to advance without uploading
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByText(/vui lòng tải ảnh mặt trước/i)).toBeInTheDocument();
});
});
it('goes back from step 2 → step 1', async () => {
render(<KycPage />);
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByTestId('kyc-back-button')).toBeInTheDocument();
});
await userEvent.click(screen.getByTestId('kyc-back-button'));
await waitFor(() => {
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
});
});
it('renders VERIFIED state without form', () => {
setupStore({ kycStatus: 'VERIFIED' });
render(<KycPage />);
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
expect(screen.getByText('Danh tính đã được xác minh')).toBeInTheDocument();
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
});
it('renders PENDING state without form', () => {
setupStore({ kycStatus: 'PENDING' });
render(<KycPage />);
expect(screen.getByText('Đang chờ duyệt')).toBeInTheDocument();
expect(screen.getByText('Đang xem xét hồ sơ')).toBeInTheDocument();
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
});
it('renders REJECTED state with form available', () => {
setupStore({ kycStatus: 'REJECTED' });
render(<KycPage />);
expect(screen.getByText('Bị từ chối')).toBeInTheDocument();
// Form should still show for resubmission
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
});
it('dismisses error when close button is clicked', async () => {
render(<KycPage />);
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByTestId('kyc-error')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Đóng'));
expect(screen.queryByTestId('kyc-error')).not.toBeInTheDocument();
});
it('changes document type via select', async () => {
render(<KycPage />);
const select = screen.getByLabelText(/loại giấy tờ/i);
await userEvent.selectOptions(select, 'PASSPORT');
expect(select).toHaveValue('PASSPORT');
});
});

View File

@@ -0,0 +1,134 @@
/* 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';
const mockUseTransactions = vi.fn();
const mockTransactions = {
items: [
{
id: 'tx-1',
type: 'SUBSCRIPTION',
status: 'COMPLETED',
amountVND: '499000',
provider: 'VNPAY',
providerTxId: 'TXN123456789012',
createdAt: '2024-06-15T10:00:00.000Z',
},
{
id: 'tx-2',
type: 'LISTING_FEE',
status: 'PENDING',
amountVND: '100000',
provider: 'MOMO',
providerTxId: null,
createdAt: '2024-06-20T10:00:00.000Z',
},
{
id: 'tx-3',
type: 'FEATURED_LISTING',
status: 'FAILED',
amountVND: '200000',
provider: 'ZALOPAY',
providerTxId: 'ZLP999',
createdAt: '2024-06-21T10:00:00.000Z',
},
],
total: 3,
};
vi.mock('@/lib/hooks/use-payments', () => ({
useTransactions: (...args: unknown[]) => mockUseTransactions(...args),
}));
vi.mock('@/lib/currency', () => ({
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
}));
import PaymentsPage from '../payments/page';
describe('PaymentsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseTransactions.mockReturnValue({ data: mockTransactions, isLoading: false });
});
it('renders payment page heading and description', () => {
render(<PaymentsPage />);
expect(screen.getByRole('heading', { level: 1, name: 'Thanh toán' })).toBeInTheDocument();
expect(screen.getAllByText(/lịch sử giao dịch/i).length).toBeGreaterThanOrEqual(1);
});
it('renders summary cards with correct values', () => {
render(<PaymentsPage />);
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
expect(screen.getByText('Đã thanh toán')).toBeInTheDocument();
expect(screen.getByText('Đang chờ')).toBeInTheDocument();
});
it('renders transaction table with type/provider/status labels', () => {
render(<PaymentsPage />);
// Type labels appear in desktop table + mobile cards, so use getAllByText
expect(screen.getAllByText('Gói dịch vụ').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Phí đăng tin').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Tin nổi bật').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Thành công').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Chờ xử lý').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Thất bại').length).toBeGreaterThanOrEqual(1);
});
it('renders loading state', () => {
mockUseTransactions.mockReturnValue({ data: undefined, isLoading: true });
render(<PaymentsPage />);
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Đang tải...')).toBeInTheDocument();
});
it('renders empty state', () => {
mockUseTransactions.mockReturnValue({ data: { items: [], total: 0 }, isLoading: false });
render(<PaymentsPage />);
expect(screen.getByText('Chưa có giao dịch nào')).toBeInTheDocument();
});
it('changes status filter via select', async () => {
render(<PaymentsPage />);
const select = screen.getByDisplayValue('Tất cả');
await userEvent.selectOptions(select, 'COMPLETED');
expect(mockUseTransactions).toHaveBeenCalledWith(
expect.objectContaining({ status: 'COMPLETED' }),
);
});
it('truncates long providerTxId', () => {
render(<PaymentsPage />);
expect(screen.getByText('TXN123456789...')).toBeInTheDocument();
});
it('shows dash for missing providerTxId', () => {
render(<PaymentsPage />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('renders pagination when more than 1 page', () => {
// 25 total with limit 20 = 2 pages
const manyItems = Array.from({ length: 20 }, (_, i) => ({
id: `tx-${i}`,
type: 'SUBSCRIPTION',
status: 'COMPLETED',
amountVND: '100000',
provider: 'VNPAY',
providerTxId: null,
createdAt: '2024-06-15T10:00:00.000Z',
}));
mockUseTransactions.mockReturnValue({
data: { items: manyItems, total: 25 },
isLoading: false,
});
render(<PaymentsPage />);
expect(screen.getByText(/trang 1\/2/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /trước/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /sau/i })).not.toBeDisabled();
});
});