feat: add Skeleton loading-placeholder primitive

A reusable, token-driven shimmer block with configurable width/height,
radius, and variant. Decorative (aria-hidden) — loading state is announced
by the container. Freezes to a solid muted block under the global
prefers-reduced-motion override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 19:28:00 +02:00
parent ef8d226ba6
commit 200ab1c0a0
5 changed files with 137 additions and 3 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.109.1"
version = "0.110.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.109.1"
version = "0.110.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.109.1",
"version": "0.110.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,85 @@
<script lang="ts">
/**
* Decorative loading placeholder. Renders a muted block with a subtle
* shimmer that sweeps across it. Purely visual — `aria-hidden` keeps it
* out of the accessibility tree; the loading state should be announced by
* the container (e.g. `role="status"`).
*
* The shimmer freezes to a solid `--surface-elevated` block under the
* global `prefers-reduced-motion` override (see tokens.css), which is the
* intended reduced-motion appearance.
*/
let {
width = '100%',
height,
radius = 'md',
variant = 'block',
testid
}: {
width?: string;
height?: string;
/** Corner radius, mapped to the design-token scale. */
radius?: 'sm' | 'md' | 'lg' | 'pill';
/** `circle` forces a pill radius (for avatars / dots). */
variant?: 'block' | 'text' | 'circle';
testid?: string;
} = $props();
const radiusClass = $derived(variant === 'circle' ? 'radius-pill' : `radius-${radius}`);
const style = $derived(
[`width: ${width};`, height ? `height: ${height};` : null].filter(Boolean).join(' ')
);
</script>
<span
class="skeleton {radiusClass}"
class:text={variant === 'text'}
data-testid={testid}
aria-hidden="true"
{style}
></span>
<style>
.skeleton {
display: block;
/* Static base so the reduced-motion freeze reads as a clean block
rather than a stalled mid-keyframe gradient. */
background-color: var(--surface-elevated);
background-image: linear-gradient(
90deg,
var(--surface) 0%,
var(--surface-elevated) 50%,
var(--surface) 100%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: skeleton-shimmer 1.2s ease-in-out infinite;
}
.skeleton.text {
height: 0.9em;
}
.radius-sm {
border-radius: var(--radius-sm);
}
.radius-md {
border-radius: var(--radius-md);
}
.radius-lg {
border-radius: var(--radius-lg);
}
.radius-pill {
border-radius: var(--radius-pill);
}
@keyframes skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import Skeleton from './Skeleton.svelte';
afterEach(() => cleanup());
describe('Skeleton primitive', () => {
it('renders an element carrying the given testid', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk')).toBeTruthy();
});
it('is decorative (aria-hidden) so screen readers ignore it', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
});
it('applies width and height as inline styles', () => {
render(Skeleton, { props: { testid: 'sk', width: '120px', height: '2rem' } });
const el = screen.getByTestId('sk') as HTMLElement;
expect(el.style.width).toBe('120px');
expect(el.style.height).toBe('2rem');
});
it('defaults width to 100% when omitted', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect((screen.getByTestId('sk') as HTMLElement).style.width).toBe('100%');
});
it('maps the radius prop to a radius class', () => {
render(Skeleton, { props: { testid: 'sk', radius: 'lg' } });
expect(screen.getByTestId('sk').classList.contains('radius-lg')).toBe(true);
});
it('defaults to the md radius', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').classList.contains('radius-md')).toBe(true);
});
it('renders a pill radius for the circle variant', () => {
render(Skeleton, { props: { testid: 'sk', variant: 'circle' } });
expect(screen.getByTestId('sk').classList.contains('radius-pill')).toBe(true);
});
it('carries the skeleton base class for the shimmer', () => {
render(Skeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').classList.contains('skeleton')).toBe(true);
});
});