diff --git a/.changeset/persist-dismissed-notifications.md b/.changeset/persist-dismissed-notifications.md new file mode 100644 index 000000000..d70a5677c --- /dev/null +++ b/.changeset/persist-dismissed-notifications.md @@ -0,0 +1,5 @@ +--- +'@cube-dev/ui-kit': patch +--- + +Persist dismissed notification IDs in localStorage so they survive page reloads (24h TTL) diff --git a/.changeset/toast-actions-support.md b/.changeset/toast-actions-support.md new file mode 100644 index 000000000..96d9a8ea8 --- /dev/null +++ b/.changeset/toast-actions-support.md @@ -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. diff --git a/src/components/overlays/Notifications/OverlayContainer.tsx b/src/components/overlays/Notifications/OverlayContainer.tsx index 5e3e5bb13..5d4e8a547 100644 --- a/src/components/overlays/Notifications/OverlayContainer.tsx +++ b/src/components/overlays/Notifications/OverlayContainer.tsx @@ -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, @@ -541,6 +542,7 @@ export function OverlayContainer({ theme={item.data.theme} icon={item.data.icon} isLoading={item.data.isLoading} + actions={item.data.actions} /> ) : ( ; + +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 { + const map = readMap(); + const now = Date.now(); + const validIds = new Set(); + 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); + } + } + + if (changed) { + writeMap(map); + } + + return validIds; +} diff --git a/src/components/overlays/Notifications/use-notification-state.ts b/src/components/overlays/Notifications/use-notification-state.ts index 4b8a6ab55..53b928ea0 100644 --- a/src/components/overlays/Notifications/use-notification-state.ts +++ b/src/components/overlays/Notifications/use-notification-state.ts @@ -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 { @@ -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). diff --git a/src/components/overlays/Notifications/use-persistent-state.ts b/src/components/overlays/Notifications/use-persistent-state.ts index 3756c2002..2d465e794 100644 --- a/src/components/overlays/Notifications/use-persistent-state.ts +++ b/src/components/overlays/Notifications/use-persistent-state.ts @@ -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 ─────────────────────────────────────────────────────────── @@ -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 ──────────────────────────────────────────────────────────── @@ -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>(new Set()); + // Lazy-initialized from localStorage so dismissed IDs survive page reloads. + const dismissedPersistentIdsRef = useRef | 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>(new Set()); + const fullyDismissedIdsRef = useRef>(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 @@ -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)); }); @@ -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 { @@ -117,5 +135,6 @@ export function usePersistentState(maxItems: number): PersistentState { markAllAsRead, hasDismissedPersistentId, isFullyDismissedId, + saveDismissedPersistentId, }; } diff --git a/src/components/overlays/Toast/Toast.stories.tsx b/src/components/overlays/Toast/Toast.stories.tsx index c58e7d572..cbfa6cba7 100644 --- a/src/components/overlays/Toast/Toast.stories.tsx +++ b/src/components/overlays/Toast/Toast.stories.tsx @@ -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'; @@ -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: ( + setIsLoading(false)}> + Cancel + + ), + } + : null, + ); + + return ( + + ); +}; + +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.', + }, + }, +}; diff --git a/src/components/overlays/Toast/Toast.test.tsx b/src/components/overlays/Toast/Toast.test.tsx index b2308745d..e39144946 100644 --- a/src/components/overlays/Toast/Toast.test.tsx +++ b/src/components/overlays/Toast/Toast.test.tsx @@ -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'; @@ -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( + Cancel} + />, + ); + + expect(getByText('Deploying...')).toBeInTheDocument(); + expect(getByText('Cancel')).toBeInTheDocument(); + }); + + it('should call onPress when action is clicked', async () => { + const onPress = vi.fn(); + + const { getByRole } = renderWithRoot( + Cancel} + />, + ); + + await userEvent.click(getByRole('button', { name: 'Cancel' })); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + }); + describe('Deduplication', () => { function DedupeTestComponent() { const toast = useToast(); diff --git a/src/components/overlays/Toast/ToastItem.tsx b/src/components/overlays/Toast/ToastItem.tsx index 443adc071..7fa466b51 100644 --- a/src/components/overlays/Toast/ToastItem.tsx +++ b/src/components/overlays/Toast/ToastItem.tsx @@ -25,7 +25,10 @@ const StyledItem = tasty(Item, { styles: { shadow: '$shadow', transition: 'theme, inset', - pointerEvents: 'none', + pointerEvents: { + '': 'none', + 'has-actions': 'auto', + }, Description: { preset: 't4', @@ -60,6 +63,7 @@ export const ToastItem = forwardRef( theme={theme} icon={icon} isLoading={isLoading} + isDisabled={false} description={secondaryContent} {...itemProps} > diff --git a/src/components/overlays/Toast/types.ts b/src/components/overlays/Toast/types.ts index 899c56752..f0b4b1e91 100644 --- a/src/components/overlays/Toast/types.ts +++ b/src/components/overlays/Toast/types.ts @@ -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; }