diff --git a/packages/ui/package.json b/packages/ui/package.json index a253de0532..00db397601 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -51,7 +51,9 @@ "@remixicon/react": "^4.6.0", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", - "react-aria-components": "^1.14.0" + "motion": "^12.0.0", + "react-aria-components": "^1.14.0", + "react-stately": "^3.35.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 index 7f3bbef42f..c4024a8fc4 100644 --- a/packages/ui/src/components/Toast/Toast.module.css +++ b/packages/ui/src/components/Toast/Toast.module.css @@ -66,19 +66,10 @@ /* Stacking */ z-index: calc(1000 - var(--toast-index)); transform-origin: bottom center; - transform: translateY(calc(var(--toast-index) * var(--toast-peek) * -1)) - scale(var(--toast-scale)); - will-change: transform; - /* Shadow + thin line separator */ + /* Shadow */ box-shadow: 0 4px 12px -2px rgba(0 0 0 / 0.4); - /* Animation */ - transition: transform 3s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 3s ease; - - /* View Transitions - name is set dynamically via inline style, class groups them */ - view-transition-class: toast; - /* Focus ring */ &:focus-visible { outline: 2px solid var(--bui-border-focus); @@ -90,13 +81,6 @@ .bui-ToastRegion:hover .bui-Toast, .bui-ToastRegion:focus-within .bui-Toast, .bui-ToastRegion[data-hover-locked] .bui-Toast { - transform: translateY( - calc( - (var(--toast-index) * -100%) - - (var(--toast-index) * var(--bui-space-2)) - ) - ) - scale(1); pointer-events: auto; } @@ -213,53 +197,4 @@ background-color: var(--bui-bg-neutral-on-surface-1-pressed); } } - - /* Starting state - toast entering (fallback for non-view-transition browsers) */ - .bui-Toast[data-starting-style] { - transform: translateY(calc(100% + 2rem)) scale(1) !important; - opacity: 0 !important; - } -} - -/* View Transition animations - must be outside @layer for browser support */ -@keyframes bui-toast-enter { - from { - opacity: 0; - transform: translateY(100px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes bui-toast-exit { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(100px); - } -} - -/* Apply exit animation only to the closing toast */ -::view-transition-old(toast-exit) { - animation: bui-toast-exit 3s ease-out forwards; -} - -::view-transition-new(toast-exit) { - animation: none; -} - -/* Reduced motion */ -@media (prefers-reduced-motion: reduce) { - .bui-Toast { - transition: none; - } - - ::view-transition-old(toast-exit) { - animation: none !important; - } } diff --git a/packages/ui/src/components/Toast/Toast.tsx b/packages/ui/src/components/Toast/Toast.tsx index 9b606ade32..d80292b7a9 100644 --- a/packages/ui/src/components/Toast/Toast.tsx +++ b/packages/ui/src/components/Toast/Toast.tsx @@ -14,20 +14,10 @@ * limitations under the License. */ -import { - forwardRef, - Ref, - isValidElement, - ReactElement, - useContext, - useState, - useEffect, -} from 'react'; -import { - UNSTABLE_Toast as RAToast, - UNSTABLE_ToastStateContext, - Button as RAButton, -} from 'react-aria-components'; +import { forwardRef, Ref, isValidElement, ReactElement, useRef } from 'react'; +import { useToast } from '@react-aria/toast'; +import { useButton } from 'react-aria'; +import { motion } from 'motion/react'; import { RiInformationLine, RiCheckLine, @@ -39,6 +29,10 @@ import type { ToastProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; import { ToastDefinition } from './definition'; +// 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. * @@ -47,8 +41,6 @@ import { ToastDefinition } from './definition'; * 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. * - * This component uses React Aria's unstable Toast API which is currently in alpha. - * * @example * Basic usage with queue: * ```tsx @@ -78,24 +70,53 @@ export const Toast = forwardRef( ToastDefinition, props, ); - const { classes, toast, onClose, status, icon } = ownProps; + const { + classes, + toast, + state, + index = 0, + onClose, + status, + icon, + } = ownProps; - // Get state from context - const state = useContext(UNSTABLE_ToastStateContext); + // Use internal ref if none provided + const internalRef = useRef(null); + const toastRef = (ref as React.RefObject) || internalRef; - // Calculate index from state - const visibleToasts = state?.visibleToasts || []; - const arrayIndex = visibleToasts.findIndex(t => t.key === toast.key); - const index = arrayIndex >= 0 ? arrayIndex : 0; + // Get ARIA props from useToast hook + const { toastProps, titleProps, closeButtonProps } = useToast( + { toast }, + state, + toastRef, + ); - // Track starting state for enter animation - const [isStarting, setIsStarting] = useState(true); + // 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'], + }; - useEffect(() => { - // Remove starting state after brief delay to trigger animation - const timer = setTimeout(() => setIsStarting(false), 50); - return () => clearTimeout(timer); - }, []); + // 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; @@ -135,42 +156,71 @@ export const Toast = forwardRef( const statusIcon = getStatusIcon(); + // Calculate stacking values based on index + // Each toast behind scales down 5% and moves up 12px + const stackScale = Math.max(0, 1 - index * 0.05); + const stackY = -index * 12; + const stackZIndex = 1000 - index; + + // Check if this toast is being manually closed + const isManualClose = manuallyClosingToasts.has(toast.key); + + // Different exit animations for manual close vs auto-timeout + // Manual close: slide down from front, stay on top + // Auto-timeout: fade out in place, stay in stack position + const exitAnimation = isManualClose + ? { opacity: 0, y: 100, scale: 1, zIndex: 2000 } + : { opacity: 0, y: stackY + 50, scale: stackScale, zIndex: stackZIndex }; + return ( - { + // Clean up the manual close tracking after exit animation + if (definition === 'exit') { + manuallyClosingToasts.delete(toast.key); + } + }} + transition={{ type: 'tween', duration: 2, ease: 'easeOut' }} {...dataAttributes} - data-toast-key={toast.key} data-status={finalStatus} - data-starting-style={isStarting ? '' : undefined} {...restProps} >
{statusIcon &&
{statusIcon}
}
-
{content.title}
+
+ {content.title} +
{content.description && (
{content.description}
)}
- { - onClose?.(); - state?.close(toast.key); - }} > -
+ + ); }, ); diff --git a/packages/ui/src/components/Toast/ToastQueue.ts b/packages/ui/src/components/Toast/ToastQueue.ts index 87df3ae8eb..e02dfc114b 100644 --- a/packages/ui/src/components/Toast/ToastQueue.ts +++ b/packages/ui/src/components/Toast/ToastQueue.ts @@ -14,18 +14,15 @@ * limitations under the License. */ -import { UNSTABLE_ToastQueue as RAToastQueue } from 'react-aria-components'; -import { flushSync } from 'react-dom'; +import { ToastQueue } from 'react-stately'; import type { ToastContent } from './types'; /** * Global toast queue for displaying toast notifications throughout the application. * * @remarks - * This uses React Aria's unstable Toast API which is currently in alpha. - * The API may change in future versions. - * - * Uses the View Transitions API for smooth enter/exit animations when supported. + * This uses React Stately's ToastQueue for state management with motion/react + * for smooth enter/exit animations. * * @example * ```tsx @@ -45,41 +42,6 @@ import type { ToastContent } from './types'; * * @public */ -// Track which toast element is being closed for view transition -let closingToastElement: HTMLElement | null = null; - -export const toastQueue = new RAToastQueue({ +export const toastQueue = new ToastQueue({ maxVisibleToasts: 5, - // Wrap state updates in a CSS view transition for smooth animations - wrapUpdate(fn) { - if (closingToastElement && 'startViewTransition' in document) { - // Set view-transition-name on the element BEFORE capturing old state - closingToastElement.style.viewTransitionName = 'toast-exit'; - const element = closingToastElement; - closingToastElement = null; - - ( - document as Document & { - startViewTransition: (cb: () => void) => void; - } - ).startViewTransition(() => { - flushSync(fn); - // Clean up (element may be removed, but just in case) - element.style.viewTransitionName = ''; - }); - } else { - fn(); - } - }, }); - -// Override close to capture the toast element before transition -const originalClose = toastQueue.close.bind(toastQueue); -toastQueue.close = (key: string) => { - // Find the toast element by our custom data attribute - const toastElement = document.querySelector( - `[data-toast-key="${key}"]`, - ) as HTMLElement | null; - closingToastElement = toastElement; - originalClose(key); -}; diff --git a/packages/ui/src/components/Toast/ToastRegion.tsx b/packages/ui/src/components/Toast/ToastRegion.tsx index e876ef46bd..1c8e65d10b 100644 --- a/packages/ui/src/components/Toast/ToastRegion.tsx +++ b/packages/ui/src/components/Toast/ToastRegion.tsx @@ -15,7 +15,9 @@ */ import { forwardRef, Ref, useState, useRef } from 'react'; -import { UNSTABLE_ToastRegion as RAToastRegion } from 'react-aria-components'; +import { useToastRegion } from '@react-aria/toast'; +import { useToastQueue } from 'react-stately'; +import { AnimatePresence } from 'motion/react'; import type { ToastRegionProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode'; @@ -32,8 +34,6 @@ import { Toast } from './Toast'; * Toast regions are ARIA landmark regions that can be navigated using F6 (forward) and * Shift+F6 (backward) for keyboard accessibility. * - * This component uses React Aria's unstable Toast API which is currently in alpha. - * * @example * Basic setup in app root: * ```tsx @@ -59,6 +59,16 @@ export const ToastRegion = forwardRef( ); const { classes, queue, className } = ownProps; + // Subscribe to the toast queue state + const state = useToastQueue(queue); + + // Use internal ref if none provided + const internalRef = useRef(null); + const regionRef = (ref as React.RefObject) || internalRef; + + // Get ARIA props for the toast region + const { regionProps } = useToastRegion({}, state, regionRef); + // Lock hover state after close to prevent stack collapse during DOM updates const [isHoverLocked, setIsHoverLocked] = useState(false); const unlockTimerRef = useRef | null>(null); @@ -82,18 +92,27 @@ export const ToastRegion = forwardRef( }; return ( - - {({ toast }) => } - + + {state.visibleToasts.map((toast, index) => ( + + ))} + + ); }, ); diff --git a/packages/ui/src/components/Toast/definition.ts b/packages/ui/src/components/Toast/definition.ts index 7cfc6e27cb..fd3158f7a4 100644 --- a/packages/ui/src/components/Toast/definition.ts +++ b/packages/ui/src/components/Toast/definition.ts @@ -35,6 +35,8 @@ export const ToastDefinition = defineComponent()({ surface: 'container', propDefs: { toast: {}, + state: {}, + index: {}, onClose: {}, status: { dataAttribute: true }, icon: {}, diff --git a/packages/ui/src/components/Toast/types.ts b/packages/ui/src/components/Toast/types.ts index 91255e705e..d6b4975810 100644 --- a/packages/ui/src/components/Toast/types.ts +++ b/packages/ui/src/components/Toast/types.ts @@ -15,8 +15,7 @@ */ import type { ReactElement, ReactNode } from 'react'; -import type { UNSTABLE_ToastQueue as RAToastQueue } from 'react-aria-components'; -import type { QueuedToast } from 'react-stately'; +import type { ToastQueue, ToastState, QueuedToast } from 'react-stately'; import type { Responsive, ContainerSurfaceProps } from '../../types'; /** @@ -41,6 +40,10 @@ export interface ToastContent { export type ToastOwnProps = ContainerSurfaceProps & { /** Toast object from the queue */ toast: QueuedToast; + /** Toast state from useToastQueue */ + state: ToastState; + /** Index of the toast in the visible toasts array */ + index?: number; /** Callback when toast is closed */ onClose?: () => void; /** Override status from content */ @@ -61,7 +64,7 @@ export interface ToastProps extends ToastOwnProps {} */ export type ToastRegionOwnProps = { /** Toast queue instance */ - queue: RAToastQueue; + queue: ToastQueue; /** Custom class name */ className?: string; }; diff --git a/yarn.lock b/yarn.lock index 1ce7bc74dc..7b6ca21183 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8123,10 +8123,12 @@ __metadata: eslint-plugin-storybook: "npm:^10.3.0-alpha.1" glob: "npm:^11.0.1" globals: "npm:^15.11.0" + motion: "npm:^12.0.0" react: "npm:^18.0.2" react-aria-components: "npm:^1.14.0" react-dom: "npm:^18.0.2" react-router-dom: "npm:^6.30.2" + react-stately: "npm:^3.35.0" storybook: "npm:^10.3.0-alpha.1" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 @@ -25778,7 +25780,18 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.0.0, axios@npm:^1.11.0, axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.13.0, axios@npm:^1.7.4": +"axios@npm:^1.0.0, axios@npm:^1.11.0, axios@npm:^1.12.2, axios@npm:^1.13.0, axios@npm:^1.7.4": + version: 1.13.4 + resolution: "axios@npm:1.13.4" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.4" + proxy-from-env: "npm:^1.1.0" + checksum: 10/54b7ef71c64837f9d52475832337f520cf6fa85c94612e03a3a2aad7082804a2544741267122696662147e90e6d2746601346984cf531ae715ecdb56d586a04c + languageName: node + linkType: hard + +"axios@npm:^1.12.0": version: 1.13.5 resolution: "axios@npm:1.13.5" dependencies: @@ -32468,6 +32481,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.31.0": + version: 12.31.0 + resolution: "framer-motion@npm:12.31.0" + dependencies: + motion-dom: "npm:^12.30.1" + motion-utils: "npm:^12.29.2" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10/8cd76953b5e4e81e69b7bbec699cd5c913df87897148cd0f9fe85fa2f1c4e2768fbaeb6e40be9cf6d3f4b17a5a8024351d7e0ba93a9cdf9d8358c5031c5cdecd + languageName: node + linkType: hard + "framer-motion@npm:^6.5.1": version: 6.5.1 resolution: "framer-motion@npm:6.5.1" @@ -39881,6 +39916,43 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.30.1": + version: 12.30.1 + resolution: "motion-dom@npm:12.30.1" + dependencies: + motion-utils: "npm:^12.29.2" + checksum: 10/22af7e074b388485b8f383bc9a8a3d03dec51f2e7112a3b50b9fde7076531ec0fccbeb61c669a439eb0a535992b121a48e612901b79f139538697755c291400e + languageName: node + linkType: hard + +"motion-utils@npm:^12.29.2": + version: 12.29.2 + resolution: "motion-utils@npm:12.29.2" + checksum: 10/ae5f9be58c07939af72334894ed1a18653d724946182a718dc3d11268ef26e63804c3f16dee5a6110596d4406b539c4513822b74f86adebef9488601c34b18b7 + languageName: node + linkType: hard + +"motion@npm:^12.0.0": + version: 12.31.0 + resolution: "motion@npm:12.31.0" + dependencies: + framer-motion: "npm:^12.31.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10/abb2253b2e679f3d153e472f63e29fa0f3d9d4dd6bb2f2b0c9026897dd069a2371ddc2374bf70debdd480351ad5311e830ebf98e5ea7a44588e44099381e950b + languageName: node + linkType: hard + "mri@npm:1.1.4": version: 1.1.4 resolution: "mri@npm:1.1.4" @@ -44699,7 +44771,7 @@ __metadata: languageName: node linkType: hard -"react-stately@npm:^3.43.0": +"react-stately@npm:^3.35.0, react-stately@npm:^3.43.0": version: 3.43.0 resolution: "react-stately@npm:3.43.0" dependencies: