feat(search): add SmartSearchStatus full-area panels (#739)

Loading panel (role=status, motion-safe spinner + pulsing subtitle) and
combined error panels: 503 (red icon + switch-to-keyword button) and
429 (amber clock icon, no action button). 5 vitest-browser-svelte specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-06 17:40:28 +02:00
parent 8ed65f8602
commit fb00c7818e
2 changed files with 129 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
let {
status,
errorCode,
onSwitchToKeyword
}: {
status: 'loading' | 'error';
errorCode?: SmartSearchErrorCode;
onSwitchToKeyword?: () => void;
} = $props();
const isRateLimited = $derived(errorCode === 'SMART_SEARCH_RATE_LIMITED');
const title = $derived(
isRateLimited ? m.search_error_rate_limited() : m.search_error_unavailable()
);
const body = $derived(
isRateLimited ? m.search_error_rate_limited_body() : m.search_error_unavailable_body()
);
</script>
{#if status === 'loading'}
<div
role="status"
aria-live="polite"
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
>
<div
aria-hidden="true"
class="h-9 w-9 rounded-full border-[3px] border-primary/12 border-t-primary motion-safe:animate-spin"
></div>
<p class="text-sm font-bold text-ink">{m.search_loading_nl()}</p>
<p class="max-w-xs text-[9px] text-ink-3 motion-safe:animate-pulse">
{m.search_loading_nl_sub()}
</p>
</div>
{:else if status === 'error'}
<div role="alert" class="flex flex-col items-center justify-center gap-3 py-16 text-center">
<div
aria-hidden="true"
class="flex h-10 w-10 items-center justify-center rounded-full border-2 text-lg font-bold {isRateLimited
? 'border-amber-400 bg-amber-50 text-amber-600'
: 'border-red-400 bg-red-50 text-red-600'}"
>
{#if isRateLimited}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else}
<span>!</span>
{/if}
</div>
<p class="text-sm font-bold text-ink">{title}</p>
<p class="max-w-xs text-xs text-ink-3">{body}</p>
{#if !isRateLimited}
<button
type="button"
onclick={onSwitchToKeyword}
class="mt-2 inline-flex min-h-[44px] items-center rounded border border-primary bg-primary px-4 py-2 text-sm font-bold text-primary-fg outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{m.search_switch_to_keyword()}
</button>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import SmartSearchStatus from './SmartSearchStatus.svelte';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('SmartSearchStatus', () => {
it('renders a role="status" loading panel with the loading title', async () => {
render(SmartSearchStatus, { status: 'loading' });
const status = page.getByRole('status');
await expect.element(status).toBeInTheDocument();
await expect.element(status).toHaveTextContent('Archiv wird befragt');
});
it('hides the loading panel once the status changes away from loading', async () => {
const { rerender } = render(SmartSearchStatus, { status: 'loading' });
await expect.element(page.getByRole('status')).toBeInTheDocument();
await rerender({ status: 'error', errorCode: 'SMART_SEARCH_UNAVAILABLE' });
await expect.element(page.getByRole('status')).not.toBeInTheDocument();
});
it('renders the 503 panel with title, body and a switch-to-keyword button', async () => {
render(SmartSearchStatus, {
status: 'error',
errorCode: 'SMART_SEARCH_UNAVAILABLE',
onSwitchToKeyword: vi.fn()
});
await expect.element(page.getByText('Intelligente Suche nicht verfügbar')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
.toBeInTheDocument();
});
it('invokes onSwitchToKeyword when the 503 fallback button is clicked', async () => {
const onSwitchToKeyword = vi.fn();
render(SmartSearchStatus, {
status: 'error',
errorCode: 'SMART_SEARCH_UNAVAILABLE',
onSwitchToKeyword
});
await page.getByRole('button', { name: /Volltextsuche wechseln/ }).click();
expect(onSwitchToKeyword).toHaveBeenCalledOnce();
});
it('renders the 429 panel with title and body but no switch-to-keyword button', async () => {
render(SmartSearchStatus, {
status: 'error',
errorCode: 'SMART_SEARCH_RATE_LIMITED',
onSwitchToKeyword: vi.fn()
});
await expect.element(page.getByText('Zu viele Anfragen')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
.not.toBeInTheDocument();
});
});