diff --git a/src/design-system/primitives/Skeleton/Skeleton.module.css b/src/design-system/primitives/Skeleton/Skeleton.module.css new file mode 100644 index 0000000..d801a0c --- /dev/null +++ b/src/design-system/primitives/Skeleton/Skeleton.module.css @@ -0,0 +1,48 @@ +@keyframes shimmer { + from { + background-position: -200% center; + } + to { + background-position: 200% center; + } +} + +.skeleton { + background-color: var(--bg-inset); + display: block; +} + +.shimmer { + background-image: linear-gradient( + 90deg, + var(--bg-inset) 25%, + color-mix(in srgb, var(--bg-inset) 60%, transparent) 50%, + var(--bg-inset) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s linear infinite; +} + +.text { + height: 12px; + border-radius: 4px; + width: 100%; +} + +.textGroup { + display: flex; + flex-direction: column; + gap: 8px; +} + +.circular { + border-radius: 50%; + width: 40px; + height: 40px; +} + +.rectangular { + border-radius: var(--radius-sm); + width: 100%; + height: 80px; +} diff --git a/src/design-system/primitives/Skeleton/Skeleton.test.tsx b/src/design-system/primitives/Skeleton/Skeleton.test.tsx new file mode 100644 index 0000000..bc5177d --- /dev/null +++ b/src/design-system/primitives/Skeleton/Skeleton.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { Skeleton } from './Skeleton' + +describe('Skeleton', () => { + it('renders rectangular variant by default', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el).toHaveClass('rectangular') + expect(el).toHaveClass('shimmer') + }) + + it('renders text variant with single bar', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el).toHaveClass('text') + expect(el).toHaveClass('shimmer') + }) + + it('renders circular variant', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el).toHaveClass('circular') + expect(el).toHaveClass('shimmer') + }) + + it('renders multiple lines for text variant when lines > 1', () => { + const { container } = render() + const group = container.firstChild as HTMLElement + expect(group).toHaveClass('textGroup') + const bars = group.querySelectorAll('.text') + expect(bars).toHaveLength(3) + }) + + it('renders last bar at 70% width when lines > 1', () => { + const { container } = render() + const group = container.firstChild as HTMLElement + const bars = group.querySelectorAll('.text') + const lastBar = bars[bars.length - 1] as HTMLElement + expect(lastBar.style.width).toBe('70%') + }) + + it('applies custom width and height', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el.style.width).toBe('200px') + expect(el.style.height).toBe('50px') + }) + + it('applies custom width and height as strings', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el.style.width).toBe('80px') + expect(el.style.height).toBe('80px') + }) + + it('applies extra className', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el).toHaveClass('custom-class') + }) + + it('circular has default 40x40 dimensions', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el.style.width).toBe('40px') + expect(el.style.height).toBe('40px') + }) + + it('rectangular has default 80px height and 100% width', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el.style.width).toBe('100%') + expect(el.style.height).toBe('80px') + }) + + it('is aria-hidden', () => { + const { container } = render() + const el = container.firstChild as HTMLElement + expect(el).toHaveAttribute('aria-hidden', 'true') + }) +}) diff --git a/src/design-system/primitives/Skeleton/Skeleton.tsx b/src/design-system/primitives/Skeleton/Skeleton.tsx new file mode 100644 index 0000000..75e3532 --- /dev/null +++ b/src/design-system/primitives/Skeleton/Skeleton.tsx @@ -0,0 +1,71 @@ +import styles from './Skeleton.module.css' + +interface SkeletonProps { + variant?: 'text' | 'circular' | 'rectangular' + width?: string | number + height?: string | number + lines?: number + className?: string +} + +function normalizeSize(value: string | number | undefined): string | undefined { + if (value === undefined) return undefined + return typeof value === 'number' ? `${value}px` : value +} + +export function Skeleton({ + variant = 'rectangular', + width, + height, + lines, + className, +}: SkeletonProps) { + const w = normalizeSize(width) + const h = normalizeSize(height) + + if (variant === 'text') { + const lineCount = lines && lines > 1 ? lines : 1 + if (lineCount === 1) { + return ( +