Improve toast layout

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-05 22:10:57 +00:00
committed by Patrik Oldsberg
parent eea95b8ae2
commit e0b7eb0b64
11 changed files with 121 additions and 120 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Fixed --bui-fg-success token in light mode to be more accessible.
+1 -1
View File
@@ -14,7 +14,7 @@ Deprecated `AlertApi` in favor of the new `ToastApi`.
- **Title and Description**: Display a prominent title with optional description text
- **Action Links**: Include clickable links within notifications
- **Custom Icons**: Override default icons or disable them entirely
- **Status Variants**: Support for neutral, info, success, warning, and danger statuses
- **Per-toast Timeout**: Control auto-dismiss timing for each notification individually
- **Programmatic Dismiss**: Close notifications via the key returned from `post()`
+1 -1
View File
@@ -10,7 +10,7 @@ The new `ToastApi` provides enhanced notification capabilities compared to the e
- **Title and Description**: Toasts support both a title and an optional description
- **Custom Timeouts**: Each toast can specify its own timeout duration
- **Links**: Toasts can include action links
- **Icons**: Support for custom icons or disabling the default icon
- **Status Variants**: Support for neutral, info, success, warning, and danger statuses
- **Programmatic Dismiss**: Toasts can be dismissed programmatically using the key returned from `post()`
**Usage:**
+1 -3
View File
@@ -19,7 +19,6 @@ import { JSX as JSX_2 } from 'react/jsx-runtime';
import { JSX as JSX_3 } from 'react';
import { Observable } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { SwappableComponentRef as SwappableComponentRef_2 } from '@backstage/frontend-plugin-api';
import type { z } from 'zod';
@@ -1957,8 +1956,7 @@ export type ToastLink = {
export type ToastMessage = {
title: ReactNode;
description?: ReactNode;
status?: 'info' | 'success' | 'warning' | 'danger';
icon?: boolean | ReactElement;
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
links?: ToastLink[];
timeout?: number;
};
@@ -16,7 +16,7 @@
import { createApiRef, ApiRef } from '../system';
import { Observable } from '@backstage/types';
import { ReactNode, ReactElement } from 'react';
import { ReactNode } from 'react';
/**
* Link item for toast notifications.
@@ -41,9 +41,7 @@ export type ToastMessage = {
/** 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;
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
/** Optional array of links to display */
links?: ToastLink[];
/** Timeout in milliseconds before auto-dismiss. If not set, toast is permanent. */
@@ -65,7 +63,7 @@ export type ToastMessageWithKey = ToastMessage & {
*
* @remarks
* This API provides richer notification capabilities than the AlertApi,
* including title/description, custom icons, links, and per-toast timeout control.
* including title/description, links, and per-toast timeout control.
*
* @example
* ```tsx
+1 -3
View File
@@ -19,7 +19,6 @@ import { JSX as JSX_3 } from 'react/jsx-runtime';
import { NavContentComponent } from '@backstage/plugin-app-react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
import type { ReactElement } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SignInPageProps } from '@backstage/plugin-app-react';
@@ -1024,9 +1023,8 @@ export default appPlugin;
// @public
export interface ToastContent {
description?: ReactNode;
icon?: boolean | ReactElement;
links?: ToastLink[];
status?: 'info' | 'success' | 'warning' | 'danger';
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
title: ReactNode;
}
+37 -46
View File
@@ -47,16 +47,14 @@
box-sizing: border-box;
display: flex;
align-items: flex-start;
padding: 1rem;
gap: 0.75rem;
padding: var(--bui-space-4);
gap: var(--bui-space-3);
/* 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;
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;
@@ -68,18 +66,17 @@
transform-origin: bottom center;
/* Shadow */
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.4);
}
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);
/* Focus ring */
&:focus-visible {
outline: 2px solid var(--bui-border-focus);
outline-offset: 2px;
}
}
.toast:focus-visible {
outline: 2px solid #6366f1;
outline: 2px solid var(--bui-border-focus);
outline-offset: 2px;
}
@@ -97,30 +94,34 @@
}
/* Status variants - color icon and title */
.toast[data-status='neutral'] .toast-title {
color: var(--bui-fg-primary);
}
.toast[data-status='info'] .toast-icon,
.toast[data-status='info'] .toast-title {
color: #3b82f6;
color: var(--bui-fg-info);
}
.toast[data-status='success'] .toast-icon,
.toast[data-status='success'] .toast-title {
color: #22c55e;
color: var(--bui-fg-success);
}
.toast[data-status='warning'] .toast-icon,
.toast[data-status='warning'] .toast-title {
color: #f59e0b;
color: var(--bui-fg-warning);
}
.toast[data-status='danger'] .toast-icon,
.toast[data-status='danger'] .toast-title {
color: #ef4444;
color: var(--bui-fg-danger);
}
.toast-wrapper {
display: flex;
align-items: flex-start;
gap: 0.75rem;
gap: var(--bui-space-3);
flex: 1;
min-width: 0;
}
@@ -128,7 +129,7 @@
.toast-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--bui-space-1);
}
/* Icon */
@@ -137,7 +138,7 @@
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.125rem;
margin-top: var(--bui-space-0_5);
}
.toast-icon svg {
@@ -147,29 +148,26 @@
/* Title */
.toast-title {
font-weight: 600;
font-size: 0.875rem;
font-weight: var(--bui-font-weight-bold);
font-size: var(--bui-font-size-3);
word-wrap: break-word;
margin-top: 0.125rem;
margin-top: var(--bui-space-0_5);
}
/* Description */
.toast-description {
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-3);
opacity: 0.9;
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;
gap: var(--bui-space-3);
margin-top: var(--bui-space-1);
}
.toast-links a {
@@ -205,11 +203,12 @@
margin: -0.25rem -0.25rem 0 0;
padding: 0;
border: none;
border-radius: 0.25rem;
border-radius: var(--bui-radius-2);
background: transparent;
color: inherit;
cursor: pointer;
transition: background-color 0.15s ease;
color: var(--bui-fg-primary);
}
.toast-close-button svg {
@@ -218,22 +217,14 @@
}
.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);
background-color: var(--bui-bg-neutral-on-surface-1-hover);
}
.toast-close-button:focus-visible {
outline: 2px solid #6366f1;
outline: 2px solid var(--bui-border-focus);
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);
background-color: var(--bui-bg-neutral-on-surface-1-pressed);
}
@@ -35,10 +35,12 @@ const randomToasts = [
{ title: 'Error', status: 'danger' as const },
{ title: 'New notification', status: 'info' as const },
{ title: 'Warning', status: 'warning' as const },
{ title: 'Task completed', status: 'neutral' 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: 'Background sync in progress', status: 'neutral' as const },
// Title + short description
{
title: 'Files uploaded',
@@ -60,6 +62,11 @@ const randomToasts = [
description: '90% used.',
status: 'warning' as const,
},
{
title: 'Clipboard updated',
description: 'Text copied.',
status: 'neutral' as const,
},
// Title + medium description
{
title: 'Deployment complete',
@@ -78,6 +85,11 @@ const randomToasts = [
description: 'You do not have access to perform this action.',
status: 'danger' as const,
},
{
title: 'Preferences updated',
description: 'Your display settings have been saved to your profile.',
status: 'neutral' as const,
},
// Title + long description
{
title: 'Sync completed',
@@ -106,6 +118,10 @@ const randomToasts = [
title: 'Multiple users are currently editing this document',
status: 'info' as const,
},
{
title: 'Your workspace has been switched to the new project',
status: 'neutral' as const,
},
];
export const Default = meta.story({
@@ -140,6 +156,17 @@ export const StatusVariants = meta.story({
<>
<ToastContainer queue={toastQueue} />
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
title: 'Neutral message',
description: 'A simple notification without emphasis.',
status: 'neutral',
})
}
>
Neutral Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
@@ -219,7 +246,7 @@ export const WithoutDescription = meta.story({
),
});
export const WithoutIcons = meta.story({
export const NeutralStatus = meta.story({
render: () => (
<>
<ToastContainer queue={toastQueue} />
@@ -227,24 +254,23 @@ export const WithoutIcons = meta.story({
<Button
onPress={() =>
toastQueue.add({
title: 'Toast without icon',
description: 'This toast has no icon displayed.',
icon: false,
title: 'Neutral toast',
description: 'This toast has no icon and uses the primary color.',
status: 'neutral',
})
}
>
No Icon
Neutral
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Success without icon',
status: 'success',
icon: false,
title: 'Simple neutral message',
status: 'neutral',
})
}
>
Success No Icon
Neutral Simple
</Button>
</Flex>
</>
@@ -426,9 +452,9 @@ export const QueueManagement = meta.story({
toastQueue.add({
title: `Toast #${i}`,
description: `This is toast number ${i}.`,
status: ['info', 'success', 'warning', 'danger'][
(i - 1) % 4
] as 'info' | 'success' | 'warning' | 'danger',
status: ['neutral', 'info', 'success', 'warning', 'danger'][
(i - 1) % 5
] as 'neutral' | 'info' | 'success' | 'warning' | 'danger',
});
}, i * 200);
}
@@ -463,8 +489,21 @@ export const AlertApiIntegration = meta.story({
<Text variant="body-small">info info (blue)</Text>
<Text variant="body-small">warning warning (orange)</Text>
<Text variant="body-small">error danger (red)</Text>
<Text variant="body-small">
(ToastApi also supports neutral - no icon, primary color)
</Text>
</Flex>
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
title: 'Workspace switched',
status: 'neutral',
})
}
>
Neutral Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
+18 -41
View File
@@ -14,15 +14,7 @@
* limitations under the License.
*/
import {
forwardRef,
Ref,
isValidElement,
ReactElement,
useRef,
useLayoutEffect,
useState,
} from 'react';
import { forwardRef, Ref, useRef, useLayoutEffect, useState } from 'react';
import { useToast } from '@react-aria/toast';
import { useButton } from '@react-aria/button';
import { motion } from 'motion/react';
@@ -44,8 +36,8 @@ const manuallyClosingToasts = new Set<string>();
*
* @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.
* It supports multiple status variants (neutral, info, success, warning, danger) and can display
* a title and description. Toasts can be dismissed manually or automatically.
*
* @internal
*/
@@ -58,7 +50,6 @@ export const Toast = forwardRef(
isExpanded = false,
onClose,
status,
icon,
expandedY: expandedYProp = 0,
collapsedHeight,
naturalHeight,
@@ -134,37 +125,23 @@ export const Toast = forwardRef(
// 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;
// Determine which icon to render based on status
const getStatusIcon = () => {
switch (finalStatus) {
case 'neutral':
// Neutral status has no icon
return null;
case 'success':
return <RiCheckLine aria-hidden="true" />;
case 'warning':
return <RiErrorWarningLine aria-hidden="true" />;
case 'danger':
return <RiAlertLine aria-hidden="true" />;
case 'info':
default:
return <RiInformationLine aria-hidden="true" />;
}
// 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 <RiCheckLine aria-hidden="true" />;
case 'warning':
return <RiErrorWarningLine aria-hidden="true" />;
case 'danger':
return <RiAlertLine aria-hidden="true" />;
case 'info':
default:
return <RiInformationLine aria-hidden="true" />;
}
}
// Default: no icon
return null;
};
const statusIcon = getStatusIcon();
@@ -95,7 +95,6 @@ export function ToastDisplay(props: ToastDisplayProps) {
title: toast.title,
description: toast.description,
status: toast.status ?? 'success',
icon: toast.icon,
links: toast.links,
};
+3 -7
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { ReactElement, ReactNode } from 'react';
import type { ReactNode } from 'react';
import type { ToastQueue, ToastState, QueuedToast } from 'react-stately';
/**
@@ -38,9 +38,7 @@ export interface ToastContent {
/** Optional description text */
description?: ReactNode;
/** Status variant of the toast */
status?: 'info' | 'success' | 'warning' | 'danger';
/** Whether to show an icon */
icon?: boolean | ReactElement;
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
/** Optional array of links to display */
links?: ToastLink[];
}
@@ -61,9 +59,7 @@ export interface ToastProps {
/** Callback when toast is closed */
onClose?: () => void;
/** Override status from content */
status?: 'info' | 'success' | 'warning' | 'danger';
/** Override icon from content */
icon?: boolean | ReactElement;
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
/** Pre-calculated Y position when expanded (based on heights of toasts below) */
expandedY?: number;
/** Height to use when collapsed (front toast's height, for uniform stacking) */