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);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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(<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', () => {
|
||||
vi.useFakeTimers();
|
||||
let id: string;
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -100,6 +107,13 @@ function Toast({
|
||||
<p className={styles.detailMeta}>Astrolabe v{__APP_VERSION__}</p>
|
||||
</details>
|
||||
)}
|
||||
{action && (
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.action} onClick={handleAction}>
|
||||
{action.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user