diff --git a/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts new file mode 100644 index 0000000000..fafe302b6a --- /dev/null +++ b/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts @@ -0,0 +1,123 @@ +/* + * 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 { createApiRef, ApiRef } from '../system'; +import { Observable } from '@backstage/types'; +import { ReactNode, ReactElement } from 'react'; + +/** + * Link item for toast notifications. + * + * @public + */ +export type ToastLink = { + /** Display text for the link */ + label: string; + /** URL the link points to */ + href: string; +}; + +/** + * Message handled by the {@link ToastApi}. + * + * @public + */ +export type ToastMessage = { + /** Title of the toast (required) */ + title: ReactNode; + /** Optional description text */ + description?: ReactNode; + /** Status variant of the toast - defaults to 'success' */ + status?: 'info' | 'success' | 'warning' | 'danger'; + /** Whether to show an icon, or a custom icon element */ + icon?: boolean | ReactElement; + /** Optional array of links to display */ + links?: ToastLink[]; + /** Timeout in milliseconds before auto-dismiss. If not set, toast is permanent. */ + timeout?: number; +}; + +/** + * Internal toast message with key for tracking. + * + * @internal + */ +export type ToastMessageWithKey = ToastMessage & { + /** Unique key for the toast, used for programmatic dismiss */ + key: string; +}; + +/** + * The toast API is used to display toast notifications to the user. + * + * @remarks + * This API provides richer notification capabilities than the AlertApi, + * including title/description, custom icons, links, and per-toast timeout control. + * + * @example + * ```tsx + * const toastApi = useApi(toastApiRef); + * + * // Full-featured toast + * toastApi.post({ + * title: 'Entity saved', + * description: 'Your changes have been saved successfully.', + * status: 'success', + * timeout: 5000, + * links: [{ label: 'View entity', href: '/catalog/default/component/my-service' }], + * }); + * + * // Simple toast + * toastApi.post({ title: 'Processing...', status: 'info' }); + * + * // Programmatic dismiss + * const key = toastApi.post({ title: 'Uploading...', status: 'info' }); + * // Later... + * toastApi.close(key); + * ``` + * + * @public + */ +export type ToastApi = { + /** + * Post a toast notification for display to the user. + * + * @param toast - The toast message to display + * @returns A unique key that can be used to programmatically dismiss the toast + */ + post(toast: ToastMessage): string; + + /** + * Programmatically close/dismiss a toast by its key. + * + * @param key - The key returned from post() + */ + close(key: string): void; + + /** + * Observe toasts posted by other parts of the application. + */ + toast$(): Observable; +}; + +/** + * The {@link ApiRef} of {@link ToastApi}. + * + * @public + */ +export const toastApiRef: ApiRef = createApiRef({ + id: 'core.toast', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 5e7f713fcf..41dce8f915 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -48,4 +48,5 @@ export * from './OAuthRequestApi'; export * from './RouteResolutionApi'; export * from './StorageApi'; export * from './AnalyticsApi'; +export * from './ToastApi'; export * from './TranslationApi'; diff --git a/plugins/app/package.json b/plugins/app/package.json index b5e9305043..ed03bd8d4d 100644 --- a/plugins/app/package.json +++ b/plugins/app/package.json @@ -20,7 +20,9 @@ "directory": "plugins/app" }, "license": "Apache-2.0", - "sideEffects": false, + "sideEffects": [ + "*.css" + ], "exports": { ".": "./src/index.ts", "./alpha": "./src/alpha/index.ts", @@ -63,8 +65,15 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", + "@react-aria/toast": "^3.0.9", "@react-hookz/web": "^24.0.0", + "@remixicon/react": "^4.6.0", + "motion": "^12.0.0", + "react-aria": "^3.41.1", + "react-aria-components": "^1.14.0", + "react-stately": "^3.35.0", "react-use": "^17.2.4", + "zen-observable": "^0.10.0", "zod": "^3.25.76" }, "devDependencies": { diff --git a/plugins/app/src/alpha/appModulePublicSignIn.test.tsx b/plugins/app/src/alpha/appModulePublicSignIn.test.tsx index c7f1e5a68d..220090b83d 100644 --- a/plugins/app/src/alpha/appModulePublicSignIn.test.tsx +++ b/plugins/app/src/alpha/appModulePublicSignIn.test.tsx @@ -100,6 +100,14 @@ describe('appModulePublicSignIn', () => { data-unified-theme-stack="[{"mode":"light","name":"backstage"}]" >
+
{ + private subscribers = new Set>(); + private isClosed = false; + + private readonly observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + subscriber.complete(); + return () => {}; + } + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + next(value: T) { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.subscribers.forEach(subscriber => subscriber.next(value)); + } + + subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription { + return this.observable.subscribe(observer); + } + + /** + * Creates an Observable that replays buffered values and then subscribes to live updates. + */ + asObservable(replayBuffer: T[] = []): Observable { + return new ObservableImpl(subscriber => { + // Replay buffered values + for (const value of replayBuffer) { + subscriber.next(value); + } + // Subscribe to live updates + return this.subscribe(subscriber); + }); + } +} + +/** + * Base implementation for the ToastApi that forwards toast messages to consumers. + * + * Recent toasts are buffered and replayed to new subscribers to prevent + * missing toasts that were posted before subscription. + * + * @internal + */ +export class ToastApiForwarder implements ToastApi { + private readonly subject = new PublishSubject(); + private readonly closeSubject = new PublishSubject(); + private readonly recentToasts: ToastMessageWithKey[] = []; + private readonly maxBufferSize = 10; + + post(toast: ToastMessage): string { + const key = generateToastKey(); + const toastWithKey: ToastMessageWithKey = { ...toast, key }; + + this.recentToasts.push(toastWithKey); + if (this.recentToasts.length > this.maxBufferSize) { + this.recentToasts.shift(); + } + this.subject.next(toastWithKey); + + return key; + } + + close(key: string): void { + // Remove from recent buffer if still there + const index = this.recentToasts.findIndex(t => t.key === key); + if (index !== -1) { + this.recentToasts.splice(index, 1); + } + this.closeSubject.next(key); + } + + toast$(): Observable { + return this.subject.asObservable(this.recentToasts); + } + + /** + * Observe close requests for toasts. + * This is used internally by the ToastDisplay to know when to dismiss a toast programmatically. + * + * @internal + */ + close$(): Observable { + return this.closeSubject.asObservable(); + } +} diff --git a/plugins/app/src/apis/index.ts b/plugins/app/src/apis/index.ts new file mode 100644 index 0000000000..7d32d25ef4 --- /dev/null +++ b/plugins/app/src/apis/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { ToastApiForwarder } from './ToastApiForwarder'; diff --git a/plugins/app/src/components/Toast/Toast.css b/plugins/app/src/components/Toast/Toast.css new file mode 100644 index 0000000000..d35079d74a --- /dev/null +++ b/plugins/app/src/components/Toast/Toast.css @@ -0,0 +1,239 @@ +/* + * 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. + */ + +/* Toast Container - Container for all toasts (bottom center) */ +.toast-container { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 100050; + width: 300px; + outline: none; +} + +@media (min-width: 500px) { + .toast-container { + width: 350px; + bottom: 2rem; + } +} + +/* Individual Toast */ +.toast { + /* CSS Variables */ + --toast-peek: 0.75rem; + --toast-scale: calc(max(0, 1 - (var(--toast-index) * 0.05))); + + /* Layout */ + position: absolute; + bottom: 0; + left: 0; + width: 100%; + margin: 0 auto; + box-sizing: border-box; + display: flex; + align-items: flex-start; + padding: 1rem; + gap: 0.75rem; + + /* Appearance */ + border-radius: 0.5rem; + background-color: #1f1f1f; + color: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, + Arial, sans-serif; + font-size: 0.875rem; + outline: none; + cursor: default; + user-select: none; + /* All toasts need pointer-events to trigger hover expansion */ + pointer-events: auto; + + /* Stacking */ + z-index: calc(1000 - var(--toast-index)); + transform-origin: bottom center; + + /* Shadow */ + box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.4); +} + +/* Light mode toast (inverted from dark app theme) */ +[data-theme-mode='light'] .toast { + background-color: #ffffff; + color: #1f1f1f; + box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05); +} + +.toast:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 2px; +} + +/* Extend hover area above each toast to fill gaps when expanded */ +/* This prevents the stack from collapsing when hovering between toasts */ +.toast::before { + content: ''; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + /* Height matches the expanded gap to ensure continuous hover */ + height: 12px; + pointer-events: auto; +} + +/* Status variants - color icon and title */ +.toast[data-status='info'] .toast-icon, +.toast[data-status='info'] .toast-title { + color: #3b82f6; +} + +.toast[data-status='success'] .toast-icon, +.toast[data-status='success'] .toast-title { + color: #22c55e; +} + +.toast[data-status='warning'] .toast-icon, +.toast[data-status='warning'] .toast-title { + color: #f59e0b; +} + +.toast[data-status='danger'] .toast-icon, +.toast[data-status='danger'] .toast-title { + color: #ef4444; +} + +.toast-wrapper { + display: flex; + align-items: flex-start; + gap: 0.75rem; + flex: 1; + min-width: 0; +} + +.toast-content { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +/* Icon */ +.toast-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + margin-top: 0.125rem; +} + +.toast-icon svg { + width: 1rem; + height: 1rem; +} + +/* Title */ +.toast-title { + font-weight: 600; + font-size: 0.875rem; + word-wrap: break-word; + margin-top: 0.125rem; +} + +/* Description */ +.toast-description { + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + word-wrap: break-word; +} + +[data-theme-mode='light'] .toast-description { + color: rgba(0, 0, 0, 0.6); +} + +/* Links */ +.toast-links { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.25rem; +} + +.toast-links a { + font-family: var(--bui-font-regular); + padding: 0; + margin: 0; + cursor: pointer; + display: inline-block; + + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-thickness: min(2px, max(1px, 0.05em)); + text-underline-offset: calc(0.025em + 2px); + text-decoration-color: color-mix(in srgb, currentColor 30%, transparent); +} + +.toast-links a:hover { + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-thickness: min(2px, max(1px, 0.05em)); + text-underline-offset: calc(0.025em + 2px); + text-decoration-color: color-mix(in srgb, currentColor 30%, transparent); +} + +/* Close Button */ +.toast-close-button { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin: -0.25rem -0.25rem 0 0; + padding: 0; + border: none; + border-radius: 0.25rem; + background: transparent; + color: inherit; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.toast-close-button svg { + width: 1rem; + height: 1rem; +} + +.toast-close-button:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +[data-theme-mode='light'] .toast-close-button:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.toast-close-button:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 1px; +} + +.toast-close-button:active { + background-color: rgba(255, 255, 255, 0.15); +} + +[data-theme-mode='light'] .toast-close-button:active { + background-color: rgba(0, 0, 0, 0.1); +} diff --git a/plugins/app/src/components/Toast/Toast.stories.tsx b/plugins/app/src/components/Toast/Toast.stories.tsx new file mode 100644 index 0000000000..e74677e188 --- /dev/null +++ b/plugins/app/src/components/Toast/Toast.stories.tsx @@ -0,0 +1,624 @@ +/* + * 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 { useState } from 'react'; +/* eslint-disable @backstage/no-relative-monorepo-imports */ +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 { MemoryRouter } from 'react-router-dom'; + +const meta = preview.meta({ + title: 'Plugins/App/Toast', + component: ToastContainer, + parameters: { + layout: 'centered', + }, +}); + +const randomToasts = [ + // Title only - short + { title: 'Saved', status: 'success' as const }, + { title: 'Error', status: 'danger' as const }, + { title: 'New notification', status: 'info' as const }, + { title: 'Warning', status: 'warning' as const }, + // Title only - medium + { title: 'Changes saved successfully', status: 'success' as const }, + { title: 'Connection restored', status: 'info' as const }, + { title: 'Action could not be completed', status: 'danger' as const }, + // Title + short description + { + title: 'Files uploaded', + description: '3 files uploaded.', + status: 'success' as const, + }, + { + title: 'Update available', + description: 'Version 2.0 is ready.', + status: 'info' as const, + }, + { + title: 'Request failed', + description: 'Please try again.', + status: 'danger' as const, + }, + { + title: 'Storage warning', + description: '90% used.', + status: 'warning' as const, + }, + // Title + medium description + { + title: 'Deployment complete', + description: + 'Your application has been deployed to production successfully.', + status: 'success' as const, + }, + { + title: 'Session expiring', + description: + 'Your session will expire in 5 minutes. Please save your work.', + status: 'warning' as const, + }, + { + title: 'Permission denied', + description: 'You do not have access to perform this action.', + status: 'danger' as const, + }, + // Title + long description + { + title: 'Sync completed', + description: + 'All your files have been synchronized across devices. This includes 47 documents, 23 images, and 12 configuration files that were updated in the last hour.', + status: 'success' as const, + }, + { + title: 'Rate limit exceeded', + description: + 'You have exceeded the maximum number of API requests allowed. Please wait a few minutes before trying again or upgrade your plan for higher limits.', + status: 'warning' as const, + }, + { + title: 'Critical error', + description: + 'The server encountered an unexpected error while processing your request. Our team has been notified and is investigating the issue. Please try again later.', + status: 'danger' as const, + }, + // Long title only + { + title: 'Your subscription has been renewed successfully for another year', + status: 'success' as const, + }, + { + title: 'Multiple users are currently editing this document', + status: 'info' as const, + }, +]; + +export const Default = meta.story({ + render: () => ( + <> + + + + + + + ), +}); + +export const StatusVariants = meta.story({ + render: () => ( + <> + + + + + + + + + ), +}); + +export const WithoutDescription = meta.story({ + render: () => ( + <> + + + + + + + ), +}); + +export const WithoutIcons = meta.story({ + render: () => ( + <> + + + + + + + ), +}); + +export const WithLinks = meta.story({ + render: () => ( + + + + + + + + + ), +}); + +export const AutoDismiss = meta.story({ + render: () => ( + <> + + + + + + + + ), +}); + +function ProgrammaticDismissStory() { + const [toastKey, setToastKey] = useState(null); + + return ( + <> + + + + ); +} + +export const ProgrammaticDismiss = meta.story({ + render: () => , +}); + +export const QueueManagement = meta.story({ + render: () => ( + <> + + + + + + + + ), +}); + +export const AlertApiIntegration = meta.story({ + name: 'AlertApi Integration', + render: () => { + // This story demonstrates how alerts posted via the AlertApi + // would be displayed using the ToastDisplay component. + // The ToastDisplay bridges AlertApi.post() calls to the toast queue. + + return ( + <> + + + + + AlertApi Severity Mapping: + + success → success (green) + info → info (blue) + warning → warning (orange) + error → danger (red) + + + + + + + + + + ); + }, +}); + +/** + * This story tests the real AlertApi integration. + * It uses the alertApi from the TestApiProvider (set up in storybook preview) + * and shows how alerts posted via alertApi.post() appear. + * + * Note: The storybook preview.tsx renders AlertDisplay from core-components, + * which still uses Material UI. To test the new ToastDisplay, run the actual + * Backstage app where the app plugin's elements.tsx is used. + */ +function RealAlertApiStory() { + // eslint-disable-next-line @backstage/no-relative-monorepo-imports + const { useApi, alertApiRef } = require('@backstage/core-plugin-api'); + const alertApi = useApi(alertApiRef); + + return ( + + + + Real AlertApi Test + + + These buttons call alertApi.post() directly. Alerts appear in the OLD + AlertDisplay (top of screen) because Storybook uses core-components. + + + To test the NEW ToastDisplay, run: yarn start + + + + + + + + + + + + + + ); +} + +export const RealAlertApi = meta.story({ + name: 'Real AlertApi Test', + render: () => , +}); + +export default meta; diff --git a/plugins/app/src/components/Toast/Toast.tsx b/plugins/app/src/components/Toast/Toast.tsx new file mode 100644 index 0000000000..bdab7b69a5 --- /dev/null +++ b/plugins/app/src/components/Toast/Toast.tsx @@ -0,0 +1,271 @@ +/* + * 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 { + forwardRef, + Ref, + isValidElement, + ReactElement, + useRef, + useLayoutEffect, + useState, +} from 'react'; +import { useToast } from '@react-aria/toast'; +import { Button } from 'react-aria-components'; +import { motion } from 'motion/react'; +import { + RiInformationLine, + RiCheckLine, + RiErrorWarningLine, + RiAlertLine, + RiCloseLine, +} from '@remixicon/react'; +import type { ToastProps } from './types'; + +// Track which toasts are being manually closed (vs auto-timeout) +// This allows different exit animations for each case +const manuallyClosingToasts = new Set(); + +/** + * A Toast displays a brief, temporary notification of actions, errors, or other events in an application. + * + * @remarks + * The Toast component is used internally by ToastContainer and managed by a ToastQueue. + * It supports multiple status variants (info, success, warning, danger) and can display + * a title, description, and optional icon. Toasts can be dismissed manually or automatically. + * + * @internal + */ +export const Toast = forwardRef( + (props: ToastProps, ref: Ref) => { + const { + toast, + state, + index = 0, + isExpanded = false, + onClose, + status, + icon, + expandedY: expandedYProp = 0, + collapsedHeight, + naturalHeight, + onHeightChange, + } = props; + + // Use internal ref if none provided + const internalRef = useRef(null); + const toastRef = (ref as React.RefObject) || internalRef; + + // Get ARIA props from useToast hook + const { toastProps, titleProps, closeButtonProps } = useToast( + { toast }, + state, + toastRef, + ); + + // Extract only ARIA and accessibility props from toastProps to avoid + // conflicts with motion.div's event handler types (motion has its own drag API) + const ariaProps = { + role: toastProps.role, + tabIndex: toastProps.tabIndex, + 'aria-label': toastProps['aria-label'], + 'aria-labelledby': toastProps['aria-labelledby'], + 'aria-describedby': toastProps['aria-describedby'], + 'aria-posinset': toastProps['aria-posinset'], + 'aria-setsize': toastProps['aria-setsize'], + }; + + // Track whether we've measured this toast's natural height + const [hasMeasured, setHasMeasured] = useState(false); + // Store the measured natural height locally to avoid re-measurement issues + const naturalHeightRef = useRef(null); + + // Measure this toast's natural height on mount (before paint) + // Using useLayoutEffect ensures we measure before the browser paints + useLayoutEffect(() => { + if (!onHeightChange) return; + if (naturalHeightRef.current) return; // Already measured + + const element = toastRef.current; + if (!element) return; + + // Measure immediately - useLayoutEffect runs before paint + const height = element.getBoundingClientRect().height; + if (height > 0) { + naturalHeightRef.current = height; + onHeightChange(toast.key, height); + setHasMeasured(true); + } + }, [toast.key, onHeightChange, toastRef]); + + // Close button handler + const handleClose = () => { + // Mark this toast as manually closed for exit animation + manuallyClosingToasts.add(toast.key); + onClose?.(); + state.close(toast.key); + }; + + // Get content from toast + const content = toast.content; + const finalStatus = status || content.status || 'info'; + const finalIcon = icon !== undefined ? icon : content.icon; + + // Determine which icon to render + const getStatusIcon = (): ReactElement | null => { + // If icon is explicitly false, don't render any icon + if (finalIcon === false) { + return null; + } + + // If icon is a custom React element, use it + if (isValidElement(finalIcon)) { + return finalIcon; + } + + // If icon is true or undefined (default to true for toasts), auto-select based on status + if (finalIcon === true || finalIcon === undefined) { + switch (finalStatus) { + case 'success': + return