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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.109.1"
|
version = "0.110.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.109.1"
|
version = "0.110.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.109.1",
|
"version": "0.110.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
85
frontend/src/lib/components/Skeleton.svelte
Normal file
85
frontend/src/lib/components/Skeleton.svelte
Normal 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>
|
||||||
49
frontend/src/lib/components/Skeleton.svelte.test.ts
Normal file
49
frontend/src/lib/components/Skeleton.svelte.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user