Skip to content

Commit c823ac4

Browse files
committed
refactor(ui): replace full-screen timer overlay with toast system
Closes #173. Adds a non-blocking ToastProvider / useToast system that renders a stack of dismissible notifications in the top-right corner of the screen. The full-screen TimerExpiredOverlay is removed. Changes: - src/components/ui/Toast.tsx (new) ToastProvider context, useToast hook, ToastStack, ToastItem. Supports info / warning / error variants. Toasts auto-dismiss after a configurable durationMs (0 = never). Enter/leave animations respect prefers-reduced-motion. role="alert" for errors, role="status" for info/warning. timerExpiredToast() convenience helper keeps timer-specific wording out of call sites. - src/components/ui/index.tsx Exports ToastProvider, useToast, timerExpiredToast and their types. - src/App.tsx Wraps the router in <ToastProvider> so all routes can use useToast. - src/components/timer/TimerPanel.tsx Drops TimerExpiredOverlay import and state. handleExpire now calls timerExpiredToast(). visualNotify guard preserved unchanged. - src/components/timer/TimerExpiredOverlay.tsx Retained for the player screen (player toasts are out of scope for #173). useCallback wrapping fixed to satisfy react-hooks/use-memo lint rule. - src/pages/admin/GameMaster.tsx Calls useToast and fires an info toast ("<name> joined the lobby") in the JOIN event handler. - src/test/toast.test.tsx (new) 24 unit tests: render, multi-toast, manual dismiss, auto-dismiss, no-dismiss at durationMs=0, role attributes, outside-provider error, lobby join message, timerExpiredToast label variants.
1 parent 41093b1 commit c823ac4

9 files changed

Lines changed: 496 additions & 90 deletions

File tree

src/App.tsx

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'
22
import { lazy, Suspense, useEffect } from 'react'
33
import { seedDefaults } from '@/db'
4+
import { ToastProvider } from '@/components/ui'
45

56
// Pages — Admin (lazy-loaded per route)
67
const Dashboard = lazy(() => import('@/pages/admin/Dashboard'))
@@ -29,25 +30,27 @@ export default function App() {
2930
}, [])
3031

3132
return (
32-
<HashRouter>
33-
<Suspense fallback={<Loading />}>
34-
<Routes>
35-
<Route path="/" element={<Navigate to="/admin" replace />} />
36-
<Route path="/admin" element={<Dashboard />} />
37-
<Route path="/admin/questions" element={<Questions />} />
38-
<Route path="/admin/games" element={<Games />} />
39-
<Route path="/admin/game/:id" element={<GameMaster />} />
40-
<Route path="/admin/layouts/:gameId" element={<Layouts />} />
41-
<Route path="/admin/players-teams" element={<PlayersTeams />} />
42-
<Route path="/admin/notes" element={<Notes />} />
43-
<Route path="/admin/notes/:id" element={<NoteDetail />} />
44-
<Route path="/admin/settings" element={<Settings />} />
45-
<Route path="/join" element={<Join />} />
46-
<Route path="/join/:roomId" element={<Join />} />
47-
<Route path="/play/:roomId" element={<Play />} />
48-
<Route path="*" element={<Navigate to="/admin" replace />} />
49-
</Routes>
50-
</Suspense>
51-
</HashRouter>
33+
<ToastProvider>
34+
<HashRouter>
35+
<Suspense fallback={<Loading />}>
36+
<Routes>
37+
<Route path="/" element={<Navigate to="/admin" replace />} />
38+
<Route path="/admin" element={<Dashboard />} />
39+
<Route path="/admin/questions" element={<Questions />} />
40+
<Route path="/admin/games" element={<Games />} />
41+
<Route path="/admin/game/:id" element={<GameMaster />} />
42+
<Route path="/admin/layouts/:gameId" element={<Layouts />} />
43+
<Route path="/admin/players-teams" element={<PlayersTeams />} />
44+
<Route path="/admin/notes" element={<Notes />} />
45+
<Route path="/admin/notes/:id" element={<NoteDetail />} />
46+
<Route path="/admin/settings" element={<Settings />} />
47+
<Route path="/join" element={<Join />} />
48+
<Route path="/join/:roomId" element={<Join />} />
49+
<Route path="/play/:roomId" element={<Play />} />
50+
<Route path="*" element={<Navigate to="/admin" replace />} />
51+
</Routes>
52+
</Suspense>
53+
</HashRouter>
54+
</ToastProvider>
5255
)
5356
}

src/components/timer/TimerExpiredOverlay.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useEffect, useState, useCallback } from 'react'
22
import { AlarmClock } from 'lucide-react'
33
import { Icon } from '@/components/ui'
44

