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]]
|
||||
name = "mangalord"
|
||||
version = "0.109.1"
|
||||
version = "0.110.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.109.1"
|
||||
version = "0.110.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.109.1",
|
||||
"version": "0.110.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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