feat: add Skeleton primitive
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
src/design-system/primitives/Skeleton/Skeleton.module.css
Normal file
48
src/design-system/primitives/Skeleton/Skeleton.module.css
Normal file
@@ -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;
|
||||
}
|
||||
82
src/design-system/primitives/Skeleton/Skeleton.test.tsx
Normal file
82
src/design-system/primitives/Skeleton/Skeleton.test.tsx
Normal file
@@ -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(<Skeleton />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el).toHaveClass('rectangular')
|
||||
expect(el).toHaveClass('shimmer')
|
||||
})
|
||||
|
||||
it('renders text variant with single bar', () => {
|
||||
const { container } = render(<Skeleton variant="text" />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el).toHaveClass('text')
|
||||
expect(el).toHaveClass('shimmer')
|
||||
})
|
||||
|
||||
it('renders circular variant', () => {
|
||||
const { container } = render(<Skeleton variant="circular" />)
|
||||
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(<Skeleton variant="text" lines={3} />)
|
||||
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(<Skeleton variant="text" lines={3} />)
|
||||
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(<Skeleton variant="rectangular" width={200} height={50} />)
|
||||
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(<Skeleton variant="circular" width="80px" height="80px" />)
|
||||
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(<Skeleton className="custom-class" />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('circular has default 40x40 dimensions', () => {
|
||||
const { container } = render(<Skeleton variant="circular" />)
|
||||
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(<Skeleton variant="rectangular" />)
|
||||
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(<Skeleton />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
71
src/design-system/primitives/Skeleton/Skeleton.tsx
Normal file
71
src/design-system/primitives/Skeleton/Skeleton.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={[styles.skeleton, styles.text, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
|
||||
style={{ width: w, height: h }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={[styles.textGroup, className ?? ''].filter(Boolean).join(' ')} aria-hidden="true">
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={[styles.skeleton, styles.text, styles.shimmer].join(' ')}
|
||||
style={{
|
||||
width: i === lineCount - 1 ? '70%' : (w ?? '100%'),
|
||||
height: h,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'circular') {
|
||||
return (
|
||||
<div
|
||||
className={[styles.skeleton, styles.circular, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
|
||||
style={{ width: w ?? '40px', height: h ?? '40px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// rectangular (default)
|
||||
return (
|
||||
<div
|
||||
className={[styles.skeleton, styles.rectangular, styles.shimmer, className ?? ''].filter(Boolean).join(' ')}
|
||||
style={{ width: w ?? '100%', height: h ?? '80px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user