Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/persist-dismissed-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': patch
---

Persist dismissed notification IDs in localStorage so they survive page reloads (24h TTL)
5 changes: 5 additions & 0 deletions .changeset/toast-actions-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': patch
---

Add `actions` prop to Toast component to support interactive action buttons (e.g., Cancel button in progress toasts). Toasts with actions remain interactive and prevent overlay collapse.
4 changes: 3 additions & 1 deletion src/components/overlays/Notifications/OverlayContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,8 @@ export function OverlayContainer({
[allItems],
);
const hasNotifications = notifications.some((n) => !n.isExiting);
const canCollapse = !hasNotifications;
const hasActionableToasts = toasts.some((t) => !t.isExiting && t.actions);
const canCollapse = !hasNotifications && !hasActionableToasts;

const {
heights,
Expand Down Expand Up @@ -541,6 +542,7 @@ export function OverlayContainer({
theme={item.data.theme}
icon={item.data.icon}
isLoading={item.data.isLoading}
actions={item.data.actions}
/>
) : (
<NotificationItem
Expand Down
59 changes: 59 additions & 0 deletions src/components/overlays/Notifications/dismissed-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Key } from 'react';

const STORAGE_KEY = 'cube-ui-dismissed-notifications';
const TTL_MS = 86_400_000; // 24 hours

type DismissedMap = Record<string, number>;

function readMap(): DismissedMap {
try {
const raw = localStorage.getItem(STORAGE_KEY);

if (!raw) return {};

return JSON.parse(raw) as DismissedMap;
} catch {
return {};
}
}

function writeMap(map: DismissedMap): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch {
// SSR, private browsing, or quota exceeded — silently ignore
}
}

export function saveDismissedId(id: Key): void {
const map = readMap();

map[String(id)] = Date.now();
writeMap(map);
}

/**
* Reads the dismissed-IDs map from localStorage, removes entries older than
* 24 hours, writes the cleaned map back, and returns the remaining valid IDs.
*/
export function cleanupAndGetValidIds(): Set<string> {
const map = readMap();
const now = Date.now();
const validIds = new Set<string>();
let changed = false;

for (const [id, timestamp] of Object.entries(map)) {
if (now - timestamp > TTL_MS) {
delete map[id];
changed = true;
} else {
validIds.add(id);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (changed) {
writeMap(map);
}

return validIds;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface PersistentCallbacks {
removePersistentItem: (id: Key) => void;
hasDismissedPersistentId: (id: Key) => boolean;
isFullyDismissedId: (id: Key) => boolean;
saveDismissedPersistentId: (id: Key) => void;
}

export interface NotificationState {
Expand Down Expand Up @@ -151,6 +152,7 @@ export function useNotificationState(
} else if (reason === 'action') {
// User clicked a regular action (not dismiss) — fully dismiss the
// notification so it never reappears (overlay or persistent list).
persistent.saveDismissedPersistentId(notif.id ?? notif.internalId);
persistent.removePersistentItem(notif.id ?? notif.internalId);
}
// reason === 'api' — programmatic cleanup (e.g. component unmount).
Expand Down
33 changes: 26 additions & 7 deletions src/components/overlays/Notifications/use-persistent-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Key, useRef, useState } from 'react';

import { useEvent } from '../../../_internal';

import { cleanupAndGetValidIds, saveDismissedId } from './dismissed-storage';

import type { PersistentNotificationItem } from './types';

// ─── Types ───────────────────────────────────────────────────────────
Expand All @@ -25,6 +27,12 @@ export interface PersistentState {
* subsequent triggers (no overlay, no persistent storage).
*/
isFullyDismissedId: (id: Key) => boolean;
/**
* Marks an id as dismissed in both the in-memory set and localStorage.
* Used for `'action'` dismissals that go through `removePersistentItem`
* instead of `addPersistentItem`.
*/
saveDismissedPersistentId: (id: Key) => void;
}

// ─── Hook ────────────────────────────────────────────────────────────
Expand All @@ -36,17 +44,22 @@ export function usePersistentState(maxItems: number): PersistentState {

// Tracks IDs that have been moved to the persistent list at least once.
// Used to skip the overlay when the same id reappears.
const dismissedPersistentIdsRef = useRef<Set<Key>>(new Set());
// Lazy-initialized from localStorage so dismissed IDs survive page reloads.
const dismissedPersistentIdsRef = useRef<Set<string> | null>(null);
if (dismissedPersistentIdsRef.current === null) {
dismissedPersistentIdsRef.current = cleanupAndGetValidIds();
}

// Tracks IDs that were explicitly removed from the persistent list by the
// user. These should be completely ignored on subsequent triggers.
const fullyDismissedIdsRef = useRef<Set<Key>>(new Set());
const fullyDismissedIdsRef = useRef<Set<string>>(new Set());

const addPersistentItem = useEvent((item: PersistentNotificationItem) => {
// If the user already dismissed this item from the persistent list, don't re-add it.
if (fullyDismissedIdsRef.current.has(item.id)) return;
if (fullyDismissedIdsRef.current.has(String(item.id))) return;

dismissedPersistentIdsRef.current.add(item.id);
dismissedPersistentIdsRef.current!.add(String(item.id));
saveDismissedId(item.id);

setPersistentItems((prev) => {
// Upsert by id
Expand Down Expand Up @@ -78,7 +91,7 @@ export function usePersistentState(maxItems: number): PersistentState {
});

const removePersistentItem = useEvent((id: Key) => {
fullyDismissedIdsRef.current.add(id);
fullyDismissedIdsRef.current.add(String(id));
setPersistentItems((prev) => prev.filter((i) => i.id !== id));
});

Expand All @@ -101,11 +114,16 @@ export function usePersistentState(maxItems: number): PersistentState {
});

const hasDismissedPersistentId = useEvent((id: Key): boolean => {
return dismissedPersistentIdsRef.current.has(id);
return dismissedPersistentIdsRef.current!.has(String(id));
});

const isFullyDismissedId = useEvent((id: Key): boolean => {
return fullyDismissedIdsRef.current.has(id);
return fullyDismissedIdsRef.current.has(String(id));
});

const saveDismissedPersistentId = useEvent((id: Key): void => {
dismissedPersistentIdsRef.current!.add(String(id));
saveDismissedId(id);
});

return {
Expand All @@ -117,5 +135,6 @@ export function usePersistentState(maxItems: number): PersistentState {
markAllAsRead,
hasDismissedPersistentId,
isFullyDismissedId,
saveDismissedPersistentId,
};
}
38 changes: 38 additions & 0 deletions src/components/overlays/Toast/Toast.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ReactNode, useRef, useState } from 'react';

import { CheckIcon } from '../../../icons';
import { Button } from '../../actions/Button/Button';
import { Item } from '../../content/Item/Item';
import { Flex } from '../../layout/Flex';
import { Space } from '../../layout/Space';
import { OverlayProvider } from '../Notifications/OverlayProvider';
Expand Down Expand Up @@ -335,3 +336,40 @@ MultipleToasts.parameters = {
},
},
};

/**
* Progress toast with a cancel action
*/
export const ProgressToastWithAction = () => {
const [isLoading, setIsLoading] = useState(false);

useProgressToast(
isLoading
? {
isLoading: true,
title: 'Deploying...',
theme: 'note',
actions: (
<Item.Action type="secondary" onPress={() => setIsLoading(false)}>
Cancel
</Item.Action>
),
}
: null,
);

return (
<Button isDisabled={isLoading} onPress={() => setIsLoading(true)}>
Deploy
</Button>
);
};

ProgressToastWithAction.parameters = {
docs: {
description: {
story:
'Progress toast with an action button. The user controls dismissal — clicking Cancel sets loading to false, which removes the toast.',
},
},
};
33 changes: 33 additions & 0 deletions src/components/overlays/Toast/Toast.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { act, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { renderWithRoot } from '../../../test/render';
import { Button } from '../../actions/Button/Button';
import { Item } from '../../content/Item/Item';

import { ToastItem } from './ToastItem';

Expand Down Expand Up @@ -215,6 +217,37 @@ describe('Toast', () => {
});
});

describe('Toast with actions', () => {
it('should render action button inside toast', async () => {
const onPress = vi.fn();

const { getByText } = renderWithRoot(
<ToastItem
title="Deploying..."
actions={<Item.Action onPress={onPress}>Cancel</Item.Action>}
/>,
);

expect(getByText('Deploying...')).toBeInTheDocument();
expect(getByText('Cancel')).toBeInTheDocument();
});

it('should call onPress when action is clicked', async () => {
const onPress = vi.fn();

const { getByRole } = renderWithRoot(
<ToastItem
title="Processing..."
actions={<Item.Action onPress={onPress}>Cancel</Item.Action>}
/>,
);

await userEvent.click(getByRole('button', { name: 'Cancel' }));

expect(onPress).toHaveBeenCalledTimes(1);
});
});

describe('Deduplication', () => {
function DedupeTestComponent() {
const toast = useToast();
Expand Down
6 changes: 5 additions & 1 deletion src/components/overlays/Toast/ToastItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ const StyledItem = tasty(Item, {
styles: {
shadow: '$shadow',
transition: 'theme, inset',
pointerEvents: 'none',
pointerEvents: {
'': 'none',
'has-actions': 'auto',
},

Description: {
preset: 't4',
Expand Down Expand Up @@ -60,6 +63,7 @@ export const ToastItem = forwardRef<HTMLElement, ToastItemProps>(
theme={theme}
icon={icon}
isLoading={isLoading}
isDisabled={false}
Comment thread
tenphi marked this conversation as resolved.
description={secondaryContent}
{...itemProps}
>
Expand Down
2 changes: 2 additions & 0 deletions src/components/overlays/Toast/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface ToastData {
isLoading?: boolean;
/** Duration in ms before auto-dismiss. null = persistent */
duration?: number | null;
/** Action buttons rendered inside the toast (e.g. Cancel) */
actions?: ReactNode;
/** Additional Item props to pass through */
itemProps?: Partial<CubeItemProps>;
}
Expand Down
Loading