Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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/dismissible-progress-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": minor
---

Add `isDismissible` option to progress toasts. When enabled, a "Hide" action button appears during loading, allowing users to temporarily dismiss the toast. The toast will not re-appear during the same loading cycle after being dismissed.
5 changes: 5 additions & 0 deletions .changeset/restore-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": minor
---

Add notification restore functionality. When an async action returns `false`, dismissed notifications can now be restored automatically.
5 changes: 5 additions & 0 deletions .changeset/update-progress-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": patch
---

Improve progress toast updates. Progress toasts now update in-place instead of removing and re-adding, preventing unnecessary exit/enter animations when data changes.
54 changes: 0 additions & 54 deletions src/components/GlobalStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,60 +74,6 @@ const STATIC_CSS = `
}
}

.cube-notification-container {
min-width: var(--min-dialog-size);
max-width: 340px;
width: calc(100vw - 32px);
position: fixed;
top: 32px;
right: 16px;
z-index: 999999;
}

.cube-notifications {
display: grid;
grid-auto-flow: row;
grid-template-columns: 1fr;
}

.cube-notification-enter {
opacity: 0;
max-height: 0px;
margin-bottom: 0px;
transform: translate(100%, 0);
}

.cube-notification-enter-active {
opacity: 1;
max-height: 56px;
margin-bottom: 8px;
transform: translate(0, 0);
transition: all 300ms ease-in;
}

.cube-notification-enter-active > * {
margin-bottom: 0px;
}

.cube-notification-exit {
opacity: 1;
margin-bottom: 8px;
max-height: 56px;
transform: translate(0, 0);
}

.cube-notification-exit-active {
opacity: 0;
max-height: 0px;
margin-bottom: 0px;
transform: translate(100%, 0);
transition: all 300ms ease-in;
}

.cube-notification-exit-active > * {
margin-bottom: 0px;
}

b, strong {
font-weight: var(--bold-font-weight, 700);
}
Expand Down
31 changes: 20 additions & 11 deletions src/components/overlays/Notifications/NotificationAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export { NotificationActionInterceptorContext };

interface NotificationDismissContextValue {
dismiss: (reason: 'action' | 'close') => void;
restore: () => void;
}

const NotificationDismissContext =
Expand All @@ -37,19 +38,25 @@ const NotificationDismissContext =
export interface NotificationDismissProviderProps {
notificationId: Key;
onDismiss: (id: Key, reason: 'action' | 'close') => void;
onRestore?: (id: Key) => void;
children: ReactNode;
}

export function NotificationDismissProvider({
notificationId,
onDismiss,
onRestore,
children,
}: NotificationDismissProviderProps) {
const dismiss = useEvent((reason: 'action' | 'close') => {
onDismiss(notificationId, reason);
});

const value = useMemo(() => ({ dismiss }), [dismiss]);
const restore = useEvent(() => {
onRestore?.(notificationId);
});

const value = useMemo(() => ({ dismiss, restore }), [dismiss, restore]);

return (
<NotificationDismissContext.Provider value={value}>
Expand Down Expand Up @@ -108,23 +115,25 @@ export function NotificationAction({
const actionInterceptor = useContext(NotificationActionInterceptorContext);

const handlePress = useEvent(async () => {
const result = await onPress?.();

if (result === false) {
return;
}

actionInterceptor?.();

if (closeOnPress || actionInterceptor) {
// isDismiss actions (dismiss button, Escape) use 'close' reason — the
// notification moves to the persistent list.
// Regular actions use 'action' reason — the notification is fully dismissed
// and won't reappear.
// Dismiss immediately so the notification hides before the async action
// completes (e.g. opening a confirmation dialog).
// isDismiss actions use 'close' reason — the notification moves to the
// persistent list. Regular actions use 'action' reason — the notification
// is fully dismissed and won't reappear.
// When an actionInterceptor is present (persistent list), always dismiss
// regardless of closeOnPress — all actions remove the item permanently.
dismissCtx?.dismiss(isDismiss ? 'close' : 'action');
}

const result = await onPress?.();

if (result === false && (closeOnPress || actionInterceptor)) {
// The async action signalled cancellation — restore the notification.
dismissCtx?.restore();
}
Comment thread
cursor[bot] marked this conversation as resolved.
});

return (
Expand Down
7 changes: 7 additions & 0 deletions src/components/overlays/Notifications/NotificationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ interface ActionsSectionProps {
hasDismissContext: boolean;
notificationId?: Key;
onDismiss?: (id: Key, reason: DismissReason) => void;
onRestore?: (id: Key) => void;
}

/**
Expand All @@ -107,6 +108,7 @@ function ActionsSection({
hasDismissContext,
notificationId,
onDismiss,
onRestore,
}: ActionsSectionProps) {
const actionsContent = (
<Space placeSelf="end" placeContent="end" flexGrow={1}>
Expand All @@ -127,6 +129,7 @@ function ActionsSection({
<NotificationDismissProvider
notificationId={notificationId!}
onDismiss={onDismiss!}
onRestore={onRestore}
>
{wrappedContent}
</NotificationDismissProvider>
Expand Down Expand Up @@ -166,6 +169,8 @@ export interface NotificationCardProps {
notificationId?: Key;
/** Called when the notification is dismissed */
onDismiss?: (id: Key, reason: DismissReason) => void;
/** Called when a dismissed notification should be restored (async action returned false) */
onRestore?: (id: Key) => void;
/** Suffix content (e.g. timestamp) */
suffix?: ReactNode;
}
Expand All @@ -185,6 +190,7 @@ export function NotificationCard({
elevated = true,
notificationId,
onDismiss,
onRestore,
suffix,
}: NotificationCardProps) {
const icon = getThemeIcon(theme, providedIcon);
Expand All @@ -205,6 +211,7 @@ export function NotificationCard({
hasDismissContext={hasDismissContext}
notificationId={notificationId}
onDismiss={onDismiss}
onRestore={onRestore}
/>
)}
</Flex>
Expand Down
3 changes: 3 additions & 0 deletions src/components/overlays/Notifications/NotificationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ const NotificationItemWrapper = tasty({
export interface NotificationItemProps {
notification: InternalNotification;
onDismiss: (id: Key, reason: DismissReason) => void;
onRestore?: (id: Key) => void;
}

export function NotificationItem({
notification,
onDismiss,
onRestore,
}: NotificationItemProps) {
const {
theme,
Expand Down Expand Up @@ -104,6 +106,7 @@ export function NotificationItem({
isDismissible={isDismissible}
notificationId={notificationId}
onDismiss={onDismiss}
onRestore={onRestore}
/>
</NotificationItemWrapper>
);
Expand Down
5 changes: 4 additions & 1 deletion src/components/overlays/Notifications/OverlayContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const OverlayContainerElement = tasty({
top: '2x',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 10000,
zIndex: 100,
Comment thread
tenphi marked this conversation as resolved.
padding: '1x',
height: '0',
pointerEvents: 'none',
Expand Down Expand Up @@ -388,6 +388,7 @@ export interface OverlayContainerProps {
onToastExitComplete: (internalId: string) => void;
onNotificationExitComplete: (internalId: string) => void;
onNotificationDismiss: (id: Key, reason: DismissReason) => void;
onNotificationRestore: (id: Key) => void;
onPauseChange: (paused: boolean) => void;
}

Expand All @@ -397,6 +398,7 @@ export function OverlayContainer({
onToastExitComplete,
onNotificationExitComplete,
onNotificationDismiss,
onNotificationRestore,
onPauseChange,
}: OverlayContainerProps) {
// Merge toasts and notifications into a single ordered list
Expand Down Expand Up @@ -548,6 +550,7 @@ export function OverlayContainer({
<NotificationItem
notification={item.data}
onDismiss={handleNotificationDismiss}
onRestore={onNotificationRestore}
/>
)}
</OverlayItemWrapper>
Expand Down
1 change: 1 addition & 0 deletions src/components/overlays/Notifications/OverlayProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function OverlayProvider({
notification.finalizeNotificationRemoval
}
onNotificationDismiss={notification.removeNotification}
onNotificationRestore={notification.restoreNotification}
onPauseChange={timers.handlePauseChange}
/>
</PersistentNotificationsContext.Provider>
Expand Down
2 changes: 2 additions & 0 deletions src/components/overlays/Notifications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export interface InternalNotification extends OverlayNotificationOptions {
createdAt: number;
updatedAt: number;
isExiting?: boolean;
/** Reason for the most recent dismiss, used to undo persistent side effects on restore. */
lastDismissReason?: DismissReason;
ownerId?: string;
}

Expand Down
49 changes: 48 additions & 1 deletion src/components/overlays/Notifications/use-notification-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ const MAX_NOTIFICATIONS = 5;
export interface PersistentCallbacks {
addPersistentItem: (item: PersistentNotificationItem) => void;
removePersistentItem: (id: Key) => void;
removePersistentItemSilently: (id: Key) => void;
hasDismissedPersistentId: (id: Key) => boolean;
isFullyDismissedId: (id: Key) => boolean;
saveDismissedPersistentId: (id: Key) => void;
undoFullyDismissedId: (id: Key) => void;
}

export interface NotificationState {
Expand All @@ -34,6 +36,7 @@ export interface NotificationState {
ownerId?: string,
) => Key;
removeNotification: (id: Key, reason?: DismissReason) => void;
restoreNotification: (id: Key) => void;
updateNotification: (
id: Key,
options: Partial<OverlayNotificationOptions>,
Expand Down Expand Up @@ -161,12 +164,55 @@ export function useNotificationState(

setNotifications((prev) =>
prev.map((n) =>
matchesNotificationId(n, id) ? { ...n, isExiting: true } : n,
matchesNotificationId(n, id)
? { ...n, isExiting: true, lastDismissReason: reason }
: n,
),
);
},
);

// ─── Restore ──────────────────────────────────────────────────

const restoreNotification = useEvent((id: Key) => {
const notif = findNotification(notificationsRef.current, id);

if (!notif || !notif.isExiting) return;

// Undo persistent side effects based on how the notification was dismissed.
if (notif.persistent && notif.lastDismissReason) {
const persistentId = notif.id ?? notif.internalId;

if (
notif.lastDismissReason === 'close' ||
notif.lastDismissReason === 'timeout'
) {
persistent.removePersistentItemSilently(persistentId);
} else if (notif.lastDismissReason === 'action') {
persistent.undoFullyDismissedId(persistentId);
}
Comment thread
tenphi marked this conversation as resolved.
}

// Restart the auto-dismiss timer.
const duration = getDuration(notif);

if (duration != null && duration > 0) {
timersRef.current?.startNotificationTimer(
notif.internalId,
notif.id ?? notif.internalId,
duration,
);
}

setNotifications((prev) =>
prev.map((n) =>
matchesNotificationId(n, id)
? { ...n, isExiting: false, lastDismissReason: undefined }
: n,
),
);
});

// ─── Finalize ──────────────────────────────────────────────────

const finalizeNotificationRemoval = useEvent((internalId: string) => {
Expand Down Expand Up @@ -353,6 +399,7 @@ export function useNotificationState(
notificationsRef,
addNotification,
removeNotification,
restoreNotification,
updateNotification,
finalizeNotificationRemoval,
removeByOwner,
Expand Down
21 changes: 21 additions & 0 deletions src/components/overlays/Notifications/use-persistent-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ export interface PersistentState {
* instead of `addPersistentItem`.
*/
saveDismissedPersistentId: (id: Key) => void;
/**
* Removes an id from the fully-dismissed set.
* Used to undo an `'action'` dismissal when the async action is cancelled.
*/
undoFullyDismissedId: (id: Key) => void;
/**
* Removes an item from the persistent list without marking it as fully dismissed.
* Used to undo a `'close'` dismissal that moved the notification to persistent.
*/
removePersistentItemSilently: (id: Key) => void;
}

// ─── Hook ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -126,6 +136,15 @@ export function usePersistentState(maxItems: number): PersistentState {
saveDismissedId(id);
});

const undoFullyDismissedId = useEvent((id: Key): void => {
fullyDismissedIdsRef.current.delete(String(id));
});
Comment thread
cursor[bot] marked this conversation as resolved.

const removePersistentItemSilently = useEvent((id: Key): void => {
dismissedPersistentIdsRef.current!.delete(String(id));
setPersistentItems((prev) => prev.filter((i) => i.id !== id));
});

return {
persistentItems,
addPersistentItem,
Expand All @@ -136,5 +155,7 @@ export function usePersistentState(maxItems: number): PersistentState {
hasDismissedPersistentId,
isFullyDismissedId,
saveDismissedPersistentId,
undoFullyDismissedId,
removePersistentItemSilently,
};
}
Loading
Loading