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:
69
frontend/src/routes/search/SmartSearchStatus.svelte
Normal file
69
frontend/src/routes/search/SmartSearchStatus.svelte
Normal 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}
|
||||
60
frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts
Normal file
60
frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user