mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add durable toasts with an inline action button
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user