refactor(web): pushToast store + ToastHost; 8 sites migrated (W3)
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
let { playlist }: { playlist: Playlist } = $props();
|
||||
|
||||
@@ -18,16 +19,6 @@
|
||||
let starting = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
// --- Toast ---
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => { toast = null; }, 5000);
|
||||
}
|
||||
|
||||
function toggleMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -92,27 +83,17 @@
|
||||
menuOpen = false;
|
||||
try {
|
||||
await refreshDiscover();
|
||||
showToast('Discover refreshed.');
|
||||
pushToast('Discover refreshed.');
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlist.id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
} catch (err: unknown) {
|
||||
showToast(`Refresh failed: ${errCode(err)}`);
|
||||
pushToast(`Refresh failed: ${errCode(err)}`, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={() => { menuOpen = false; }} />
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow-md"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card relative">
|
||||
{#if isDiscover}
|
||||
<div class="kebab-wrap absolute right-1 top-1 z-10">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
// Single global toast renderer. Mounted once in +layout.svelte; pages call
|
||||
// pushToast() from $lib/stores/toast.svelte. The {#key} re-mount on id
|
||||
// change is what makes screen readers re-announce a replacement toast.
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
</script>
|
||||
|
||||
{#if toast.value}
|
||||
{#key toast.value.id}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="toast"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border bg-surface px-4 py-3 text-sm shadow-lg"
|
||||
class:border-border={toast.value.variant === 'info'}
|
||||
class:text-text-primary={toast.value.variant === 'info'}
|
||||
class:border-error={toast.value.variant === 'error'}
|
||||
class:text-error={toast.value.variant === 'error'}
|
||||
>
|
||||
{toast.value.message}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Each test loads a fresh module so module-scope state (_toast, _timer,
|
||||
// _next) doesn't leak between cases. vi.useFakeTimers() controls the
|
||||
// auto-clear timeout deterministically; we never depend on real wall time.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('pushToast / clearToast', () => {
|
||||
test('pushToast sets value with default variant info', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('hello');
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
expect(mod.toast.value?.message).toBe('hello');
|
||||
expect(mod.toast.value?.variant).toBe('info');
|
||||
});
|
||||
|
||||
test('pushToast respects explicit variant error', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('boom', 'error');
|
||||
expect(mod.toast.value?.variant).toBe('error');
|
||||
expect(mod.toast.value?.message).toBe('boom');
|
||||
});
|
||||
|
||||
test('pushToast auto-clears after default 5000ms', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('temporary');
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mod.toast.value).toBeNull();
|
||||
});
|
||||
|
||||
test('pushToast respects custom duration', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('quick', 'info', 1000);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(mod.toast.value).toBeNull();
|
||||
});
|
||||
|
||||
test('a second push replaces the first and only the second timer fires', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('first');
|
||||
vi.advanceTimersByTime(2000);
|
||||
mod.pushToast('second');
|
||||
expect(mod.toast.value?.message).toBe('second');
|
||||
// 4000ms after the second push, only 6000ms have elapsed total —
|
||||
// the first push's original 5000ms timer must NOT have fired
|
||||
// (which would have cleared the second toast).
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(mod.toast.value?.message).toBe('second');
|
||||
// Now run out the second's clock.
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(mod.toast.value).toBeNull();
|
||||
});
|
||||
|
||||
test('clearToast resets value and timer', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('present');
|
||||
expect(mod.toast.value).not.toBeNull();
|
||||
mod.clearToast();
|
||||
expect(mod.toast.value).toBeNull();
|
||||
// Timer was cleared — advancing past the original duration must not
|
||||
// re-clear or otherwise touch state in a way that would surface here.
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(mod.toast.value).toBeNull();
|
||||
});
|
||||
|
||||
test('each push increments the id (drives {#key} re-mount)', async () => {
|
||||
const mod = await import('./toast.svelte');
|
||||
mod.pushToast('a');
|
||||
const firstId = mod.toast.value?.id;
|
||||
mod.pushToast('b');
|
||||
const secondId = mod.toast.value?.id;
|
||||
mod.pushToast('c');
|
||||
const thirdId = mod.toast.value?.id;
|
||||
expect(firstId).toBeDefined();
|
||||
expect(secondId).toBeDefined();
|
||||
expect(thirdId).toBeDefined();
|
||||
expect(secondId!).toBeGreaterThan(firstId!);
|
||||
expect(thirdId!).toBeGreaterThan(secondId!);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Shared toast store. Pages call `pushToast(message, variant?)`; a single
|
||||
// <ToastHost /> mounted in +layout.svelte renders the result. Replacing a
|
||||
// live toast cancels its timer so the new message gets the full duration,
|
||||
// and bumps `id` so the host's {#key} block re-mounts the node — important
|
||||
// for screen readers, which won't re-announce text changes inside an
|
||||
// already-live region.
|
||||
|
||||
type ToastVariant = 'info' | 'error';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
}
|
||||
|
||||
let _toast = $state<Toast | null>(null);
|
||||
let _timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _next = 0;
|
||||
|
||||
export const toast = {
|
||||
get value(): Toast | null {
|
||||
return _toast;
|
||||
}
|
||||
};
|
||||
|
||||
export function pushToast(
|
||||
message: string,
|
||||
variant: ToastVariant = 'info',
|
||||
durationMs = 5000
|
||||
): void {
|
||||
if (_timer) clearTimeout(_timer);
|
||||
_toast = { id: ++_next, message, variant };
|
||||
_timer = setTimeout(() => {
|
||||
_toast = null;
|
||||
_timer = null;
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
export function clearToast(): void {
|
||||
if (_timer) clearTimeout(_timer);
|
||||
_timer = null;
|
||||
_toast = null;
|
||||
}
|
||||
Reference in New Issue
Block a user