feat(fase-16): add Spinner component + unit tests

Fase 16.1 — reusable inline loading indicator. SVG circle with
stroke-dasharray + Tailwind animate-spin (respects motion-reduce).
Sizes: sm=16, md=24, lg=40. Inherits currentColor so it plays nicely
with the Fase 9 theme tokens. Wrapper has role=status + aria-live=polite
and an sr-only m.loading() label (no new Paraglide strings).

vitest.config: resolve.conditions=['browser'] + inline svelte so the
Svelte 5 mount() API resolves to index-client.js instead of the server
entry. Without it the new Spinner.test throws lifecycle_function_unavailable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:39:40 +02:00
parent 51e62ecba3
commit f01dc21e74
3 changed files with 125 additions and 1 deletions

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
interface Props {
size?: 'sm' | 'md' | 'lg';
class?: string;
}
let { size = 'md', class: extra = '' }: Props = $props();
const px = $derived(size === 'sm' ? 16 : size === 'lg' ? 40 : 24);
</script>
<div role="status" aria-live="polite" class={extra}>
<svg
data-testid="spinner"
width={px}
height={px}
viewBox="0 0 24 24"
fill="none"
class="animate-spin motion-reduce:animate-none"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-dasharray="42 14"
opacity="0.75"
/>
</svg>
<span class="sr-only">{m.loading()}</span>
</div>

View File

@@ -0,0 +1,79 @@
/**
* Fase 16 — Spinner component unit tests.
*
* Vitest + jsdom + Svelte 5's `mount()` API. There is no @testing-library/svelte
* in the repo (yet), but Svelte 5 exposes `mount`/`unmount` directly so we can
* inspect the rendered DOM without an extra dependency.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mount, unmount } from 'svelte';
import Spinner from './Spinner.svelte';
import * as m from '$lib/paraglide/messages';
let host: HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let instance: any;
function render(props: Record<string, unknown> = {}) {
host = document.createElement('div');
document.body.appendChild(host);
instance = mount(Spinner, { target: host, props });
return host;
}
beforeEach(() => {
host = document.createElement('div');
});
afterEach(() => {
if (instance) unmount(instance);
if (host?.parentNode) host.parentNode.removeChild(host);
});
describe('Spinner component', () => {
it('SP-U-01: default size renders a 24x24 SVG', () => {
const root = render();
const svg = root.querySelector('svg[data-testid="spinner"]');
expect(svg).not.toBeNull();
expect(svg?.getAttribute('width')).toBe('24');
expect(svg?.getAttribute('height')).toBe('24');
});
it("SP-U-02: size='sm' renders 16x16 and size='lg' renders 40x40", () => {
const smRoot = render({ size: 'sm' });
const smSvg = smRoot.querySelector('svg[data-testid="spinner"]');
expect(smSvg?.getAttribute('width')).toBe('16');
expect(smSvg?.getAttribute('height')).toBe('16');
unmount(instance);
instance = null;
smRoot.remove();
const lgRoot = render({ size: 'lg' });
const lgSvg = lgRoot.querySelector('svg[data-testid="spinner"]');
expect(lgSvg?.getAttribute('width')).toBe('40');
expect(lgSvg?.getAttribute('height')).toBe('40');
});
it('SP-U-03: outer wrapper exposes role="status" and aria-live="polite"', () => {
const root = render();
const wrapper = root.querySelector('[role="status"]');
expect(wrapper).not.toBeNull();
expect(wrapper?.getAttribute('aria-live')).toBe('polite');
});
it("SP-U-04: includes an sr-only label sourced from Paraglide m.loading()", () => {
const root = render();
const srOnly = root.querySelector('.sr-only');
expect(srOnly).not.toBeNull();
// Paraglide's English default is "Loading…" — we don't hard-code it here,
// we compare against the message itself so an i18n switch doesn't break.
expect(srOnly?.textContent).toBe(m.loading());
});
it('SP-U-05: forwards the `class` prop onto the wrapper element', () => {
const root = render({ class: 'text-amber-500 ml-2' });
const wrapper = root.querySelector('[role="status"]');
expect(wrapper?.className).toContain('text-amber-500');
expect(wrapper?.className).toContain('ml-2');
});
});