[G-009] feat(components): Add Skeleton loading component system

- Add Skeleton base component with variants: text, rectangular, circular
- Add SkeletonText for multi-line text placeholders
- Add SkeletonCard for card-shaped loading states
- Add SkeletonTable for table loading with configurable rows/columns
- Add SkeletonAvatar for avatar placeholders (sm/md/lg/xl)
- Add SkeletonButton for button placeholders
- Add shimmer animation to Tailwind config
- Add comprehensive tests (21 tests passing)
- Export all components from common/index.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 01:27:06 -06:00
parent 434990972e
commit b3dd4b859e
4 changed files with 465 additions and 0 deletions

View File

@ -0,0 +1,183 @@
/**
* Tests for Skeleton component
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import {
Skeleton,
SkeletonText,
SkeletonCard,
SkeletonTable,
SkeletonAvatar,
SkeletonButton,
} from './Skeleton';
describe('Skeleton', () => {
describe('base Skeleton', () => {
it('should render with default props', () => {
const { container } = render(<Skeleton />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toBeInTheDocument();
expect(skeleton).toHaveClass('bg-gray-200', 'dark:bg-gray-700');
expect(skeleton).toHaveClass('animate-pulse');
expect(skeleton).toHaveClass('rounded'); // text variant default
});
it('should apply rectangular variant', () => {
const { container } = render(<Skeleton variant="rectangular" />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveClass('rounded-md');
});
it('should apply circular variant', () => {
const { container } = render(<Skeleton variant="circular" />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveClass('rounded-full');
});
it('should apply wave animation', () => {
const { container } = render(<Skeleton animation="wave" />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveClass('animate-shimmer');
expect(skeleton).not.toHaveClass('animate-pulse');
});
it('should not animate with none', () => {
const { container } = render(<Skeleton animation="none" />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).not.toHaveClass('animate-pulse');
expect(skeleton).not.toHaveClass('animate-shimmer');
});
it('should apply custom width and height as numbers', () => {
const { container } = render(<Skeleton width={100} height={50} />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveStyle({ width: '100px', height: '50px' });
});
it('should apply custom width and height as strings', () => {
const { container } = render(<Skeleton width="50%" height="2rem" />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveStyle({ width: '50%', height: '2rem' });
});
it('should be aria-hidden', () => {
const { container } = render(<Skeleton />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveAttribute('aria-hidden', 'true');
});
it('should apply custom className', () => {
const { container } = render(<Skeleton className="my-custom-class" />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveClass('my-custom-class');
});
});
describe('SkeletonText', () => {
it('should render default 3 lines', () => {
const { container } = render(<SkeletonText />);
const wrapper = container.firstChild as HTMLElement;
const lines = wrapper.querySelectorAll('[aria-hidden="true"]');
expect(lines).toHaveLength(3);
});
it('should render custom number of lines', () => {
const { container } = render(<SkeletonText lines={5} />);
const wrapper = container.firstChild as HTMLElement;
const lines = wrapper.querySelectorAll('[aria-hidden="true"]');
expect(lines).toHaveLength(5);
});
it('should apply gap classes', () => {
const { container } = render(<SkeletonText gap="lg" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('gap-3');
});
});
describe('SkeletonCard', () => {
it('should render with header by default', () => {
const { container } = render(<SkeletonCard />);
const skeletons = container.querySelectorAll('[aria-hidden="true"]');
// Header (2 skeletons) + content lines (3) = 5
expect(skeletons.length).toBeGreaterThanOrEqual(5);
});
it('should render avatar when showAvatar is true', () => {
const { container } = render(<SkeletonCard showAvatar />);
const circular = container.querySelector('.rounded-full');
expect(circular).toBeInTheDocument();
});
it('should render footer when showFooter is true', () => {
const { container } = render(<SkeletonCard showFooter />);
const rectangles = container.querySelectorAll('.rounded-md');
expect(rectangles.length).toBeGreaterThanOrEqual(2);
});
});
describe('SkeletonTable', () => {
it('should render correct number of rows', () => {
const { container } = render(<SkeletonTable rows={3} columns={4} />);
const rows = container.querySelectorAll('.divide-y > div');
expect(rows).toHaveLength(3);
});
it('should render header when showHeader is true', () => {
const { container } = render(<SkeletonTable showHeader />);
const header = container.querySelector('.bg-background-subtle');
expect(header).toBeInTheDocument();
});
});
describe('SkeletonAvatar', () => {
it('should render with correct size', () => {
const { container } = render(<SkeletonAvatar size="lg" />);
const avatar = container.firstChild as HTMLElement;
expect(avatar).toHaveStyle({ width: '48px', height: '48px' });
});
it('should be circular', () => {
const { container } = render(<SkeletonAvatar />);
const avatar = container.firstChild as HTMLElement;
expect(avatar).toHaveClass('rounded-full');
});
});
describe('SkeletonButton', () => {
it('should render with correct height for size', () => {
const { container } = render(<SkeletonButton size="lg" />);
const button = container.firstChild as HTMLElement;
expect(button).toHaveStyle({ height: '48px' });
});
it('should apply custom width', () => {
const { container } = render(<SkeletonButton width={200} />);
const button = container.firstChild as HTMLElement;
expect(button).toHaveStyle({ width: '200px' });
});
});
});

View File

@ -0,0 +1,265 @@
/**
* Skeleton - Loading placeholder component with shimmer animation
*/
import clsx from 'clsx';
type SkeletonVariant = 'text' | 'rectangular' | 'circular';
type SkeletonAnimation = 'pulse' | 'wave' | 'none';
interface SkeletonProps {
/** Width of the skeleton (number = px, string = CSS value) */
width?: number | string;
/** Height of the skeleton (number = px, string = CSS value) */
height?: number | string;
/** Shape variant */
variant?: SkeletonVariant;
/** Animation type */
animation?: SkeletonAnimation;
/** Additional CSS classes */
className?: string;
}
const variantClasses: Record<SkeletonVariant, string> = {
text: 'rounded',
rectangular: 'rounded-md',
circular: 'rounded-full',
};
export function Skeleton({
width,
height,
variant = 'text',
animation = 'pulse',
className,
}: SkeletonProps) {
const style: React.CSSProperties = {
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
};
// Default heights for text variant
if (variant === 'text' && !height) {
style.height = '1em';
}
return (
<div
className={clsx(
'bg-gray-200 dark:bg-gray-700',
variantClasses[variant],
animation === 'pulse' && 'animate-pulse',
animation === 'wave' && 'animate-shimmer',
className
)}
style={style}
aria-hidden="true"
/>
);
}
/**
* SkeletonText - Multiple lines of skeleton text
*/
interface SkeletonTextProps {
/** Number of lines */
lines?: number;
/** Width of last line (percentage) */
lastLineWidth?: string;
/** Gap between lines */
gap?: 'sm' | 'md' | 'lg';
className?: string;
}
const gapClasses = {
sm: 'gap-1',
md: 'gap-2',
lg: 'gap-3',
};
export function SkeletonText({
lines = 3,
lastLineWidth = '60%',
gap = 'md',
className,
}: SkeletonTextProps) {
return (
<div className={clsx('flex flex-col', gapClasses[gap], className)}>
{Array.from({ length: lines }).map((_, index) => (
<Skeleton
key={index}
variant="text"
width={index === lines - 1 ? lastLineWidth : '100%'}
height={16}
/>
))}
</div>
);
}
/**
* SkeletonCard - Card-shaped skeleton placeholder
*/
interface SkeletonCardProps {
/** Show header section */
showHeader?: boolean;
/** Show avatar in header */
showAvatar?: boolean;
/** Number of content lines */
contentLines?: number;
/** Show footer section */
showFooter?: boolean;
className?: string;
}
export function SkeletonCard({
showHeader = true,
showAvatar = false,
contentLines = 3,
showFooter = false,
className,
}: SkeletonCardProps) {
return (
<div
className={clsx(
'bg-surface-card dark:bg-surface-card rounded-lg p-4 space-y-4',
className
)}
>
{showHeader && (
<div className="flex items-center gap-3">
{showAvatar && <Skeleton variant="circular" width={40} height={40} />}
<div className="flex-1 space-y-2">
<Skeleton variant="text" width="40%" height={16} />
<Skeleton variant="text" width="60%" height={12} />
</div>
</div>
)}
<SkeletonText lines={contentLines} />
{showFooter && (
<div className="flex justify-between pt-2">
<Skeleton variant="rectangular" width={80} height={32} />
<Skeleton variant="rectangular" width={80} height={32} />
</div>
)}
</div>
);
}
/**
* SkeletonTable - Table-shaped skeleton for data loading
*/
interface SkeletonTableProps {
/** Number of rows */
rows?: number;
/** Number of columns */
columns?: number;
/** Show header row */
showHeader?: boolean;
className?: string;
}
export function SkeletonTable({
rows = 5,
columns = 4,
showHeader = true,
className,
}: SkeletonTableProps) {
return (
<div
className={clsx(
'bg-surface-card dark:bg-surface-card rounded-lg overflow-hidden',
className
)}
>
{showHeader && (
<div className="flex gap-4 p-4 bg-background-subtle dark:bg-background-emphasis border-b border-border">
{Array.from({ length: columns }).map((_, index) => (
<Skeleton
key={index}
variant="text"
width={`${100 / columns}%`}
height={14}
/>
))}
</div>
)}
<div className="divide-y divide-border dark:divide-border">
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="flex gap-4 p-4">
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={colIndex}
variant="text"
width={`${100 / columns}%`}
height={16}
/>
))}
</div>
))}
</div>
</div>
);
}
/**
* SkeletonAvatar - Avatar placeholder
*/
interface SkeletonAvatarProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
const avatarSizes = {
sm: 32,
md: 40,
lg: 48,
xl: 64,
};
export function SkeletonAvatar({ size = 'md', className }: SkeletonAvatarProps) {
const dimension = avatarSizes[size];
return (
<Skeleton
variant="circular"
width={dimension}
height={dimension}
className={className}
/>
);
}
/**
* SkeletonButton - Button placeholder
*/
interface SkeletonButtonProps {
size?: 'sm' | 'md' | 'lg';
width?: number | string;
className?: string;
}
const buttonHeights = {
sm: 32,
md: 40,
lg: 48,
};
export function SkeletonButton({
size = 'md',
width = 100,
className,
}: SkeletonButtonProps) {
return (
<Skeleton
variant="rectangular"
width={width}
height={buttonHeights[size]}
className={className}
/>
);
}
export default Skeleton;

View File

@ -5,6 +5,14 @@
// Loading & Empty States
export { LoadingSpinner, LoadingOverlay } from './LoadingSpinner';
export { EmptyState } from './EmptyState';
export {
Skeleton,
SkeletonText,
SkeletonCard,
SkeletonTable,
SkeletonAvatar,
SkeletonButton,
} from './Skeleton';
// Status & Badges
export { StatusBadge, StatusBadgeFromOptions } from './StatusBadge';

View File

@ -162,6 +162,15 @@ export default {
'ease-out': 'var(--ease-out)',
'ease-in-out': 'var(--ease-in-out)',
},
animation: {
shimmer: 'shimmer 1.5s infinite',
},
keyframes: {
shimmer: {
'0%': { backgroundPosition: '200% 0' },
'100%': { backgroundPosition: '-200% 0' },
},
},
},
},
plugins: [],