Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
33cd9f7
feat(ui): notification manager first version
vlad-schur-external-sap May 13, 2026
a7ff95c
Merge branch 'main' into vlad-notification-manager-second-iteration
vlad-schur-external-sap Jun 5, 2026
a6e6203
fix(ui): coderabbit and copilot code-review comments
vlad-schur-external-sap Jun 5, 2026
d79fda6
feat(ui): add story prop interface to customise them
vlad-schur-external-sap Jun 5, 2026
8472e6c
feat(ui): add infinity story
vlad-schur-external-sap Jun 5, 2026
71b2883
fix(ui): apply default juno-font to notification-manager
vlad-schur-external-sap Jun 5, 2026
b3f4910
feat(ui): move logic from toast to notification-manager
vlad-schur-external-sap Jun 5, 2026
d736498
feat(ui): add stories for on-dismiss and on-close callbacks
vlad-schur-external-sap Jun 5, 2026
b16b25e
refactor(ui): common types and naming for notification-manager and to…
vlad-schur-external-sap Jun 5, 2026
6d27ce5
Merge branch 'main' into vlad-notification-manager-second-iteration
vlad-schur-external-sap Jun 5, 2026
a841de6
fix(ui): tests for toast and not-manager
vlad-schur-external-sap Jun 5, 2026
eb97a02
feat(ui): make notification-manager dismissable
vlad-schur-external-sap Jun 5, 2026
819fb13
Merge branch 'main' into vlad-notification-manager-second-iteration
vlad-schur-external-sap Jun 5, 2026
1351366
fix(ui): copilot code review comments
vlad-schur-external-sap Jun 5, 2026
0247531
fix(ui): ai pr comments
vlad-schur-external-sap Jun 8, 2026
001ab45
fix(ui): copilot types and test comments
vlad-schur-external-sap Jun 8, 2026
36fc34c
fix(ui): notification-manager registry concurrent rendering
vlad-schur-external-sap Jun 8, 2026
95f48be
fix(ui): notification-manager type export
vlad-schur-external-sap Jun 9, 2026
c745d5d
Merge branch 'main' into vlad-notification-manager-second-iteration
vlad-schur-external-sap Jun 9, 2026
600413d
fix(ui): franz comments for lifecycle management of notification-manager
vlad-schur-external-sap Jun 10, 2026
f59da03
fix(ui): sonner locked version
vlad-schur-external-sap Jun 10, 2026
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/hip-parts-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudoperators/juno-ui-components": minor
---

Add a new NotificationManager component, replacing the existing Toast and using it as a base for presentation.
Comment thread
vlad-schur-external-sap marked this conversation as resolved.
Outdated
1 change: 1 addition & 0 deletions packages/ui-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"react": "19.2.6",
"react-dom": "19.2.6",
"react-tabs": "6.1.1",
"sonner": "2.0.7",
Comment thread
vlad-schur-external-sap marked this conversation as resolved.
Outdated
"storybook": "10.3.6",
"tailwindcss": "4.3.0",
"typescript": "6.0.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from "react"
import { Toaster, toast as sonnerToast } from "sonner"
import { customToast, NotificationToast, ToastHandler, ToastPosition, ToastVariant } from "./NotificationManager.types"
import { Toast } from "../Toast"
Comment thread
vlad-schur-external-sap marked this conversation as resolved.

/**
* NotificationManager wraps the Sonner toast library and supports rendering
* multiple notifications simultaneously, each with independent durations and
* dismissibility settings.
*
* @example
* // Multiple notifications appear at the same time, each with its own timer
* toast("First notification", { duration: 2000 })
* toast("Second notification", { duration: 5000 })
* // First closes at 2s, second at 5s, independently
*/
export interface NotificationManagerProps {
/**
* Optional Sonner toaster id. Use this to scope notifications to a specific
* NotificationManager instance.
*/
id?: string

/**
* Controls whether notifications can be dismissed manually.
*
* Set to `false` to render non-dismissible notifications that disappear only
* after their configured duration (or when dismissed programmatically).
* Can be overridden for individual notifications by passing
* `closeButton` in `toast()` options.
*
* @example
* toast("Background sync started", { closeButton: false })
*
* @default true
*/
dismissible?: boolean

/**
* Default display time for notifications in milliseconds.
*
* Use this to customize how long timed notifications stay visible before
* auto-dismiss.
* Can be overridden for individual notifications by passing
* `duration` in `toast()` options.
*
* @example
* toast("Changes saved", { duration: 10000 })
*
* @default 4000
*/
duration?: number

/**
* Maximum number of notifications visible on screen at once.
*
* Additional notifications queue internally and appear as others close.
* If more toasts exist than this limit allows, hidden toasts remain invisible
* (CSS `data-visible="false"`). Handling extreme overflow (e.g., >10 simultaneous
* toasts) via custom scrolling/pagination is not a common requirement and should
* be addressed per app design if needed.
*
* @default 3
*/
visibleToasts?: number

/**
* Position of the notification stack on screen.
*
* @default "bottom-right"
*/
position?: ToastPosition
}

