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:
34
apps/web/src/lib/components/Spinner.svelte
Normal file
34
apps/web/src/lib/components/Spinner.svelte
Normal 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>
|
||||||
79
apps/web/src/lib/components/Spinner.test.ts
Normal file
79
apps/web/src/lib/components/Spinner.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,9 +16,20 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts'],
|
||||||
exclude: ['tests/**', 'node_modules/**', '.svelte-kit/**'],
|
exclude: ['tests/**', 'node_modules/**', '.svelte-kit/**'],
|
||||||
setupFiles: ['./vitest.setup.ts']
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
// Force Svelte to load its browser entry (index-client.js) instead of
|
||||||
|
// the server entry — `mount()` only exists on the client. Without this
|
||||||
|
// any test that mounts a Svelte 5 component throws
|
||||||
|
// `lifecycle_function_unavailable`. jsdom + the `browser` condition is
|
||||||
|
// the canonical Svelte 5 unit-test setup.
|
||||||
|
server: {
|
||||||
|
deps: {
|
||||||
|
inline: [/^svelte/]
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
conditions: ['browser'],
|
||||||
alias: {
|
alias: {
|
||||||
$lib: new URL('./src/lib', import.meta.url).pathname
|
$lib: new URL('./src/lib', import.meta.url).pathname
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user