From 8abbe735195c45f4b59de6ee87e080975c6c4fe9 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 27 Jan 2026 08:57:02 +0000 Subject: [PATCH 01/40] Add new Toast component Signed-off-by: Charles de Dreuille --- .changeset/nine-islands-glow.md | 5 + .../src/app/components/toast/components.tsx | 308 ++++++++++++++ docs-ui/src/app/components/toast/page.mdx | 162 +++++++ .../app/components/toast/props-definition.ts | 51 +++ docs-ui/src/app/components/toast/snippets.ts | 321 ++++++++++++++ .../ui/src/components/Toast/Toast.module.css | 314 ++++++++++++++ .../ui/src/components/Toast/Toast.stories.tsx | 395 ++++++++++++++++++ packages/ui/src/components/Toast/Toast.tsx | 149 +++++++ .../ui/src/components/Toast/ToastRegion.tsx | 102 +++++ .../ui/src/components/Toast/definition.ts | 59 +++ packages/ui/src/components/Toast/index.ts | 21 + packages/ui/src/components/Toast/queue.ts | 45 ++ packages/ui/src/components/Toast/types.ts | 79 ++++ packages/ui/src/definitions.ts | 4 + packages/ui/src/index.ts | 1 + 15 files changed, 2016 insertions(+) create mode 100644 .changeset/nine-islands-glow.md create mode 100644 docs-ui/src/app/components/toast/components.tsx create mode 100644 docs-ui/src/app/components/toast/page.mdx create mode 100644 docs-ui/src/app/components/toast/props-definition.ts create mode 100644 docs-ui/src/app/components/toast/snippets.ts create mode 100644 packages/ui/src/components/Toast/Toast.module.css create mode 100644 packages/ui/src/components/Toast/Toast.stories.tsx create mode 100644 packages/ui/src/components/Toast/Toast.tsx create mode 100644 packages/ui/src/components/Toast/ToastRegion.tsx create mode 100644 packages/ui/src/components/Toast/definition.ts create mode 100644 packages/ui/src/components/Toast/index.ts create mode 100644 packages/ui/src/components/Toast/queue.ts create mode 100644 packages/ui/src/components/Toast/types.ts diff --git a/.changeset/nine-islands-glow.md b/.changeset/nine-islands-glow.md new file mode 100644 index 0000000000..3ac7bad909 --- /dev/null +++ b/.changeset/nine-islands-glow.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': minor +--- + +Added Toast component with React Aria integration. The Toast component displays brief, temporary notifications for actions, errors, or other events. It supports multiple status variants (info, success, warning, danger), flexible positioning, auto-dismiss with timeout, programmatic control, and deep stacking of multiple toasts. The component uses a global queue system for triggering toasts from anywhere in the application. diff --git a/docs-ui/src/app/components/toast/components.tsx b/docs-ui/src/app/components/toast/components.tsx new file mode 100644 index 0000000000..0a6179bc42 --- /dev/null +++ b/docs-ui/src/app/components/toast/components.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { useState } from 'react'; +import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui'; +import type { ToastContent } from '@backstage/ui'; + +export function Default() { + return ( + <> + + + + ); +} + +export function StatusVariants() { + return ( + <> + + + + + + + + + ); +} + +export function WithDescription() { + return ( + <> + + + + ); +} + +export function WithoutDescription() { + return ( + <> + + + + + + + ); +} + +export function Positions() { + const [currentPosition, setCurrentPosition] = useState< + 'top' | 'bottom' | null + >(null); + const [currentPlacement, setCurrentPlacement] = useState< + 'start' | 'center' | 'end' | null + >(null); + + const showToast = ( + position: 'top' | 'bottom', + placement: 'start' | 'center' | 'end', + ) => { + setCurrentPosition(position); + setCurrentPlacement(placement); + toastQueue.add({ + title: `${position} - ${placement}`, + description: `Toast positioned at ${position} ${placement}`, + status: 'info', + }); + }; + + return ( + <> + {currentPosition && currentPlacement && ( + + )} + +
+ Top Positions: + + + + + +
+
+ Bottom Positions: + + + + + +
+
+ + ); +} + +export function AutoDismiss() { + return ( + <> + + + + + + + ); +} + +export function ProgrammaticControl() { + const [toastKey, setToastKey] = useState(null); + + return ( + <> + + + + ); +} + +export function QueueManagement() { + return ( + <> + + + + + + + + ); +} diff --git a/docs-ui/src/app/components/toast/page.mdx b/docs-ui/src/app/components/toast/page.mdx new file mode 100644 index 0000000000..f23d4a752f --- /dev/null +++ b/docs-ui/src/app/components/toast/page.mdx @@ -0,0 +1,162 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { toastContentPropDefs, toastRegionPropDefs } from './props-definition'; +import { + toastUsageSnippet, + defaultSnippet, + statusVariantsSnippet, + withDescriptionSnippet, + withoutDescriptionSnippet, + positionsSnippet, + autoDismissSnippet, + programmaticControlSnippet, + queueManagementSnippet, +} from './snippets'; +import { + Default, + StatusVariants, + WithDescription, + WithoutDescription, + Positions, + AutoDismiss, + ProgrammaticControl, + QueueManagement, +} from './components'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { ToastDefinition, ToastRegionDefinition } from '../../../utils/definitions'; + + + +} code={defaultSnippet} /> + +## Status + +The Toast component uses React Aria's unstable Toast API, which is currently in **alpha status**. The API may change in future versions of react-aria-components. + +## Usage + +The Toast component uses a global queue system. Place the `ToastRegion` once in your app root, then trigger toasts from anywhere using the `queue` API. + + + +## API reference + +### ToastContent + +The content object passed to `queue.add()`: + + + +### ToastRegion + + + +## Examples + +### Status Variants + +The Toast component supports four status variants, each with its own color theme and icon. + +} + code={statusVariantsSnippet} +/> + +### With Description + +Add a description to provide additional context or details. + +} + code={withDescriptionSnippet} +/> + +### Without Description + +Toasts can display a title only for simpler notifications. + +} + code={withoutDescriptionSnippet} +/> + +### Positions and Placements + +Control where toasts appear on the screen with `position` (top/bottom) and `placement` (start/center/end). + +} + code={positionsSnippet} +/> + +### Auto Dismiss + +Toasts can automatically dismiss after a timeout. For accessibility, a minimum of 5 seconds is recommended. Timers automatically pause when users hover or focus on a toast. + +} + code={autoDismissSnippet} +/> + +### Programmatic Control + +You can programmatically dismiss toasts using the key returned from `queue.add()`. + +} + code={programmaticControlSnippet} +/> + +### Queue Management + +The toast queue supports multiple toasts with deep stacking. When multiple toasts are visible, they stack with the most recent toast fully visible and others slightly visible behind it with reduced opacity and scale. You can show multiple toasts at once or clear all toasts programmatically. + +} + code={queueManagementSnippet} +/> + +## Accessibility + +- **Landmark Region**: Toast regions are ARIA landmark regions that can be navigated using F6 (forward) and Shift+F6 (backward). +- **Focus Management**: When a toast is closed, focus moves to the next toast if any. When the last toast is closed, focus is restored. +- **Timer Pausing**: Timers automatically pause when users focus or hover over a toast. +- **Minimum Timeout**: For accessibility, toasts should have a minimum timeout of 5 seconds. +- **Manual Dismissal**: Always include a close button for users who need more time to read the content. + +## Theming + + + + + +## Changelog + + diff --git a/docs-ui/src/app/components/toast/props-definition.ts b/docs-ui/src/app/components/toast/props-definition.ts new file mode 100644 index 0000000000..351cb97362 --- /dev/null +++ b/docs-ui/src/app/components/toast/props-definition.ts @@ -0,0 +1,51 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; + +export const toastContentPropDefs: Record = { + title: { + type: 'enum', + values: ['React.ReactNode'], + responsive: false, + }, + description: { + type: 'enum', + values: ['React.ReactNode'], + responsive: false, + }, + status: { + type: 'enum', + values: ['info', 'success', 'warning', 'danger'], + responsive: false, + default: 'info', + }, + icon: { + type: 'enum', + values: ['boolean', 'React.ReactElement'], + responsive: false, + default: 'true', + }, +}; + +export const toastRegionPropDefs: Record = { + queue: { + type: 'enum', + values: ['ToastQueue'], + responsive: false, + }, + position: { + type: 'enum', + values: ['top', 'bottom'], + responsive: true, + default: 'bottom', + }, + placement: { + type: 'enum', + values: ['start', 'center', 'end'], + responsive: true, + default: 'end', + }, + ...classNamePropDefs, +}; diff --git a/docs-ui/src/app/components/toast/snippets.ts b/docs-ui/src/app/components/toast/snippets.ts new file mode 100644 index 0000000000..363b3d65b7 --- /dev/null +++ b/docs-ui/src/app/components/toast/snippets.ts @@ -0,0 +1,321 @@ +export const toastUsageSnippet = `import { ToastRegion, toastQueue } from '@backstage/ui'; + +// Place ToastRegion once in your app root +function App() { + return ( + <> + + + + ); +} + +// Trigger toasts from anywhere +toastQueue.add({ title: 'Success!', status: 'success' });`; + +export const defaultSnippet = `import { ToastRegion, toastQueue, Button } from '@backstage/ui'; + +export function Example() { + return ( + <> + + + + ); +}`; + +export const statusVariantsSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui'; + +export function Example() { + return ( + <> + + + + + + + + + ); +}`; + +export const withDescriptionSnippet = `import { ToastRegion, toastQueue, Button } from '@backstage/ui'; + +export function Example() { + return ( + <> + + + + ); +}`; + +export const withoutDescriptionSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui'; + +export function Example() { + return ( + <> + + + + + + + ); +}`; + +export const positionsSnippet = `import { useState } from 'react'; +import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui'; + +export function Example() { + const [currentPosition, setCurrentPosition] = useState<'top' | 'bottom' | null>(null); + const [currentPlacement, setCurrentPlacement] = useState<'start' | 'center' | 'end' | null>(null); + + const showToast = ( + position: 'top' | 'bottom', + placement: 'start' | 'center' | 'end' + ) => { + setCurrentPosition(position); + setCurrentPlacement(placement); + toastQueue.add({ + title: \`\${position} - \${placement}\`, + description: \`Toast positioned at \${position} \${placement}\`, + status: 'info', + }); + }; + + return ( + <> + {currentPosition && currentPlacement && ( + + )} + +
+ Top Positions: + + + + + +
+
+ Bottom Positions: + + + + + +
+
+ + ); +}`; + +export const autoDismissSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui'; + +export function Example() { + return ( + <> + + + + + + + ); +}`; + +export const programmaticControlSnippet = `import { useState } from 'react'; +import { ToastRegion, toastQueue, Button } from '@backstage/ui'; + +export function Example() { + const [toastKey, setToastKey] = useState(null); + + return ( + <> + + + + ); +}`; + +export const queueManagementSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui'; + +export function Example() { + return ( + <> + + + + + + + + ); +}`; diff --git a/packages/ui/src/components/Toast/Toast.module.css b/packages/ui/src/components/Toast/Toast.module.css new file mode 100644 index 0000000000..c2e07d6ff2 --- /dev/null +++ b/packages/ui/src/components/Toast/Toast.module.css @@ -0,0 +1,314 @@ +/* + * 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 Region - Container for all toasts */ + .bui-ToastRegion { + position: fixed; + inset-inline-start: 0; + inset-inline-end: 0; + z-index: 100050; /* Above modals */ + display: flex; + pointer-events: none; + outline: none; + margin-block-end: var(--bui-space-2); + margin-inline: var(--bui-space-2); + } + + /* Position: Top */ + .bui-ToastRegion[data-position='top'] { + top: 0; + flex-direction: column; + } + + /* Position: Bottom */ + .bui-ToastRegion[data-position='bottom'] { + bottom: 0; + flex-direction: column-reverse; + } + + /* Placement: Start */ + .bui-ToastRegion[data-placement='start'] { + align-items: flex-start; + } + + /* Placement: Center */ + .bui-ToastRegion[data-placement='center'] { + align-items: center; + } + + /* Placement: End */ + .bui-ToastRegion[data-placement='end'] { + align-items: flex-end; + } + + /* Individual Toast */ + .bui-Toast { + --toast-bg: var(--bui-bg-surface-1); + --toast-fg: var(--bui-fg-primary); + --toast-gap: 0.75rem; + --toast-peek: 1.25rem; + --toast-scale: calc(max(0, 1 - (var(--toast-index) * 0.05))); + --toast-shrink: calc(1 - var(--toast-scale)); + + position: relative; + display: flex; + align-items: flex-start; + gap: var(--bui-space-3); + min-width: 320px; + max-width: 480px; + margin: var(--bui-space-2); + padding: var(--bui-space-3); + border-radius: var(--bui-radius-3); + background-color: var(--toast-bg); + color: var(--toast-fg); + font-family: var(--bui-font-regular); + font-size: var(--bui-font-size-3); + line-height: 1.5; + box-shadow: var(--bui-shadow); + outline: none; + z-index: calc(1000 - var(--toast-index)); + pointer-events: auto; + transition: + transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.4s; + + /* Focus ring */ + &:focus-visible { + outline: 2px solid var(--bui-border-focus); + outline-offset: 2px; + } + } + + /* Bottom position stacking */ + .bui-ToastRegion[data-position='bottom'] .bui-Toast:not([data-entering]) { + transform: translateY( + calc( + (var(--toast-index) * var(--toast-peek) * -1) - + (var(--toast-shrink) * 100%) + ) + ) + scale(var(--toast-scale)); + transform-origin: bottom center; + } + + /* Top position stacking */ + .bui-ToastRegion[data-position='top'] .bui-Toast:not([data-entering]) { + transform: translateY( + calc( + (var(--toast-index) * var(--toast-peek)) + + (var(--toast-shrink) * 100%) + ) + ) + scale(var(--toast-scale)); + transform-origin: top center; + } + + /* Reduce opacity and disable interaction for stacked toasts */ + .bui-Toast { + opacity: calc(1 - (var(--toast-index) * 0.15)); + } + + /* Only first toast is interactive */ + .bui-ToastRegion li:not(:first-child) .bui-Toast { + pointer-events: none; + } + + /* List styling for toast container */ + .bui-ToastRegion ol { + display: inherit; + flex-direction: inherit; + align-items: inherit; + list-style-type: none; + margin: 0; + padding: 0; + position: relative; + } + + .bui-ToastRegion li { + display: block !important; + position: absolute; + width: 100%; + } + + /* Position list items based on toast region position */ + .bui-ToastRegion[data-position='bottom'] li { + bottom: 0; + } + + .bui-ToastRegion[data-position='top'] li { + top: 0; + } + + /* Status variants */ + .bui-Toast[data-status='info'] { + --toast-bg: var(--bui-bg-info); + --toast-fg: var(--bui-fg-info); + --toast-border: var(--bui-border-info); + } + + .bui-Toast[data-status='success'] { + --toast-bg: var(--bui-bg-success); + --toast-fg: var(--bui-fg-success); + --toast-border: var(--bui-border-success); + } + + .bui-Toast[data-status='warning'] { + --toast-bg: var(--bui-bg-warning); + --toast-fg: var(--bui-fg-warning); + --toast-border: var(--bui-border-warning); + } + + .bui-Toast[data-status='danger'] { + --toast-bg: var(--bui-bg-danger); + --toast-fg: var(--bui-fg-danger); + --toast-border: var(--bui-border-danger); + } + + /* Toast Content */ + .bui-ToastContent { + display: flex; + align-items: flex-start; + gap: var(--bui-space-3); + flex: 1; + min-width: 0; + } + + /* 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; + } + + /* Description */ + .bui-ToastDescription { + font-size: var(--bui-font-size-2); + opacity: 0.9; + margin-top: var(--bui-space-1); + word-wrap: break-word; + } + + /* 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: var(--toast-fg); + cursor: pointer; + transition: background-color 0.15s ease; + + svg { + width: 1rem; + height: 1rem; + } + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } + + &:focus-visible { + outline: 2px solid var(--bui-border-focus); + outline-offset: 1px; + } + + &:active { + background-color: rgba(0, 0, 0, 0.15); + } + } + + /* Animations */ + @keyframes bui-toast-slide-in-bottom { + from { + transform: translateY(calc(100% + 2rem)) scale(1); + opacity: 0; + } + } + + @keyframes bui-toast-slide-in-top { + from { + transform: translateY(calc(-100% - 2rem)) scale(1); + opacity: 0; + } + } + + @keyframes bui-toast-slide-out-bottom { + to { + transform: translateY(calc(100% + 2rem)); + opacity: 0; + } + } + + @keyframes bui-toast-slide-out-top { + to { + transform: translateY(calc(-100% - 2rem)); + opacity: 0; + } + } + + /* Apply animations based on position */ + .bui-ToastRegion[data-position='bottom'] .bui-Toast[data-entering] { + animation: bui-toast-slide-in-bottom 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + .bui-ToastRegion[data-position='bottom'] .bui-Toast[data-exiting] { + animation: bui-toast-slide-out-bottom 0.3s ease-in forwards; + } + + .bui-ToastRegion[data-position='top'] .bui-Toast[data-entering] { + animation: bui-toast-slide-in-top 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + .bui-ToastRegion[data-position='top'] .bui-Toast[data-exiting] { + animation: bui-toast-slide-out-top 0.3s ease-in forwards; + } + + /* Reduced motion */ + @media (prefers-reduced-motion: reduce) { + .bui-Toast { + transition: none; + } + + .bui-Toast[data-entering], + .bui-Toast[data-exiting] { + animation: none; + } + } +} diff --git a/packages/ui/src/components/Toast/Toast.stories.tsx b/packages/ui/src/components/Toast/Toast.stories.tsx new file mode 100644 index 0000000000..99496e064c --- /dev/null +++ b/packages/ui/src/components/Toast/Toast.stories.tsx @@ -0,0 +1,395 @@ +/* + * 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 { ToastRegion } from './ToastRegion'; +import { toastQueue } from './queue'; +import { Flex } from '../Flex'; +import { Button } from '../Button'; + +const meta = preview.meta({ + title: 'Backstage UI/Toast', + component: ToastRegion, + argTypes: { + position: { + control: 'select', + options: ['top', 'bottom'], + }, + placement: { + control: 'select', + options: ['start', 'center', 'end'], + }, + }, +}); + +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 Positions = meta.story({ + render: () => ( + <> + + + + + + ), +}); + +export const AllPositions = meta.story({ + render: () => { + const [currentPosition, setCurrentPosition] = useState< + 'top' | 'bottom' | null + >(null); + const [currentPlacement, setCurrentPlacement] = useState< + 'start' | 'center' | 'end' | null + >(null); + + const showToast = ( + position: 'top' | 'bottom', + placement: 'start' | 'center' | 'end', + ) => { + setCurrentPosition(position); + setCurrentPlacement(placement); + toastQueue.add({ + title: `${position} - ${placement}`, + description: `Toast positioned at ${position} ${placement}`, + status: 'info', + }); + }; + + return ( + <> + {currentPosition && currentPlacement && ( + + )} + +
+ Top Positions: + + + + + +
+
+ Bottom Positions: + + + + + +
+
+ + ); + }, +}); + +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 new file mode 100644 index 0000000000..221a66c468 --- /dev/null +++ b/packages/ui/src/components/Toast/Toast.tsx @@ -0,0 +1,149 @@ +/* + * 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 } from 'react'; +import { + UNSTABLE_Toast as RAToast, + UNSTABLE_ToastContent as RAToastContent, + Text, + Button as RAButton, +} from 'react-aria-components'; +import { + RiInformationLine, + RiCheckLine, + RiErrorWarningLine, + RiAlertLine, + RiCloseLine, +} from '@remixicon/react'; +import type { ToastProps } from './types'; +import { useDefinition } from '../../hooks/useDefinition'; +import { ToastDefinition } from './definition'; + +/** + * A Toast displays a brief, temporary notification of actions, errors, or other events in an application. + * + * @remarks + * The Toast component is typically used within a ToastRegion 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. + * + * This component uses React Aria's unstable Toast API which is currently in alpha. + * + * @example + * Basic usage with queue: + * ```tsx + * import { queue } from '@backstage/ui'; + * + * queue.add({ title: 'File saved successfully', status: 'success' }); + * ``` + * + * @example + * With description and auto-dismiss: + * ```tsx + * queue.add( + * { + * title: 'Update available', + * description: 'A new version is ready to install.', + * status: 'info' + * }, + * { timeout: 5000 } + * ); + * ``` + * + * @public + */ +export const Toast = forwardRef( + (props: ToastProps, ref: Ref) => { + const { ownProps, restProps, dataAttributes } = useDefinition( + ToastDefinition, + props, + ); + const { classes, toast, index = 0, status, icon } = ownProps; + + // 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