Improve toast layout
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
eea95b8ae2
commit
e0b7eb0b64
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
Fixed --bui-fg-success token in light mode to be more accessible.
|
||||
@@ -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()`
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user