From b3dd4b859e1122be63dd0c6f82ec12d43edfed35 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Wed, 4 Feb 2026 01:27:06 -0600 Subject: [PATCH] [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 --- web/src/components/common/Skeleton.test.tsx | 183 ++++++++++++++ web/src/components/common/Skeleton.tsx | 265 ++++++++++++++++++++ web/src/components/common/index.ts | 8 + web/tailwind.config.js | 9 + 4 files changed, 465 insertions(+) create mode 100644 web/src/components/common/Skeleton.test.tsx create mode 100644 web/src/components/common/Skeleton.tsx diff --git a/web/src/components/common/Skeleton.test.tsx b/web/src/components/common/Skeleton.test.tsx new file mode 100644 index 0000000..f4fd856 --- /dev/null +++ b/web/src/components/common/Skeleton.test.tsx @@ -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(); + 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(); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton).toHaveClass('rounded-md'); + }); + + it('should apply circular variant', () => { + const { container } = render(); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton).toHaveClass('rounded-full'); + }); + + it('should apply wave animation', () => { + const { container } = render(); + 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(); + 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(); + 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(); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton).toHaveStyle({ width: '50%', height: '2rem' }); + }); + + it('should be aria-hidden', () => { + const { container } = render(); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton).toHaveAttribute('aria-hidden', 'true'); + }); + + it('should apply custom className', () => { + const { container } = render(); + const skeleton = container.firstChild as HTMLElement; + + expect(skeleton).toHaveClass('my-custom-class'); + }); + }); + + describe('SkeletonText', () => { + it('should render default 3 lines', () => { + const { container } = render(); + 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(); + 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(); + const wrapper = container.firstChild as HTMLElement; + + expect(wrapper).toHaveClass('gap-3'); + }); + }); + + describe('SkeletonCard', () => { + it('should render with header by default', () => { + const { container } = render(); + 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(); + const circular = container.querySelector('.rounded-full'); + + expect(circular).toBeInTheDocument(); + }); + + it('should render footer when showFooter is true', () => { + const { container } = render(); + const rectangles = container.querySelectorAll('.rounded-md'); + + expect(rectangles.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('SkeletonTable', () => { + it('should render correct number of rows', () => { + const { container } = render(); + const rows = container.querySelectorAll('.divide-y > div'); + + expect(rows).toHaveLength(3); + }); + + it('should render header when showHeader is true', () => { + const { container } = render(); + const header = container.querySelector('.bg-background-subtle'); + + expect(header).toBeInTheDocument(); + }); + }); + + describe('SkeletonAvatar', () => { + it('should render with correct size', () => { + const { container } = render(); + const avatar = container.firstChild as HTMLElement; + + expect(avatar).toHaveStyle({ width: '48px', height: '48px' }); + }); + + it('should be circular', () => { + const { container } = render(); + const avatar = container.firstChild as HTMLElement; + + expect(avatar).toHaveClass('rounded-full'); + }); + }); + + describe('SkeletonButton', () => { + it('should render with correct height for size', () => { + const { container } = render(); + const button = container.firstChild as HTMLElement; + + expect(button).toHaveStyle({ height: '48px' }); + }); + + it('should apply custom width', () => { + const { container } = render(); + const button = container.firstChild as HTMLElement; + + expect(button).toHaveStyle({ width: '200px' }); + }); + }); +}); diff --git a/web/src/components/common/Skeleton.tsx b/web/src/components/common/Skeleton.tsx new file mode 100644 index 0000000..5411c2b --- /dev/null +++ b/web/src/components/common/Skeleton.tsx @@ -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 = { + 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 ( +