Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions frontend/src/components/SkeletonLoader.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
109 changes: 109 additions & 0 deletions frontend/src/components/SkeletonLoader.jsx
Original file line number Diff line number Diff line change
@@ -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 <span className={`skeleton-loader-block ${className}`.trim()} style={style} />;
}

function TextSkeleton({ lines }) {
const count = clampCount(lines, 3);
return (
<div className="skeleton-loader-text" aria-hidden="true">
{Array.from({ length: count }, (_, index) => (
<SkeletonBlock
key={index}
className="skeleton-loader-line"
style={{ width: `${index === count - 1 ? 68 : 100}%` }}
/>
))}
</div>
);
}

function CardSkeleton() {
return (
<div className="skeleton-loader-card" aria-hidden="true">
<div className="skeleton-loader-card-header">
<SkeletonBlock className="skeleton-loader-avatar" />
<div className="skeleton-loader-card-title">
<SkeletonBlock className="skeleton-loader-line skeleton-loader-line--short" />
<SkeletonBlock className="skeleton-loader-line skeleton-loader-line--tiny" />
</div>
</div>
<SkeletonBlock className="skeleton-loader-media" />
<TextSkeleton lines={3} />
</div>
);
}

function TableSkeleton({ rows }) {
const count = clampCount(rows, 5);
return (
<div className="skeleton-loader-table" aria-hidden="true">
<div className="skeleton-loader-table-row skeleton-loader-table-row--header">
<SkeletonBlock />
<SkeletonBlock />
<SkeletonBlock />
</div>
{Array.from({ length: count }, (_, index) => (
<div className="skeleton-loader-table-row" key={index}>
<SkeletonBlock />
<SkeletonBlock />
<SkeletonBlock />
</div>
))}
</div>
);
}

function DashboardSkeleton() {
return (
<div className="skeleton-loader-dashboard" aria-hidden="true">
{Array.from({ length: 4 }, (_, index) => (
<div className="skeleton-loader-dashboard-card" key={index}>
<SkeletonBlock className="skeleton-loader-line skeleton-loader-line--tiny" />
<SkeletonBlock className="skeleton-loader-metric" />
<SkeletonBlock className="skeleton-loader-line skeleton-loader-line--short" />
</div>
))}
</div>
);
}

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 <CardSkeleton />;
case 'table':
return <TableSkeleton rows={rows} />;
case 'dashboard':
return <DashboardSkeleton />;
case 'text':
default:
return <TextSkeleton lines={lines} />;
}
};

return (
<div className={classes} role="status" aria-busy="true" aria-label={label}>
{renderSkeleton()}
<span className="sr-only">{label}</span>
</div>
);
}
31 changes: 31 additions & 0 deletions frontend/src/components/SkeletonLoader.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<SkeletonLoader label="Loading campaigns" />);

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(<SkeletonLoader variant="text" lines={4} />);

expect(container.querySelectorAll('.skeleton-loader-line')).toHaveLength(4);
});

it('renders table rows with a capped row count', () => {
const { container } = render(<SkeletonLoader variant="table" rows={20} />);

expect(container.querySelectorAll('.skeleton-loader-table-row')).toHaveLength(13);
});

it('renders dashboard cards', () => {
const { container } = render(<SkeletonLoader variant="dashboard" />);

expect(container.querySelectorAll('.skeleton-loader-dashboard-card')).toHaveLength(4);
});
});
46 changes: 46 additions & 0 deletions frontend/src/stories/SkeletonLoader.stories.jsx
Original file line number Diff line number Diff line change
@@ -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',
},
};