[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
|
// Loading & Empty States
|
||||||
export { LoadingSpinner, LoadingOverlay } from './LoadingSpinner';
|
export { LoadingSpinner, LoadingOverlay } from './LoadingSpinner';
|
||||||
export { EmptyState } from './EmptyState';
|
export { EmptyState } from './EmptyState';
|
||||||
|
export {
|
||||||
|
Skeleton,
|
||||||
|
SkeletonText,
|
||||||
|
SkeletonCard,
|
||||||
|
SkeletonTable,
|
||||||
|
SkeletonAvatar,
|
||||||
|
SkeletonButton,
|
||||||
|
} from './Skeleton';
|
||||||
|
|
||||||
// Status & Badges
|
// Status & Badges
|
||||||
export { StatusBadge, StatusBadgeFromOptions } from './StatusBadge';
|
export { StatusBadge, StatusBadgeFromOptions } from './StatusBadge';
|
||||||
|
|||||||
@ -162,6 +162,15 @@ export default {
|
|||||||
'ease-out': 'var(--ease-out)',
|
'ease-out': 'var(--ease-out)',
|
||||||
'ease-in-out': 'var(--ease-in-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: [],
|
plugins: [],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user