[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:
parent
434990972e
commit
b3dd4b859e
183
web/src/components/common/Skeleton.test.tsx
Normal file
183
web/src/components/common/Skeleton.test.tsx
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
265
web/src/components/common/Skeleton.tsx
Normal file
265
web/src/components/common/Skeleton.tsx
Normal 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;
|
||||
@ -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';
|
||||
|
||||
@ -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: [],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user