Implemented comprehensive loading and error handling components for the WellNuo web application with full test coverage. Components added: - LoadingSpinner: Configurable spinner with sizes, colors, and variants * LoadingOverlay: Full-screen loading with backdrop options * InlineLoader: Small inline spinner for buttons * PageLoader: Full-page centered loading state - ErrorMessage: Inline error messages with severity levels * FullScreenError: Full-page error states with retry/back actions * FieldError: Form field validation errors * ErrorBoundaryFallback: Error boundary fallback component * EmptyState: Empty data state component - Skeleton: Animated loading skeletons * SkeletonAvatar: Circular avatar skeleton * SkeletonText: Multi-line text skeleton * SkeletonCard: Card-style skeleton * SkeletonList: List of skeleton cards * SkeletonTable: Table skeleton with rows/columns * SkeletonDashboard: Dashboard-style skeleton layout * SkeletonForm: Form skeleton with fields Technical details: - Tailwind CSS styling with cn() utility function - Full accessibility support (ARIA labels, roles) - Comprehensive test coverage (57 tests, all passing) - TypeScript strict mode compliance - Added clsx and tailwind-merge dependencies Files: - web/components/ui/LoadingSpinner.tsx - web/components/ui/ErrorMessage.tsx - web/components/ui/Skeleton.tsx - web/components/ui/index.ts - web/lib/utils.ts - web/components/ui/__tests__/*.test.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
import { render } from '@testing-library/react';
|
|
import {
|
|
Skeleton,
|
|
SkeletonAvatar,
|
|
SkeletonText,
|
|
SkeletonCard,
|
|
SkeletonList,
|
|
SkeletonTable,
|
|
SkeletonForm,
|
|
} from '../Skeleton';
|
|
|
|
describe('Skeleton Component', () => {
|
|
it('renders basic skeleton', () => {
|
|
const { container } = render(<Skeleton />);
|
|
const skeleton = container.firstChild;
|
|
expect(skeleton).toHaveClass('animate-pulse', 'bg-slate-200');
|
|
});
|
|
|
|
it('renders with different variants', () => {
|
|
const { rerender, container } = render(<Skeleton variant="rectangular" />);
|
|
let skeleton = container.firstChild;
|
|
expect(skeleton).toHaveClass('rounded');
|
|
|
|
rerender(<Skeleton variant="circular" />);
|
|
skeleton = container.firstChild;
|
|
expect(skeleton).toHaveClass('rounded-full');
|
|
|
|
rerender(<Skeleton variant="text" />);
|
|
skeleton = container.firstChild;
|
|
expect(skeleton).toHaveClass('h-4');
|
|
});
|
|
|
|
it('applies custom className', () => {
|
|
const { container } = render(<Skeleton className="w-full h-20" />);
|
|
const skeleton = container.firstChild;
|
|
expect(skeleton).toHaveClass('w-full', 'h-20');
|
|
});
|
|
|
|
it('has aria-hidden attribute', () => {
|
|
const { container } = render(<Skeleton />);
|
|
const skeleton = container.firstChild;
|
|
expect(skeleton).toHaveAttribute('aria-hidden', 'true');
|
|
});
|
|
});
|
|
|
|
describe('SkeletonAvatar Component', () => {
|
|
it('renders circular skeleton', () => {
|
|
const { container } = render(<SkeletonAvatar />);
|
|
const avatar = container.firstChild;
|
|
expect(avatar).toHaveClass('rounded-full');
|
|
});
|
|
|
|
it('renders with different sizes', () => {
|
|
const { rerender, container } = render(<SkeletonAvatar size="sm" />);
|
|
let avatar = container.firstChild;
|
|
expect(avatar).toHaveClass('h-8', 'w-8');
|
|
|
|
rerender(<SkeletonAvatar size="lg" />);
|
|
avatar = container.firstChild;
|
|
expect(avatar).toHaveClass('h-12', 'w-12');
|
|
|
|
rerender(<SkeletonAvatar size="xl" />);
|
|
avatar = container.firstChild;
|
|
expect(avatar).toHaveClass('h-16', 'w-16');
|
|
});
|
|
});
|
|
|
|
describe('SkeletonText Component', () => {
|
|
it('renders default number of lines', () => {
|
|
const { container } = render(<SkeletonText />);
|
|
const lines = container.querySelectorAll('.h-4');
|
|
expect(lines).toHaveLength(3);
|
|
});
|
|
|
|
it('renders custom number of lines', () => {
|
|
const { container } = render(<SkeletonText lines={5} />);
|
|
const lines = container.querySelectorAll('.h-4');
|
|
expect(lines).toHaveLength(5);
|
|
});
|
|
|
|
it('makes last line shorter', () => {
|
|
const { container } = render(<SkeletonText lines={2} />);
|
|
const lines = container.querySelectorAll('.h-4');
|
|
expect(lines[1]).toHaveClass('w-3/4');
|
|
});
|
|
});
|
|
|
|
describe('SkeletonCard Component', () => {
|
|
it('renders card with avatar by default', () => {
|
|
const { container } = render(<SkeletonCard />);
|
|
const avatar = container.querySelector('.rounded-full');
|
|
expect(avatar).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders without avatar when specified', () => {
|
|
const { container } = render(<SkeletonCard showAvatar={false} />);
|
|
const avatar = container.querySelector('.rounded-full');
|
|
expect(avatar).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders custom number of text lines', () => {
|
|
const { container } = render(<SkeletonCard lines={5} />);
|
|
const textContainer = container.querySelector('.space-y-2');
|
|
const lines = textContainer?.querySelectorAll('.h-4');
|
|
expect(lines).toHaveLength(5);
|
|
});
|
|
|
|
it('has card styling', () => {
|
|
const { container } = render(<SkeletonCard />);
|
|
const card = container.firstChild;
|
|
expect(card).toHaveClass('rounded-lg', 'border', 'bg-white', 'p-6');
|
|
});
|
|
});
|
|
|
|
describe('SkeletonList Component', () => {
|
|
it('renders default number of cards', () => {
|
|
const { container } = render(<SkeletonList />);
|
|
const cards = container.querySelectorAll('.rounded-lg.border');
|
|
expect(cards).toHaveLength(3);
|
|
});
|
|
|
|
it('renders custom number of cards', () => {
|
|
const { container } = render(<SkeletonList count={5} />);
|
|
const cards = container.querySelectorAll('.rounded-lg.border');
|
|
expect(cards).toHaveLength(5);
|
|
});
|
|
|
|
it('passes showAvatar prop to cards', () => {
|
|
const { container } = render(<SkeletonList showAvatar={false} />);
|
|
const avatars = container.querySelectorAll('.rounded-full');
|
|
expect(avatars).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('SkeletonTable Component', () => {
|
|
it('renders table with header by default', () => {
|
|
const { container } = render(<SkeletonTable />);
|
|
const header = container.querySelector('.bg-slate-50');
|
|
expect(header).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders without header when specified', () => {
|
|
const { container } = render(<SkeletonTable showHeader={false} />);
|
|
const header = container.querySelector('.bg-slate-50');
|
|
expect(header).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders correct number of rows and columns', () => {
|
|
const { container } = render(<SkeletonTable rows={3} columns={4} />);
|
|
const rows = container.querySelectorAll('.divide-y > div');
|
|
expect(rows).toHaveLength(3);
|
|
|
|
const firstRowCells = rows[0].querySelectorAll('.h-4');
|
|
expect(firstRowCells).toHaveLength(4);
|
|
});
|
|
});
|
|
|
|
describe('SkeletonForm Component', () => {
|
|
it('renders default number of fields', () => {
|
|
const { container } = render(<SkeletonForm />);
|
|
const fields = container.querySelectorAll('.space-y-2');
|
|
expect(fields).toHaveLength(3);
|
|
});
|
|
|
|
it('renders custom number of fields', () => {
|
|
const { container } = render(<SkeletonForm fields={5} />);
|
|
const fields = container.querySelectorAll('.space-y-2');
|
|
expect(fields).toHaveLength(5);
|
|
});
|
|
|
|
it('includes submit button skeleton', () => {
|
|
const { container } = render(<SkeletonForm />);
|
|
// Count all h-10 elements (fields + button)
|
|
const elements = container.querySelectorAll('.h-10');
|
|
// Should have fields + 1 submit button
|
|
expect(elements.length).toBeGreaterThan(3);
|
|
});
|
|
});
|