diff --git a/.changeset/nine-islands-glow.md b/.changeset/nine-islands-glow.md deleted file mode 100644 index 87ab9a673d..0000000000 --- a/.changeset/nine-islands-glow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/ui': minor ---- - -Added Toast component in Backstage UI with support for status variants, auto-dismiss, links, and stacking. diff --git a/packages/ui/package.json b/packages/ui/package.json index 00db397601..671be132e8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -47,13 +47,10 @@ }, "dependencies": { "@backstage/version-bridge": "workspace:^", - "@react-aria/toast": "^3.0.9", "@remixicon/react": "^4.6.0", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", - "motion": "^12.0.0", - "react-aria-components": "^1.14.0", - "react-stately": "^3.35.0" + "react-aria-components": "^1.14.0" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/ui/src/components/Toast/Toast.module.css b/packages/ui/src/components/Toast/Toast.module.css deleted file mode 100644 index 89cba0b6bd..0000000000 --- a/packages/ui/src/components/Toast/Toast.module.css +++ /dev/null @@ -1,203 +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. - */ - -@layer tokens, base, components, utilities; - -@layer components { - /* Toast Container - Container for all toasts (bottom center) */ - .bui-ToastContainer { - position: fixed; - bottom: 1rem; - left: 50%; - transform: translateX(-50%); - z-index: 100050; - width: 300px; - outline: none; - } - - @media (min-width: 500px) { - .bui-ToastContainer { - width: 350px; - bottom: 2rem; - } - } - - /* Individual Toast */ - .bui-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: var(--bui-space-4); - gap: var(--bui-space-3); - - /* Appearance */ - border-radius: var(--bui-radius-3); - background-color: var(--bui-bg-surface-1); - font-family: var(--bui-font-regular); - font-size: var(--bui-font-size-3); - 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); - - /* Focus ring */ - &:focus-visible { - outline: 2px solid var(--bui-border-focus); - outline-offset: 2px; - } - } - - /* Extend hover area above each toast to fill gaps when expanded */ - /* This prevents the stack from collapsing when hovering between toasts */ - .bui-Toast::before { - content: ''; - position: absolute; - bottom: 100%; - left: 0; - right: 0; - /* Height matches the expanded gap (80px) to ensure continuous hover */ - height: 12px; - pointer-events: auto; - } - - /* Status variants - color icon and title only */ - .bui-Toast[data-status='info'] .bui-ToastIcon, - .bui-Toast[data-status='info'] .bui-ToastTitle { - color: var(--bui-fg-info); - } - - .bui-Toast[data-status='success'] .bui-ToastIcon, - .bui-Toast[data-status='success'] .bui-ToastTitle { - color: var(--bui-fg-success); - } - - .bui-Toast[data-status='warning'] .bui-ToastIcon, - .bui-Toast[data-status='warning'] .bui-ToastTitle { - color: var(--bui-fg-warning); - } - - .bui-Toast[data-status='danger'] .bui-ToastIcon, - .bui-Toast[data-status='danger'] .bui-ToastTitle { - color: var(--bui-fg-danger); - } - - .bui-ToastWrapper { - display: flex; - align-items: flex-start; - gap: var(--bui-space-3); - flex: 1; - min-width: 0; - } - - .bui-ToastContent { - display: flex; - flex-direction: column; - gap: var(--bui-space-1); - } - - /* Icon */ - .bui-ToastIcon { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - margin-top: 0.125rem; - - svg { - width: 1rem; - height: 1rem; - } - } - - /* Title */ - .bui-ToastTitle { - font-weight: var(--bui-font-weight-bold); - font-size: var(--bui-font-size-3); - word-wrap: break-word; - margin-top: var(--bui-space-0_5); - } - - /* Description */ - .bui-ToastDescription { - color: var(--bui-fg-secondary); - font-size: var(--bui-font-size-3); - opacity: 0.9; - word-wrap: break-word; - } - - /* Links */ - .bui-ToastLinks { - display: flex; - flex-wrap: wrap; - gap: var(--bui-space-3); - margin-top: var(--bui-space-1); - } - - /* Close Button */ - .bui-ToastCloseButton { - 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: var(--bui-radius-2); - background: transparent; - color: inherit; - cursor: pointer; - transition: background-color 0.15s ease; - color: var(--bui-fg-primary); - - svg { - width: 1rem; - height: 1rem; - } - - &:hover { - background-color: var(--bui-bg-neutral-on-surface-1-hover); - } - - &:focus-visible { - outline: 2px solid var(--bui-border-focus); - outline-offset: 1px; - } - - &:active { - background-color: var(--bui-bg-neutral-on-surface-1-pressed); - } - } -} diff --git a/packages/ui/src/components/Toast/Toast.stories.tsx b/packages/ui/src/components/Toast/Toast.stories.tsx deleted file mode 100644 index 5f82b9b123..0000000000 --- a/packages/ui/src/components/Toast/Toast.stories.tsx +++ /dev/null @@ -1,441 +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 { useState } from 'react'; -import preview from '../../../../../.storybook/preview'; -import { ToastContainer, toastQueue } from './index'; -import { Flex } from '../Flex'; -import { Button } from '../Button'; -import { MemoryRouter } from 'react-router-dom'; - -const meta = preview.meta({ - title: 'Backstage UI/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: () => ( - <> - - - - - - - - ), -}); - -export const ProgrammaticDismiss = meta.story({ - render: () => { - const [toastKey, setToastKey] = useState(null); - - return ( - <> - - - - ); - }, -}); - -export const QueueManagement = meta.story({ - render: () => ( - <> - - - - - - - - ), -}); - -export default meta; diff --git a/packages/ui/src/components/Toast/Toast.tsx b/packages/ui/src/components/Toast/Toast.tsx deleted file mode 100644 index 4f2aac1ffd..0000000000 --- a/packages/ui/src/components/Toast/Toast.tsx +++ /dev/null @@ -1,309 +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 { - forwardRef, - Ref, - isValidElement, - ReactElement, - useRef, - useLayoutEffect, - useState, -} from 'react'; -import { useToast } from '@react-aria/toast'; -import { useButton } from 'react-aria'; -import { motion } from 'motion/react'; -import { - RiInformationLine, - RiCheckLine, - RiErrorWarningLine, - RiAlertLine, - RiCloseLine, -} from '@remixicon/react'; -import type { ToastProps } from './types'; -import { useDefinition } from '../../hooks/useDefinition'; -import { ToastDefinition } from './definition'; -import { Link } from '../Link'; - -// 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. - * - * @example - * Basic usage with queue: - * ```tsx - * import { toastQueue } from '@backstage/ui'; - * - * toastQueue.add({ title: 'File saved successfully', status: 'success' }); - * ``` - * - * @example - * With description and auto-dismiss: - * ```tsx - * toastQueue.add( - * { - * title: 'Update available', - * description: 'A new version is ready to install.', - * status: 'info' - * }, - * { timeout: 5000 } - * ); - * ``` - * - * @internal - */ -export const Toast = forwardRef( - (props: ToastProps, ref: Ref) => { - const { ownProps, restProps, dataAttributes } = useDefinition( - ToastDefinition, - props, - ); - const { - classes, - toast, - state, - index = 0, - isExpanded = false, - onClose, - status, - icon, - expandedY: expandedYProp = 0, - collapsedHeight, - naturalHeight, - onHeightChange, - } = ownProps; - - // 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]); - - // Close button ref and props - const closeButtonRef = useRef(null); - const { buttonProps } = useButton( - { - ...closeButtonProps, - onPress: () => { - // Mark this toast as manually closed for exit animation - manuallyClosingToasts.add(toast.key); - onClose?.(); - state.close(toast.key); - }, - }, - closeButtonRef, - ); - - // 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