diff --git a/frontend/src/components/SkeletonLoader.css b/frontend/src/components/SkeletonLoader.css new file mode 100644 index 00000000..890301ed --- /dev/null +++ b/frontend/src/components/SkeletonLoader.css @@ -0,0 +1,139 @@ +.skeleton-loader { + width: 100%; + color: var(--text-muted); +} + +.skeleton-loader-block { + display: block; + min-height: 0.85rem; + border-radius: 8px; + background: + linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent), + var(--border); + background-size: 220% 100%; + animation: skeleton-loader-shimmer 1.35s ease-in-out infinite; +} + +:root[data-theme='light'] .skeleton-loader-block { + background: + linear-gradient(90deg, transparent, rgba(15, 98, 254, 0.08), transparent), + rgba(21, 34, 53, 0.12); +} + +.skeleton-loader-text { + display: grid; + gap: 0.65rem; +} + +.skeleton-loader-line { + height: 0.85rem; +} + +.skeleton-loader-line--short { + width: 68%; +} + +.skeleton-loader-line--tiny { + width: 42%; +} + +.skeleton-loader-card { + display: grid; + gap: 1rem; + width: min(360px, 100%); + padding: 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); +} + +.skeleton-loader-card-header { + display: flex; + align-items: center; + gap: 0.85rem; +} + +.skeleton-loader-avatar { + width: 2.75rem; + height: 2.75rem; + border-radius: 50%; + flex: 0 0 auto; +} + +.skeleton-loader-card-title { + display: grid; + flex: 1; + gap: 0.55rem; +} + +.skeleton-loader-media { + height: 8rem; +} + +.skeleton-loader-table { + display: grid; + gap: 0.5rem; + width: min(640px, 100%); +} + +.skeleton-loader-table-row { + display: grid; + grid-template-columns: 1.4fr 1fr 0.8fr; + gap: 0.75rem; + align-items: center; +} + +.skeleton-loader-table-row--header .skeleton-loader-block { + min-height: 0.7rem; + opacity: 0.72; +} + +.skeleton-loader-dashboard { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; + width: min(520px, 100%); +} + +.skeleton-loader-dashboard-card { + display: grid; + gap: 0.75rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); +} + +.skeleton-loader-metric { + height: 2rem; + width: 74%; +} + +@keyframes skeleton-loader-shimmer { + 0% { + background-position: 180% 0; + } + 100% { + background-position: -80% 0; + } +} + +@media (max-width: 540px) { + .skeleton-loader-dashboard { + grid-template-columns: 1fr; + } + + .skeleton-loader-table-row { + grid-template-columns: 1.2fr 0.9fr; + } + + .skeleton-loader-table-row .skeleton-loader-block:nth-child(3) { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton-loader-block { + animation: none; + } +} diff --git a/frontend/src/components/SkeletonLoader.jsx b/frontend/src/components/SkeletonLoader.jsx new file mode 100644 index 00000000..e272e52e --- /dev/null +++ b/frontend/src/components/SkeletonLoader.jsx @@ -0,0 +1,109 @@ +import './SkeletonLoader.css'; + +function clampCount(value, fallback) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, 12); +} + +function SkeletonBlock({ className = '', style }) { + return ; +} + +function TextSkeleton({ lines }) { + const count = clampCount(lines, 3); + return ( + + ); +} + +function CardSkeleton() { + return ( + + ); +} + +function TableSkeleton({ rows }) { + const count = clampCount(rows, 5); + return ( + + ); +} + +function DashboardSkeleton() { + return ( + + ); +} + +export default function SkeletonLoader({ + variant = 'text', + lines = 3, + rows = 5, + label = 'Loading content', + className = '', +}) { + const classes = ['skeleton-loader', `skeleton-loader--${variant}`, className] + .filter(Boolean) + .join(' '); + + const renderSkeleton = () => { + switch (variant) { + case 'card': + return ; + case 'table': + return ; + case 'dashboard': + return ; + case 'text': + default: + return ; + } + }; + + return ( +
+ {renderSkeleton()} + {label} +
+ ); +} diff --git a/frontend/src/components/SkeletonLoader.test.jsx b/frontend/src/components/SkeletonLoader.test.jsx new file mode 100644 index 00000000..cdc5cb4c --- /dev/null +++ b/frontend/src/components/SkeletonLoader.test.jsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import SkeletonLoader from './SkeletonLoader'; + +describe('SkeletonLoader', () => { + it('renders an accessible loading status', () => { + render(); + + const status = screen.getByRole('status', { name: 'Loading campaigns' }); + expect(status.getAttribute('aria-busy')).toBe('true'); + expect(status.textContent).toBe('Loading campaigns'); + }); + + it('renders configurable text lines', () => { + const { container } = render(); + + expect(container.querySelectorAll('.skeleton-loader-line')).toHaveLength(4); + }); + + it('renders table rows with a capped row count', () => { + const { container } = render(); + + expect(container.querySelectorAll('.skeleton-loader-table-row')).toHaveLength(13); + }); + + it('renders dashboard cards', () => { + const { container } = render(); + + expect(container.querySelectorAll('.skeleton-loader-dashboard-card')).toHaveLength(4); + }); +}); diff --git a/frontend/src/stories/SkeletonLoader.stories.jsx b/frontend/src/stories/SkeletonLoader.stories.jsx new file mode 100644 index 00000000..98780bf2 --- /dev/null +++ b/frontend/src/stories/SkeletonLoader.stories.jsx @@ -0,0 +1,46 @@ +import SkeletonLoader from '../components/SkeletonLoader.jsx'; + +export default { + title: 'Components/SkeletonLoader', + component: SkeletonLoader, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['text', 'card', 'table', 'dashboard'], + }, + lines: { control: 'number' }, + rows: { control: 'number' }, + label: { control: 'text' }, + }, +}; + +export const Text = { + args: { + variant: 'text', + lines: 3, + label: 'Loading copy', + }, +}; + +export const Card = { + args: { + variant: 'card', + label: 'Loading campaign card', + }, +}; + +export const Table = { + args: { + variant: 'table', + rows: 5, + label: 'Loading campaign table', + }, +}; + +export const Dashboard = { + args: { + variant: 'dashboard', + label: 'Loading dashboard metrics', + }, +};