@@ -10,7 +10,8 @@ interface TimerExpiredOverlayProps {
1010
}
1111

1212
/**
13-
* Fullscreen overlay shown when a timer hits zero.
13+
* Full-screen overlay shown on the **player** screen when a timer hits zero.
14+
* The host-side equivalent was replaced by a toast notification (#173).
1415
* Auto-dismisses after autoDismissMs or on click/keypress.
1516
*/
1617
export function TimerExpiredOverlay({
@@ -20,6 +21,8 @@ export function TimerExpiredOverlay({
2021
}: TimerExpiredOverlayProps) {
2122
const [progress, setProgress] = useState(1)
2223

24+
const handleDismiss = useCallback(() => onDismiss(), [onDismiss])
25+
2326
useEffect(() => {
2427
const start = performance.now()
2528
let rafId: number
@@ -29,33 +32,33 @@ export function TimerExpiredOverlay({
2932
const p = Math.max(0, 1 - elapsed / autoDismissMs)
3033
setProgress(p)
3134
if (p <= 0) {
32-
onDismiss()
35+
handleDismiss()
3336
return
3437
}
3538
rafId = requestAnimationFrame(tick)
3639
}
3740

3841
rafId = requestAnimationFrame(tick)
3942
return () => cancelAnimationFrame(rafId)
40-
}, [autoDismissMs, onDismiss])
43+
}, [autoDismissMs, handleDismiss])
4144

4245
useEffect(() => {
4346
function onKey(e: KeyboardEvent) {
44-
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') onDismiss()
47+
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') handleDismiss()
4548
}
4649
window.addEventListener('keydown', onKey)
4750
return () => window.removeEventListener('keydown', onKey)
48-
}, [onDismiss])
51+
}, [handleDismiss])
4952

5053
const circumference = 2 * Math.PI * 20
5154

