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,
|
||||
include: ['src/**/*.test.ts'],
|
||||
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: {
|
||||
conditions: ['browser'],
|
||||
alias: {
|
||||
$lib: new URL('./src/lib', import.meta.url).pathname
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user