Pause AlertToast auto-dismiss while hovered (#235)

Each alert toast had a 5-second auto-dismiss timer that fired even
while the user was reading the card. This adds pause-on-hover: the
dismiss timer stops while the mouse is over a toast and restarts (full
lifetime) on mouse leave. The progress bar animation pauses with it,
so the visual matches the actual remaining time.

All other behavior is preserved: same cyber/mono styling, same spring
slide-in, same risk-color border + glow, same warning icon, same
LVL X/10 readout, same title/source layout, same click-to-fly + dismiss
on body click, same × dismiss button.

Implementation notes:
- Extract a ToastCard sub-component so each card can own its own
  paused state (useState can't be array-indexed in the parent).
- Move the auto-dismiss timer out of useAlertToasts.ts and into
  ToastCard. The hook previously scheduled the dismiss itself, which
  meant the UI couldn't pause it — only the component knows whether
  the user is interacting.
- Add tests covering: title/source/severity render, auto-dismiss
  fires at 5s, hover pauses indefinitely, mouse-leave restarts the
  full lifetime, × dismisses without flying, body-click flies +
  dismisses.

This implements the genuine UX improvement that PR #234 was reaching
for, without #234's broken syntax, missing-field bug, duplicate
timer logic, or design regression.

Refs: #234

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Shadowbroker
2026-05-19 00:49:36 -06:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 40734e310b
commit 421682c447
3 changed files with 261 additions and 113 deletions
@@ -0,0 +1,126 @@
import React from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AlertToast from '@/components/AlertToast';
import type { ToastItem } from '@/hooks/useAlertToasts';
function buildToast(partial: Partial<ToastItem> = {}): ToastItem {
return {
id: 'toast-1',
title: 'Embassy evacuation reported',
source: 'Reuters',
risk_score: 9,
lat: 38.9,
lng: -77.0,
timestamp: Date.now(),
...partial,
};
}
describe('AlertToast', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('renders the toast title, source, and severity label', () => {
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={vi.fn()} />,
);
expect(screen.getByText(toast.title)).toBeTruthy();
expect(screen.getByText(toast.source)).toBeTruthy();
// 9/10 -> CRITICAL
expect(screen.getByText(/CRITICAL/)).toBeTruthy();
expect(screen.getByText(/LVL 9\/10/)).toBeTruthy();
});
it('auto-dismisses after 5 seconds', () => {
const onDismiss = vi.fn();
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={onDismiss} />,
);
expect(onDismiss).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onDismiss).toHaveBeenCalledWith(toast.id);
});
it('pauses auto-dismiss while the card is hovered', () => {
const onDismiss = vi.fn();
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={onDismiss} />,
);
// Hover before the timer fires. mouseEnter must be flushed
// (state update + effect cleanup) in its own act() before we
// advance timers — otherwise the original mount-time timer is
// still active when advanceTimersByTime runs.
const card = screen.getByText(toast.title).closest('[class*="cursor-pointer"]')!;
expect(card).toBeTruthy();
act(() => {
fireEvent.mouseEnter(card);
});
act(() => {
vi.advanceTimersByTime(10_000);
});
// Still no dismiss — timer is paused.
expect(onDismiss).not.toHaveBeenCalled();
// Leave: a fresh full-lifetime timer starts.
act(() => {
fireEvent.mouseLeave(card);
});
act(() => {
vi.advanceTimersByTime(4_999);
});
expect(onDismiss).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1);
});
expect(onDismiss).toHaveBeenCalledWith(toast.id);
});
it('dismisses on × button click without calling onFlyTo', () => {
const onDismiss = vi.fn();
const onFlyTo = vi.fn();
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={onDismiss} onFlyTo={onFlyTo} />,
);
fireEvent.click(screen.getByText('×'));
expect(onDismiss).toHaveBeenCalledWith(toast.id);
expect(onFlyTo).not.toHaveBeenCalled();
});
it('flies to the toast location and dismisses on body click', () => {
const onDismiss = vi.fn();
const onFlyTo = vi.fn();
const toast = buildToast();
render(
<AlertToast toasts={[toast]} onDismiss={onDismiss} onFlyTo={onFlyTo} />,
);
fireEvent.click(screen.getByText(toast.title));
expect(onFlyTo).toHaveBeenCalledWith(toast.lat, toast.lng);
expect(onDismiss).toHaveBeenCalledWith(toast.id);
});
});