5255
return (
5356
<div
5457
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-6 cursor-pointer"
5558
style={{ background: 'rgba(0,0,0,0.82)' }}
56-
onClick={onDismiss}
59+
onClick={handleDismiss}
5760
>
58-
{/* Pulsing ring */}
61+
{/* Countdown ring */}
5962
<div className="relative flex items-center justify-center">
6063
<svg width="120" height="120" viewBox="0 0 48 48">
6164
<circle

src/components/timer/TimerPanel.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { useState, useCallback } from 'react'
22
import { Plus, PauseCircle, PlayCircle, RotateCcw, Trash2 } from 'lucide-react'
3-
import { Button, Icon } from '@/components/ui'
3+
import { Button, Icon, useToast, timerExpiredToast } from '@/components/ui'
44
import { TimerCard } from './TimerCard'
55
import { CreateTimerModal } from './CreateTimerModal'
66
import { EditTimerModal } from './EditTimerModal'
7-
import { TimerExpiredOverlay } from './TimerExpiredOverlay'
87
import { useTimerExpiry } from '@/hooks/useTimer'
98
import type { UseTimerListResult, ExpiryEvent } from '@/hooks/useTimer'
109
import type { Timer } from '@/db'
@@ -25,7 +24,7 @@ interface TimerPanelProps {
2524
export function TimerPanel({ gameId, hook }: TimerPanelProps) {
2625
const [showCreate, setShowCreate] = useState(false)
2726
const [editingTimer, setEditingTimer] = useState<Timer | null>(null)
28-
const [expiredEvent, setExpiredEvent] = useState<ExpiryEvent | null>(null)
27+
const { addToast } = useToast()
2928

3029
const {
3130
timers,
@@ -49,11 +48,14 @@ export function TimerPanel({ gameId, hook }: TimerPanelProps) {
4948

5049
const handlePauseResumeAll = allPaused ? resumeAll : pauseAll
5150

52-
const handleExpire = useCallback((evt: ExpiryEvent) => {
53-
if (evt.visualNotify === 'host' || evt.visualNotify === 'both') {
54-
setExpiredEvent(evt)
55-
}
56-
}, [])
51+
const handleExpire = useCallback(
52+
(evt: ExpiryEvent) => {
53+
if (evt.visualNotify === 'host' || evt.visualNotify === 'both') {
54+
timerExpiredToast(addToast, evt.label)
55+
}
56+
},
57+
[addToast]
58+
)
5759

5860
useTimerExpiry(timers, remaining, handleExpire)
5961

@@ -65,10 +67,6 @@ export function TimerPanel({ gameId, hook }: TimerPanelProps) {
6567

6668
return (
6769
<>
68-
{expiredEvent && (
69-
<TimerExpiredOverlay label={expiredEvent.label} onDismiss={() => setExpiredEvent(null)} />
70-
)}
71-
7270
{showCreate && (
7371
<CreateTimerModal onConfirm={handleCreate} onCancel={() => setShowCreate(false)} />
7472
)}

src/components/ui/Toast.tsx

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import React, { useCallback, useEffect, useState } from 'react'
2+
import type { LucideIcon } from 'lucide-react'
3+
import { X, Info, AlertTriangle, AlertCircle } from 'lucide-react'
4+
import { Icon } from './Icon'
5+
import { ToastContext } from './toastContext'
6+
import type { Toast, ToastOptions, ToastVariant } from './toastContext'
7+
8+
// Computed once at module load -- safe to use during render
9+
const PREFERS_REDUCED_MOTION =
10+
typeof window !== 'undefined' &&
11+
typeof window.matchMedia === 'function' &&
12+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
13+
14+
// ── Provider ──────────────────────────────────────────────────────────────────
15+
16+
export function ToastProvider({ children }: { children: React.ReactNode }) {
17+
const [toasts, setToasts] = useState<Toast[]>([])
18+
19+
const removeToast = useCallback((id: string) => {
20+
setToasts(prev => prev.filter(t => t.id !== id))
21+
}, [])
22+
23+
const addToast = useCallback((message: string, options: ToastOptions = {}): string => {
24+
const id = crypto.randomUUID()
25+
const toast: Toast = {
26+
id,
27+
message,
28+
variant: options.variant ?? 'info',
29+
durationMs: options.durationMs ?? 5000,
30+
icon: options.icon,
31+
}
32+
setToasts(prev => [...prev, toast])
33+
return id
34+
}, [])
35+
36+
return (
37+
<ToastContext.Provider value={{ addToast, removeToast }}>
38+
{children}
39+
<ToastStack toasts={toasts} onRemove={removeToast} />
40+
</ToastContext.Provider>
41+
)
42+
}
43+
44+
// ── Stack ─────────────────────────────────────────────────────────────────────
45+
46+
function ToastStack({ toasts, onRemove }: { toasts: Toast[]; onRemove: (id: string) => void }) {
47+
if (toasts.length === 0) return null
48+
49+
return (
50+
<div
51+
aria-live="polite"
52+
aria-label="Notifications"
53+
className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
54+
style={{ maxWidth: '360px', width: 'calc(100vw - 2rem)' }}
55+
>
56+
{toasts.map(toast => (
57+
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
58+
))}
59+
</div>
60+
)
61+
}
62+
63+
// ── Item ──────────────────────────────────────────────────────────────────────
64+
65+
const VARIANT_STYLES: Record<ToastVariant, { border: string; iconColor: string }> = {
66+
info: { border: 'var(--color-gold)', iconColor: 'var(--color-gold)' },
67+
warning: { border: 'var(--color-amber, #f59e0b)', iconColor: 'var(--color-amber, #f59e0b)' },
68+
error: { border: 'var(--color-red)', iconColor: 'var(--color-red)' },
69+
}
70+
71+
const VARIANT_ICONS: Record<ToastVariant, LucideIcon> = {
72+
info: Info,
73+
warning: AlertTriangle,
74+
error: AlertCircle,
75+
}
76+
77+
function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) {
78+
const [visible, setVisible] = useState(false)
79+
const [leaving, setLeaving] = useState(false)
80+
const reduced = PREFERS_REDUCED_MOTION
81+
82+
// One-frame delay so the initial hidden state paints before the enter transition
83+
useEffect(() => {
84+
const frame = requestAnimationFrame(() => setVisible(true))
85+
return () => cancelAnimationFrame(frame)
86+
}, [])
87+
88+
const dismiss = useCallback(() => {
89+
if (reduced) {
90+
onRemove(toast.id)
91+
return
92+
}
93+
setLeaving(true)
94+
setTimeout(() => onRemove(toast.id), 200)
95+
}, [onRemove, toast.id, reduced])
96+
97+
useEffect(() => {
98+
if (toast.durationMs === 0) return
99+
const timer = setTimeout(dismiss, toast.durationMs)
100+
return () => clearTimeout(timer)
101+
}, [toast.durationMs, dismiss])
102+
103+
const styles = VARIANT_STYLES[toast.variant]
104+
const DefaultIcon = VARIANT_ICONS[toast.variant]
105+
const opacity = reduced ? 1 : visible && !leaving ? 1 : 0
106+
const translateY = reduced ? '0px' : visible && !leaving ? '0px' : '-8px'
107+
108+
return (
109+
<div
110+
role={toast.variant === 'error' ? 'alert' : 'status'}
111+
aria-live={toast.variant === 'error' ? 'assertive' : 'polite'}
112+
className="pointer-events-auto flex items-start gap-3 rounded-lg px-3 py-3 shadow-lg border"
113+
style={{
114+
background: 'var(--color-surface)',
115+
borderColor: styles.border,
116+
borderLeftWidth: '3px',
117+
opacity,
118+
transform: `translateY(${translateY})`,
119+
transition: reduced ? 'none' : 'opacity 0.18s ease, transform 0.18s ease',
120+
boxShadow: '0 4px 16px rgba(0,0,0,0.14)',
121+
}}
122+
>
123+
<span className="mt-0.5 shrink-0" style={{ color: styles.iconColor }}>
124+
{toast.icon ?? <Icon icon={DefaultIcon} size="sm" />}
125+
</span>
126+
<span className="flex-1 text-sm leading-snug" style={{ color: 'var(--color-ink)' }}>
127+
{toast.message}
128+
</span>
129+
<button
130+
onClick={dismiss}
131+
aria-label="Dismiss notification"
132+
className="shrink-0 rounded transition-colors hover:bg-black/10 p-0.5 -mt-0.5 -mr-0.5"
133+
style={{ color: 'var(--color-muted)' }}
134+
>
135+
<Icon icon={X} size="sm" />
136+
</button>
137+
</div>
138+
)
139+
}

src/components/ui/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import type { TransportStatus, TransportType } from '@/transport/types'
66
export { Icon } from './Icon'
77
export { Steps } from './Steps'
88
export type { StepConfig } from './Steps'
9+
export { ToastProvider } from './Toast'
10+
// eslint-disable-next-line react-refresh/only-export-components
11+
export { useToast } from './toastUtils'
12+
export type { Toast, ToastVariant, ToastOptions } from './toastContext'
13+
// eslint-disable-next-line react-refresh/only-export-components
14+
export { timerExpiredToast } from './toastUtils'
15+
export type { ToastContextValue } from './toastContext'
916

1017
// ── Button ────────────────────────────────────────────────────────────────────
1118

src/components/ui/toastContext.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { createContext } from 'react'
2+
import type React from 'react'
3+
4+
// ── Types ─────────────────────────────────────────────────────────────────────
5+
6+
export type ToastVariant = 'info' | 'warning' | 'error'
7+
8+
export interface Toast {
9+
id: string
10+
message: string
11+
variant: ToastVariant
12+
/** Auto-dismiss after this many ms. Default 5000. Pass 0 to disable. */
13+
durationMs: number
14+
/** Optional icon override -- defaults to the variant icon. */
15+
icon?: React.ReactNode
16+
}
17+
18+
export interface ToastOptions {
19+
variant?: ToastVariant
20+
durationMs?: number
21+
icon?: React.ReactNode
22+
}
23+
24+
export interface ToastContextValue {
25+
addToast: (message: string, options?: ToastOptions) => string
26+
removeToast: (id: string) => void
27+
}
28+
29+
// ── Context ───────────────────────────────────────────────────────────────────
30+
31+
export const ToastContext = createContext<ToastContextValue | null>(null)

src/components/ui/toastUtils.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useContext } from 'react'
2+
import { createElement } from 'react'
3+
import { AlarmClock } from 'lucide-react'
4+
import { Icon } from './Icon'
5+
import { ToastContext } from './toastContext'
6+
import type { ToastContextValue } from './toastContext'
7+
8+
export type { ToastContextValue }
9+
export type { Toast, ToastVariant, ToastOptions } from './toastContext'
10+
11+
// ── Hook ──────────────────────────────────────────────────────────────────────
12+
13+
export function useToast(): ToastContextValue {
14+
const ctx = useContext(ToastContext)
15+
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>')
16+
return ctx
17+
}
18+
19+
// ── Helpers ───────────────────────────────────────────────────────────────────
20+
21+
/**
22+
* Fires a timer-expired warning toast.
23+
* Kept here (not in Toast.tsx) so Toast.tsx remains a component-only file,
24+
* satisfying react-refresh/only-export-components.
25+
*/
26+
export function timerExpiredToast(addToast: ToastContextValue['addToast'], label: string): void {
27+
addToast(label && label !== 'Timer' ? `Time's up! — ${label}` : "Time's up!", {
28+
variant: 'warning',
29+
durationMs: 8000,
30+
icon: createElement(Icon, { icon: AlarmClock, size: 'sm' }),
31+
})
32+
}

0 commit comments

Comments
 (0)