/**
* NotificationManager component that wraps Sonner's Toaster.
*
* All lifecycle logic (timers, auto-dismiss, dismissal handling) is delegated
* to the Sonner library, allowing the Toast component to be a fully logic-less
* presentational component.
*
* Existing notifications can be targeted by id in order to update or dismiss
* them programmatically.
*
* @example
* const notificationId = toast.error("Error occurred")
* toast("Error resolved", { id: notificationId })
* toast.dismiss(notificationId)
*
* Events can also be fired per toast call:
* - shown: when `toast(...)` returns an id for the created notification
* - onclick/dismissed/disappeared: through `onClick`, `onDismiss` and `onAutoClose` in toast options
*
* @example
* toast.info("Upload started", {
* onClick: () => console.log("run the callback passed via onClick"),
* onDismiss: () => console.log("dismissed by user or programmatically"),
* onAutoClose: () => console.log("closed after duration"),
* })
*
* **Visibility & Overflow Behavior:**
* - By default, `expand={true}` renders all visible notifications at full height
* with consistent spacing, rather than stacking diminished copies.
* - The `visibleToasts` prop (default: 3) limits how many notifications display
* simultaneously. Additional notifications queue invisibly and appear as others close.
*
* **Notification History:**
* - `toast.getToasts()` returns currently active (not dismissed) notifications.
* - `toast.getHistory()` returns all notifications created during the current runtime,
* including notifications that have already been dismissed/expired.
*
* If persistence across page reloads/navigation/app restarts is needed, it must be
* implemented with an explicit storage mechanism (e.g., application state, backend,
* or localStorage). Long-term retention is intentionally the consumer's responsibility.
*
* @see Toast - The presentational component (should remain logic-less)
* @see https://sonner.emilkowal.ski/
*/
export const NotificationManager = ({
id,
dismissible = true,
duration = 4000,
visibleToasts = 3,
position = "bottom-right",
...props
}: NotificationManagerProps) => (
<Toaster
expand
id={id}
closeButton={dismissible}
duration={duration}
Comment thread
vlad-schur-external-sap marked this conversation as resolved.
Outdated
visibleToasts={visibleToasts}
position={position}
toastOptions={{ classNames: { toast: "juno-toast jn:font-sans" } }}
className="juno-notification-manager jn:font-sans"
{...props}
/>
)

/**
* Builds a semantic toast handler that renders Juno's `Toast` component through
* Sonner's `custom` API.
*
* Why this exists:
* - Keeps Sonner in charge of queueing, timing, and dismissal lifecycle.
* - Allows Juno-specific markup and visual semantics per variant.
* - Preserves id-based dismiss/update capabilities from Sonner.
*
* The incoming message/description can be values or lazy functions; both are
* normalized before rendering into the custom toast body.
*/
const createSemanticToast = (variant: ToastVariant): ToastHandler => {
return (message, data) => {
const title = typeof message === "function" ? message() : message
const description = typeof data?.description === "function" ? data.description() : data?.description

const { description: _description, ...options } = data ?? {}

// Use Sonner's custom renderer but keep dismissal bound to Sonner toast id.
return customToast(
(id) => (
<Toast variant={variant} onDismiss={() => sonnerToast.dismiss(id)}>
Comment thread
vlad-schur-external-sap marked this conversation as resolved.
Outdated
<div className="jn:flex jn:flex-col jn:gap-1">
<div>{title}</div>
{description ? <div className="jn:text-theme-medium">{description}</div> : null}
</div>
</Toast>
),
options
)
Comment thread
vlad-schur-external-sap marked this conversation as resolved.
}
}

/**
* Sonner exposes a broader internal set of toast types
* (`normal | action | success | info | warning | error | loading | default`),
* but the semantic helper surface is intentionally aligned with Juno semantics.
* Juno notification API overrides the semantic variant helpers to:
* `info | success | warning | error | danger`.
*
* Calling `toast()` without a variant renders as the `info` semantic variant.
*
* All other Sonner methods, such as `dismiss`, `getHistory`, `getToasts`,
* `loading`, `promise`, and `custom`, remain available on the exported API.
*/
const semanticToasts = {
info: createSemanticToast("info"),
success: createSemanticToast("success"),
warning: createSemanticToast("warning"),
error: createSemanticToast("error"),
danger: createSemanticToast("danger"),
} satisfies Record<ToastVariant, ToastHandler>

const baseToast: ToastHandler = (message, data) => semanticToasts.info(message, data)
export const toast = Object.assign(baseToast, sonnerToast, semanticToasts) as NotificationToast
Loading
Loading