diff --git a/plugins/app/src/components/Toast/Toast.css b/plugins/app/src/components/Toast/Toast.module.css similarity index 83% rename from plugins/app/src/components/Toast/Toast.css rename to plugins/app/src/components/Toast/Toast.module.css index 050b2c6fa2..eeb6e92084 100644 --- a/plugins/app/src/components/Toast/Toast.css +++ b/plugins/app/src/components/Toast/Toast.module.css @@ -15,7 +15,7 @@ */ /* Toast Container - Container for all toasts (bottom center) */ -.toast-container { +.container { position: fixed; bottom: 1rem; left: 50%; @@ -26,7 +26,7 @@ } @media (min-width: 500px) { - .toast-container { + .container { width: 350px; bottom: 2rem; } @@ -67,12 +67,6 @@ /* Shadow */ box-shadow: 0 4px 12px -2px rgba(0 0 0 / 0.4); - - /* Focus ring */ - &:focus-visible { - outline: 2px solid var(--bui-border-focus); - outline-offset: 2px; - } } .toast:focus-visible { @@ -94,31 +88,31 @@ } /* Status variants - color icon and title */ -.toast[data-status='neutral'] .toast-title { +.toast[data-status='neutral'] .title { color: var(--bui-fg-primary); } -.toast[data-status='info'] .toast-icon, -.toast[data-status='info'] .toast-title { +.toast[data-status='info'] .icon, +.toast[data-status='info'] .title { color: var(--bui-fg-info); } -.toast[data-status='success'] .toast-icon, -.toast[data-status='success'] .toast-title { +.toast[data-status='success'] .icon, +.toast[data-status='success'] .title { color: var(--bui-fg-success); } -.toast[data-status='warning'] .toast-icon, -.toast[data-status='warning'] .toast-title { +.toast[data-status='warning'] .icon, +.toast[data-status='warning'] .title { color: var(--bui-fg-warning); } -.toast[data-status='danger'] .toast-icon, -.toast[data-status='danger'] .toast-title { +.toast[data-status='danger'] .icon, +.toast[data-status='danger'] .title { color: var(--bui-fg-danger); } -.toast-wrapper { +.wrapper { display: flex; align-items: flex-start; gap: var(--bui-space-3); @@ -126,14 +120,14 @@ min-width: 0; } -.toast-content { +.content { display: flex; flex-direction: column; gap: var(--bui-space-1); } /* Icon */ -.toast-icon { +.icon { flex-shrink: 0; display: flex; align-items: center; @@ -141,13 +135,13 @@ margin-top: var(--bui-space-0_5); } -.toast-icon svg { +.icon svg { width: 1rem; height: 1rem; } /* Title */ -.toast-title { +.title { font-weight: var(--bui-font-weight-bold); font-size: var(--bui-font-size-3); word-wrap: break-word; @@ -155,7 +149,7 @@ } /* Description */ -.toast-description { +.description { color: var(--bui-fg-secondary); font-size: var(--bui-font-size-3); opacity: 0.9; @@ -163,14 +157,14 @@ } /* Links */ -.toast-links { +.links { display: flex; flex-wrap: wrap; gap: var(--bui-space-3); margin-top: var(--bui-space-1); } -.toast-links a { +.links a { font-family: var(--bui-font-regular); padding: 0; margin: 0; @@ -184,7 +178,7 @@ text-decoration-color: color-mix(in srgb, currentColor 30%, transparent); } -.toast-links a:hover { +.links a:hover { text-decoration-line: underline; text-decoration-style: solid; text-decoration-thickness: min(2px, max(1px, 0.05em)); @@ -193,7 +187,7 @@ } /* Close Button */ -.toast-close-button { +.closeButton { flex-shrink: 0; display: flex; align-items: center; @@ -211,20 +205,20 @@ color: var(--bui-fg-primary); } -.toast-close-button svg { +.closeButton svg { width: 1rem; height: 1rem; } -.toast-close-button:hover { +.closeButton:hover { background-color: var(--bui-bg-neutral-on-surface-1-hover); } -.toast-close-button:focus-visible { +.closeButton:focus-visible { outline: 2px solid var(--bui-border-focus); outline-offset: 1px; } -.toast-close-button:active { +.closeButton:active { background-color: var(--bui-bg-neutral-on-surface-1-pressed); } diff --git a/plugins/app/src/components/Toast/Toast.stories.tsx b/plugins/app/src/components/Toast/Toast.stories.tsx index 14a3dab126..21f8a40e3c 100644 --- a/plugins/app/src/components/Toast/Toast.stories.tsx +++ b/plugins/app/src/components/Toast/Toast.stories.tsx @@ -18,9 +18,13 @@ import { useState } from 'react'; import preview from '../../../../../.storybook/preview'; import { Button, Flex, Text } from '../../../../../packages/ui/src'; /* eslint-enable @backstage/no-relative-monorepo-imports */ -import { ToastContainer, toastQueue } from './index'; +import { ToastQueue } from '@react-stately/toast'; +import { ToastContainer } from './index'; +import type { ToastContent } from './types'; import { MemoryRouter } from 'react-router-dom'; +const toastQueue = new ToastQueue({ maxVisibleToasts: 4 }); + const meta = preview.meta({ title: 'Plugins/App/Toast', component: ToastContainer, diff --git a/plugins/app/src/components/Toast/Toast.tsx b/plugins/app/src/components/Toast/Toast.tsx index c2a1258fa0..0a62c129e0 100644 --- a/plugins/app/src/components/Toast/Toast.tsx +++ b/plugins/app/src/components/Toast/Toast.tsx @@ -26,6 +26,7 @@ import { RiCloseLine, } from '@remixicon/react'; import type { ToastProps } from './types'; +import styles from './Toast.module.css'; // Track which toasts are being manually closed (vs auto-timeout) // This allows different exit animations for each case @@ -205,7 +206,7 @@ export const Toast = forwardRef( -
- {statusIcon &&
{statusIcon}
} -
-
+
+ {statusIcon &&
{statusIcon}
} +
+
{content.title}
{content.description && ( -
{content.description}
+
{content.description}
)} {content.links && content.links.length > 0 && ( -
+
{content.links.map(link => ( {link.label} @@ -248,7 +249,7 @@ export const Toast = forwardRef( diff --git a/plugins/app/src/components/Toast/ToastContainer.tsx b/plugins/app/src/components/Toast/ToastContainer.tsx index 07bb38e537..94394e204d 100644 --- a/plugins/app/src/components/Toast/ToastContainer.tsx +++ b/plugins/app/src/components/Toast/ToastContainer.tsx @@ -21,6 +21,7 @@ import { AnimatePresence } from 'motion/react'; import type { ToastContainerProps } from './types'; import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode'; import { Toast } from './Toast'; +import styles from './Toast.module.css'; /** * A ToastContainer displays one or more toast notifications in the bottom-center of the screen. @@ -119,7 +120,7 @@ export const ToastContainer = forwardRef(
setIsHovered(true)} diff --git a/plugins/app/src/components/Toast/ToastDisplay.test.tsx b/plugins/app/src/components/Toast/ToastDisplay.test.tsx index dc2839c9b7..53730ba37a 100644 --- a/plugins/app/src/components/Toast/ToastDisplay.test.tsx +++ b/plugins/app/src/components/Toast/ToastDisplay.test.tsx @@ -26,7 +26,6 @@ import { Observable } from '@backstage/types'; import ObservableImpl from 'zen-observable'; import { ToastDisplay } from './ToastDisplay'; import { ToastApiForwarder } from '../../apis'; -import { toastQueue } from './ToastQueue'; // Mock AlertApi with proper Observable implementation class MockAlertApi implements AlertApi { @@ -53,10 +52,6 @@ describe('ToastDisplay', () => { let alertApi: MockAlertApi; beforeEach(() => { - // Clear the toast queue before each test - while (toastQueue.visibleToasts.length > 0) { - toastQueue.close(toastQueue.visibleToasts[0].key); - } toastApi = new ToastApiForwarder(); alertApi = new MockAlertApi(); }); diff --git a/plugins/app/src/components/Toast/ToastDisplay.tsx b/plugins/app/src/components/Toast/ToastDisplay.tsx index 2740cd335f..21791b8256 100644 --- a/plugins/app/src/components/Toast/ToastDisplay.tsx +++ b/plugins/app/src/components/Toast/ToastDisplay.tsx @@ -14,13 +14,12 @@ * limitations under the License. */ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { alertApiRef, useApi } from '@backstage/core-plugin-api'; import { toastApiRef } from '@backstage/frontend-plugin-api'; +import { ToastQueue } from '@react-stately/toast'; import { ToastContainer } from './ToastContainer'; -import { toastQueue } from './ToastQueue'; import type { ToastDisplayProps, ToastContent } from './types'; -import './Toast.css'; /** * Maps AlertApi severity to Toast status. @@ -85,6 +84,11 @@ export function ToastDisplay(props: ToastDisplayProps) { const toastApi = useApi(toastApiRef); const { transientTimeoutMs = 5000 } = props; + // Create toast queue once per component instance + const [toastQueue] = useState( + () => new ToastQueue({ maxVisibleToasts: 4 }), + ); + // Track toast keys for programmatic close const toastKeyMap = useRef>(new Map()); @@ -108,7 +112,7 @@ export function ToastDisplay(props: ToastDisplayProps) { }); return () => subscription.unsubscribe(); - }, [toastApi]); + }, [toastApi, toastQueue]); // Subscribe to ToastApi close events for programmatic dismissal useEffect(() => { @@ -121,7 +125,7 @@ export function ToastDisplay(props: ToastDisplayProps) { }); return () => subscription.unsubscribe(); - }, [toastApi]); + }, [toastApi, toastQueue]); // Subscribe to AlertApi (deprecated - provides backward compatibility during migration) // This subscription will be removed when AlertApi is fully deprecated @@ -140,7 +144,7 @@ export function ToastDisplay(props: ToastDisplayProps) { }); return () => subscription.unsubscribe(); - }, [alertApi, transientTimeoutMs]); + }, [alertApi, transientTimeoutMs, toastQueue]); return ; } diff --git a/plugins/app/src/components/Toast/ToastQueue.ts b/plugins/app/src/components/Toast/ToastQueue.ts deleted file mode 100644 index 31ec0cb2d9..0000000000 --- a/plugins/app/src/components/Toast/ToastQueue.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ToastQueue } from '@react-stately/toast'; -import type { ToastContent } from './types'; - -/** - * Global toast queue for displaying toast notifications throughout the application. - * - * @remarks - * This uses React Stately's ToastQueue for state management with motion/react - * for smooth enter/exit animations. - * - * @example - * ```tsx - * import { toastQueue } from './ToastQueue'; - * - * // Show a toast - * toastQueue.add({ title: 'Success!', status: 'success' }); - * - * // Show with auto-dismiss - * toastQueue.add({ title: 'Saved' }, { timeout: 5000 }); - * - * // Programmatic dismiss - * const key = toastQueue.add({ title: 'Processing...' }); - * // Later... - * toastQueue.close(key); - * ``` - * - * @internal - */ -export const toastQueue = new ToastQueue({ - maxVisibleToasts: 4, -}); diff --git a/plugins/app/src/components/Toast/index.ts b/plugins/app/src/components/Toast/index.ts index 279bc3b8cc..f7b7ce71ef 100644 --- a/plugins/app/src/components/Toast/index.ts +++ b/plugins/app/src/components/Toast/index.ts @@ -20,5 +20,4 @@ export type { ToastDisplayProps } from './types'; // Internal exports (used within the plugin only) export { ToastContainer } from './ToastContainer'; -export { toastQueue } from './ToastQueue'; export type { ToastContent, ToastLink, ToastContainerProps } from './types';