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