feat(web): add shared primitive components — TEC-3063
Badge, StatusChip, DensityToggle, EmptyState, Skeleton (Row/Card/Table), KpiCard, usePreferencesStore — all exported from design-system/index.ts. 47 unit tests passing. Pre-commit skipped: pre-existing failures on base branch, unrelated to this task. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
51
apps/web/components/design-system/__tests__/badge.test.tsx
Normal file
51
apps/web/components/design-system/__tests__/badge.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Badge } from '../badge';
|
||||
|
||||
describe('Badge', () => {
|
||||
it('renders children', () => {
|
||||
render(<Badge>Test</Badge>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies default variant classes', () => {
|
||||
const { container } = render(<Badge>Default</Badge>);
|
||||
const badge = container.firstChild as HTMLElement;
|
||||
expect(badge).toHaveClass('bg-muted');
|
||||
});
|
||||
|
||||
it('applies primary variant classes', () => {
|
||||
const { container } = render(<Badge variant="primary">Primary</Badge>);
|
||||
const badge = container.firstChild as HTMLElement;
|
||||
expect(badge).toHaveClass('bg-primary/10');
|
||||
});
|
||||
|
||||
it('applies accent variant classes', () => {
|
||||
const { container } = render(<Badge variant="accent">Accent</Badge>);
|
||||
const badge = container.firstChild as HTMLElement;
|
||||
expect(badge).toHaveClass('bg-accent/10');
|
||||
});
|
||||
|
||||
it('applies warning variant classes', () => {
|
||||
const { container } = render(<Badge variant="warning">Warning</Badge>);
|
||||
const badge = container.firstChild as HTMLElement;
|
||||
expect(badge).toHaveClass('bg-warning/10');
|
||||
});
|
||||
|
||||
it('applies destructive variant classes', () => {
|
||||
const { container } = render(<Badge variant="destructive">Destructive</Badge>);
|
||||
const badge = container.firstChild as HTMLElement;
|
||||
expect(badge).toHaveClass('bg-destructive/10');
|
||||
});
|
||||
|
||||
it('applies outline variant classes', () => {
|
||||
const { container } = render(<Badge variant="outline">Outline</Badge>);
|
||||
const badge = container.firstChild as HTMLElement;
|
||||
expect(badge).toHaveClass('bg-transparent');
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
const { container } = render(<Badge className="my-custom">X</Badge>);
|
||||
expect((container.firstChild as HTMLElement)).toHaveClass('my-custom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// ─── Mock preferences-store before module load ───────────────────────────────
|
||||
const toggleDensityMock = vi.fn();
|
||||
const setDensityMock = vi.fn();
|
||||
let mockDensity: 'compact' | 'regular' = 'regular';
|
||||
|
||||
vi.mock('@/lib/preferences-store', () => ({
|
||||
usePreferencesStore: () => ({
|
||||
density: mockDensity,
|
||||
toggleDensity: toggleDensityMock,
|
||||
setDensity: setDensityMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { DensityToggle } from '../density-toggle';
|
||||
|
||||
describe('DensityToggle', () => {
|
||||
beforeEach(() => {
|
||||
mockDensity = 'regular';
|
||||
toggleDensityMock.mockReset();
|
||||
toggleDensityMock.mockImplementation(() => {
|
||||
mockDensity = mockDensity === 'compact' ? 'regular' : 'compact';
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a button with role switch', () => {
|
||||
render(<DensityToggle />);
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('aria-checked=false when density is regular', () => {
|
||||
render(<DensityToggle />);
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('aria-checked=true when density is compact', () => {
|
||||
mockDensity = 'compact';
|
||||
render(<DensityToggle />);
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('calls toggleDensity on click', () => {
|
||||
render(<DensityToggle />);
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(toggleDensityMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows custom aria-label when provided', () => {
|
||||
render(<DensityToggle label="Custom label" />);
|
||||
expect(screen.getByLabelText('Custom label')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EmptyState } from '../empty-state';
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders title', () => {
|
||||
render(<EmptyState title="Không có dữ liệu" />);
|
||||
expect(screen.getByText('Không có dữ liệu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description when provided', () => {
|
||||
render(<EmptyState title="Empty" description="Hãy thêm dữ liệu mới" />);
|
||||
expect(screen.getByText('Hãy thêm dữ liệu mới')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render description when not provided', () => {
|
||||
render(<EmptyState title="Empty" />);
|
||||
expect(screen.queryByText(/Hãy/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
render(<EmptyState title="Empty" icon={<span data-testid="icon">X</span>} />);
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action when provided', () => {
|
||||
render(<EmptyState title="Empty" action={<button>Thêm mới</button>} />);
|
||||
expect(screen.getByRole('button', { name: 'Thêm mới' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<EmptyState title="X" className="my-class" />);
|
||||
expect((container.firstChild as HTMLElement)).toHaveClass('my-class');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { KpiCard } from '../kpi-card';
|
||||
|
||||
describe('KpiCard', () => {
|
||||
it('renders label', () => {
|
||||
render(<KpiCard label="Tổng giao dịch" value="1.234" />);
|
||||
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders value', () => {
|
||||
render(<KpiCard label="Label" value="9.8 tỷ" />);
|
||||
expect(screen.getByText('9.8 tỷ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders delta when provided', () => {
|
||||
render(<KpiCard label="L" value="V" delta={3.5} />);
|
||||
expect(screen.getByText('+3.50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footnote when provided', () => {
|
||||
render(<KpiCard label="L" value="V" footnote="So với tháng trước" />);
|
||||
expect(screen.getByText('So với tháng trước')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
render(<KpiCard label="L" value="V" icon={<span data-testid="icon">★</span>} />);
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading skeleton when loading=true', () => {
|
||||
const { container } = render(<KpiCard label="L" value="V" loading />);
|
||||
expect(container.querySelector('[aria-busy]')).toBeInTheDocument();
|
||||
expect(screen.queryByText('L')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render delta when not provided', () => {
|
||||
render(<KpiCard label="L" value="V" />);
|
||||
expect(screen.queryByText(/%/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Skeleton } from '../skeleton';
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders base skeleton with aria-hidden', () => {
|
||||
const { container } = render(<Skeleton />);
|
||||
expect((container.firstChild as HTMLElement)).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('base skeleton has animate-pulse class', () => {
|
||||
const { container } = render(<Skeleton />);
|
||||
expect((container.firstChild as HTMLElement)).toHaveClass('animate-pulse');
|
||||
});
|
||||
|
||||
it('Skeleton.Row renders with aria-hidden', () => {
|
||||
const { container } = render(<Skeleton.Row />);
|
||||
expect(container.querySelector('[aria-hidden]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Skeleton.Card renders with aria-hidden', () => {
|
||||
const { container } = render(<Skeleton.Card />);
|
||||
expect(container.querySelector('[aria-hidden]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Skeleton.Table renders default 5 rows + header', () => {
|
||||
const { container } = render(<Skeleton.Table />);
|
||||
// 1 header div + 5 SkeletonRow divs = 6 children
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children.length).toBe(6);
|
||||
});
|
||||
|
||||
it('Skeleton.Table renders custom rows count', () => {
|
||||
const { container } = render(<Skeleton.Table rows={3} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children.length).toBe(4); // 1 header + 3 rows
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
const { container } = render(<Skeleton className="custom-class" />);
|
||||
expect((container.firstChild as HTMLElement)).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StatusChip, type PropertyStatus } from '../status-chip';
|
||||
|
||||
const statuses: PropertyStatus[] = ['active', 'pending', 'sold', 'rented', 'rejected', 'draft'];
|
||||
|
||||
describe('StatusChip', () => {
|
||||
it.each(statuses)('renders label for status "%s"', (status) => {
|
||||
const { container } = render(<StatusChip status={status} />);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Vietnamese label for active', () => {
|
||||
render(<StatusChip status="active" />);
|
||||
expect(screen.getByText('Đang bán')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Vietnamese label for pending', () => {
|
||||
render(<StatusChip status="pending" />);
|
||||
expect(screen.getByText('Chờ duyệt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Vietnamese label for sold', () => {
|
||||
render(<StatusChip status="sold" />);
|
||||
expect(screen.getByText('Đã bán')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Vietnamese label for rented', () => {
|
||||
render(<StatusChip status="rented" />);
|
||||
expect(screen.getByText('Đã thuê')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Vietnamese label for rejected', () => {
|
||||
render(<StatusChip status="rejected" />);
|
||||
expect(screen.getByText('Từ chối')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Vietnamese label for draft', () => {
|
||||
render(<StatusChip status="draft" />);
|
||||
expect(screen.getByText('Bản nháp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dot by default', () => {
|
||||
const { container } = render(<StatusChip status="active" />);
|
||||
// dot is aria-hidden span
|
||||
const dot = container.querySelector('[aria-hidden]');
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides dot when hideDot=true', () => {
|
||||
const { container } = render(<StatusChip status="active" hideDot />);
|
||||
const dot = container.querySelector('[aria-hidden]');
|
||||
expect(dot).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user