Add durable toasts with an inline action button

This commit is contained in:
2026-06-07 20:37:55 +03:00
parent 50d3079b24
commit 5dc5ef8724
4 changed files with 95 additions and 5 deletions
+25
View File
@@ -142,6 +142,31 @@
color: var(--text-secondary); 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 { .close {
flex: none; flex: none;
display: inline-flex; display: inline-flex;
+39
View File
@@ -171,6 +171,45 @@ describe('Toaster exit animation (two-phase dismiss)', () => {
vi.useRealTimers(); 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(<Toaster />);
});
// 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', () => { test('clicking close twice does not reset the exit timer', () => {
vi.useFakeTimers(); vi.useFakeTimers();
let id: string; let id: string;
+19 -5
View File
@@ -70,18 +70,25 @@ function Toast({
exiting: boolean; exiting: boolean;
onStartExit: (id: string) => void; 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. // Trigger exit animation; after the animation duration the store removes it.
const handleDismiss = useCallback(() => onStartExit(id), [id, onStartExit]); const handleDismiss = useCallback(() => onStartExit(id), [id, onStartExit]);
// Positive/informational toasts clear themselves after a beat. Resets on // Positive/informational toasts clear themselves after a beat — unless marked
// re-render only when id/kind changes (stable identity guarantee). // durable (e.g. an update prompt the user must act on). Resets only when
// id/kind changes (stable identity guarantee).
useEffect(() => { useEffect(() => {
if (!AUTO_DISMISS[kind]) return; if (!AUTO_DISMISS[kind] || durable) return;
const timer = setTimeout(handleDismiss, AUTO_DISMISS_MS); const timer = setTimeout(handleDismiss, AUTO_DISMISS_MS);
return () => clearTimeout(timer); 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 ( return (
<div <div
@@ -100,6 +107,13 @@ function Toast({
<p className={styles.detailMeta}>Astrolabe v{__APP_VERSION__}</p> <p className={styles.detailMeta}>Astrolabe v{__APP_VERSION__}</p>
</details> </details>
)} )}
{action && (
<div className={styles.actions}>
<button type="button" className={styles.action} onClick={handleAction}>
{action.label}
</button>
</div>
)}
</div> </div>
<button <button
type="button" type="button"
+12
View File
@@ -37,6 +37,18 @@ export interface NotifyOptions {
* notifications — there is nothing for the user to report. * notifications — there is nothing for the user to report.
*/ */
detail?: string; detail?: string;
/**
* Optional inline call-to-action rendered as a button in the toast (Carbon
* notification action). The toast dismisses after the handler runs. Used for
* e.g. the service-worker "Reload to update" prompt (web.dev → council).
*/
action?: { label: string; onClick: () => void };
/**
* When true, the toast never auto-dismisses — it waits for the user (a Reload
* prompt or an offer the user must see). Mirrors how errors/warnings persist;
* lets a positive/info kind opt out of the auto-dismiss timer.
*/
durable?: boolean;
} }
export interface Notification extends NotifyOptions { export interface Notification extends NotifyOptions {