From 5dc5ef8724b86e5eff532ab8a0e20ca1a43916cc Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 7 Jun 2026 20:37:55 +0300 Subject: [PATCH] Add durable toasts with an inline action button --- src/app/components/Toaster.module.css | 25 +++++++++++++++++ src/app/components/Toaster.test.tsx | 39 +++++++++++++++++++++++++++ src/app/components/Toaster.tsx | 24 +++++++++++++---- src/app/stores/NotificationStore.ts | 12 +++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/app/components/Toaster.module.css b/src/app/components/Toaster.module.css index b7879e5..3553745 100644 --- a/src/app/components/Toaster.module.css +++ b/src/app/components/Toaster.module.css @@ -142,6 +142,31 @@ color: var(--text-secondary); } +.actions { + margin-top: var(--space-3); +} + +.action { + padding: var(--space-1) var(--space-3); + font: inherit; + font-size: 13px; + color: var(--toast-accent); + background: transparent; + border: var(--border-width) solid var(--toast-accent); + border-radius: var(--radius); + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.action:hover { + background: var(--layer-01); +} + +.action:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + .close { flex: none; display: inline-flex; diff --git a/src/app/components/Toaster.test.tsx b/src/app/components/Toaster.test.tsx index 466a470..8e2d635 100644 --- a/src/app/components/Toaster.test.tsx +++ b/src/app/components/Toaster.test.tsx @@ -171,6 +171,45 @@ describe('Toaster exit animation (two-phase dismiss)', () => { vi.useRealTimers(); }); + test('a durable toast does not auto-dismiss, and its action runs then dismisses it', () => { + vi.useFakeTimers(); + const onClick = vi.fn(); + let id: string; + act(() => { + id = useNotificationStore.getState().notify({ + kind: 'info', + title: 'Update available', + message: 'A new version is ready.', + durable: true, + action: { label: 'Reload', onClick }, + }); + root.render(); + }); + + // Well past the auto-dismiss window — still present (durable). + act(() => { + vi.advanceTimersByTime(60000); + }); + expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true); + + // The action button runs the handler, then the toast exits. + const actionBtn = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'Reload', + ); + expect(actionBtn).toBeTruthy(); + act(() => { + actionBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onClick).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(TOAST_EXIT_MS); + }); + expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false); + + vi.useRealTimers(); + }); + test('clicking close twice does not reset the exit timer', () => { vi.useFakeTimers(); let id: string; diff --git a/src/app/components/Toaster.tsx b/src/app/components/Toaster.tsx index fc77cfc..d6df57c 100644 --- a/src/app/components/Toaster.tsx +++ b/src/app/components/Toaster.tsx @@ -70,18 +70,25 @@ function Toast({ exiting: boolean; onStartExit: (id: string) => void; }) { - const { id, kind, title, message, detail } = notification; + const { id, kind, title, message, detail, action, durable } = notification; // Trigger exit animation; after the animation duration the store removes it. const handleDismiss = useCallback(() => onStartExit(id), [id, onStartExit]); - // Positive/informational toasts clear themselves after a beat. Resets on - // re-render only when id/kind changes (stable identity guarantee). + // Positive/informational toasts clear themselves after a beat — unless marked + // durable (e.g. an update prompt the user must act on). Resets only when + // id/kind changes (stable identity guarantee). useEffect(() => { - if (!AUTO_DISMISS[kind]) return; + if (!AUTO_DISMISS[kind] || durable) return; const timer = setTimeout(handleDismiss, AUTO_DISMISS_MS); return () => clearTimeout(timer); - }, [id, kind, handleDismiss]); + }, [id, kind, durable, handleDismiss]); + + // Run the action, then dismiss the toast (the handler may navigate/reload). + const handleAction = useCallback(() => { + action?.onClick(); + handleDismiss(); + }, [action, handleDismiss]); return (
Astrolabe v{__APP_VERSION__}

)} + {action && ( +
+ +
+ )}