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/dismissible-progress-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": minor
---

Add `isDismissable` 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/rename-is-dismissible-to-is-dismissable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": minor
---

Rename `isDismissible` prop to `isDismissable` in Banner and Notification components for consistency with other components (Dialog, LayoutPanel, etc.). This is a breaking change - update your code to use `isDismissable` instead of `isDismissible`.
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
4 changes: 2 additions & 2 deletions src/components/actions/Banner/Banner.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Inline link styled with white color and underline. Use for links within the bann
### Dismissible Banner

```jsx
<Banner theme="success" isDismissible onDismiss={() => console.log('Dismissed')}>
<Banner theme="success" isDismissable onDismiss={() => console.log('Dismissed')}>
Your deployment has been successfully updated.
</Banner>
```
Expand Down Expand Up @@ -148,7 +148,7 @@ Use `shape="sharp"` when stacking multiple banners to remove gaps between them.
```jsx
import { IconBell } from '@tabler/icons-react';

<Banner theme="note" icon={<IconBell />} isDismissible>
<Banner theme="note" icon={<IconBell />} isDismissable>
You have new notifications.
</Banner>
```
Expand Down
8 changes: 4 additions & 4 deletions src/components/actions/Banner/Banner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const Themes: StoryFn<BannerProps> = () => {
<Banner theme="note" actions={<Banner.Action>Learn More</Banner.Action>}>
Tip: Enable auto-scaling to handle traffic spikes automatically.
</Banner>
<Banner isDismissible theme="success">
<Banner isDismissable theme="success">
Deployment v2.4.1 is now live with improved query performance.
</Banner>
</Space>
Expand Down Expand Up @@ -104,10 +104,10 @@ export const Stacked: StoryFn<BannerProps> = () => {
>
Warning: You have exceeded 80% of your query limit.
</Banner>
<Banner isDismissible theme="note" shape="sharp">
<Banner isDismissable theme="note" shape="sharp">
New deployment features are available.
</Banner>
<Banner isDismissible theme="success" shape="sharp">
<Banner isDismissable theme="success" shape="sharp">
All systems operational.
</Banner>
</Flex>
Expand All @@ -119,7 +119,7 @@ CustomIcon.args = {
children: 'You have new notifications.',
theme: 'note',
icon: <IconBell />,
isDismissible: true,
isDismissable: true,
};

/**
Expand Down
8 changes: 4 additions & 4 deletions src/components/actions/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type BannerProps = Omit<CubeItemProps, 'type' | 'size' | 'theme'> & {
* Controls whether the banner can be dismissed by the user.
* @default false
*/
isDismissible?: boolean;
isDismissable?: boolean;
/**
* Callback fired when the dismiss button is clicked.
*/
Expand Down Expand Up @@ -100,7 +100,7 @@ export function Banner(props: BannerProps) {
const {
theme = 'note',
actions,
isDismissible = false,
isDismissable = false,
onDismiss,
children,
icon,
Expand All @@ -113,7 +113,7 @@ export function Banner(props: BannerProps) {

const defaultIcon = useMemo(() => DEFAULT_ICONS[theme], [theme]);

const hasActions = !!(actions || isDismissible);
const hasActions = !!(actions || isDismissable);

return (
<BannerElement
Expand All @@ -128,7 +128,7 @@ export function Banner(props: BannerProps) {
hasActions ? (
<>
{actions}
{isDismissible && (
{isDismissable && (
<Item.Action
icon={<IconX />}
tooltip="Hide banner"
Expand Down
2 changes: 1 addition & 1 deletion src/components/overlays/Notifications/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function Notification(props: NotificationProps): null {
props.description,
props.icon,
props.actions,
props.isDismissible,
props.isDismissable,
props.persistent,
props.duration,
]);
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
17 changes: 12 additions & 5 deletions src/components/overlays/Notifications/NotificationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ interface ActionsSectionProps {
theme?: NotificationType;
/**
* Whether to show the auto-appended "Dismiss" button.
* Controlled by `isDismissible` on the notification.
* Controlled by `isDismissable` on the notification.
*
* When false, no default "Dismiss" button is rendered, but actions with
* `closeOnPress` (default `true`) can still close the notification via
Expand All @@ -90,14 +90,15 @@ interface ActionsSectionProps {
hasDismissContext: boolean;
notificationId?: Key;
onDismiss?: (id: Key, reason: DismissReason) => void;
onRestore?: (id: Key) => void;
}

/**
* Extracted sub-component for the actions area of a notification card.
*
* The dismiss provider is always rendered when `hasDismissContext` is true,
* so any action with `closeOnPress` can close the notification — regardless
* of `isDismissible`. The `showAutoDismiss` flag only controls the
* of `isDismissable`. The `showAutoDismiss` flag only controls the
* auto-appended "Dismiss" button.
*/
function ActionsSection({
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 @@ -159,13 +162,15 @@ export interface NotificationCardProps {
* nothing, but actions with `closeOnPress` (default) can still close
* the notification.
*/
isDismissible?: boolean;
isDismissable?: boolean;
/** When false the card drops its shadow (e.g. inside a list). Default: true. */
elevated?: boolean;
/** Notification id */
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 @@ -181,16 +186,17 @@ export function NotificationCard({
description,
icon: providedIcon,
actions,
isDismissible = true,
isDismissable = true,
elevated = true,
notificationId,
onDismiss,
onRestore,
suffix,
}: NotificationCardProps) {
const icon = getThemeIcon(theme, providedIcon);

const hasDismissContext = notificationId != null && onDismiss != null;
const showAutoDismiss = isDismissible && hasDismissContext;
const showAutoDismiss = isDismissable && hasDismissContext;
const hasActions = !!(actions || showAutoDismiss);

const descriptionContent: ReactNode =
Expand All @@ -205,6 +211,7 @@ export function NotificationCard({
hasDismissContext={hasDismissContext}
notificationId={notificationId}
onDismiss={onDismiss}
onRestore={onRestore}
/>
)}
</Flex>
Expand Down
9 changes: 6 additions & 3 deletions src/components/overlays/Notifications/NotificationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,27 +62,29 @@ 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,
title,
description,
icon,
actions,
isDismissible = true,
isDismissable = true,
id,
internalId,
} = notification;

const notificationId = id ?? internalId;

const handleKeyDown = useEvent((e: KeyboardEvent) => {
if (e.key === 'Escape' && isDismissible) {
if (e.key === 'Escape' && isDismissable) {
e.stopPropagation();
onDismiss(notificationId, 'close');
}
Expand All @@ -101,9 +103,10 @@ export function NotificationItem({
description={description}
icon={icon}
actions={actions}
isDismissible={isDismissible}
isDismissable={isDismissable}
notificationId={notificationId}
onDismiss={onDismiss}
onRestore={onRestore}
/>
</NotificationItemWrapper>
);
Expand Down
Loading
Loading