Cleanup Toast after feedbacks

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-09 11:30:08 +00:00
committed by Patrik Oldsberg
parent aa1d73a798
commit 033ec31a33
8 changed files with 51 additions and 100 deletions
@@ -15,7 +15,7 @@
*/
/* Toast Container - Container for all toasts (bottom center) */
.toast-container {
.container {
position: fixed;
bottom: 1rem;
left: 50%;
@@ -26,7 +26,7 @@
}
@media (min-width: 500px) {
.toast-container {
.container {
width: 350px;
bottom: 2rem;
}
@@ -67,12 +67,6 @@
/* 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;
}
}
.toast:focus-visible {
@@ -94,31 +88,31 @@
}
/* Status variants - color icon and title */
.toast[data-status='neutral'] .toast-title {
.toast[data-status='neutral'] .title {
color: var(--bui-fg-primary);
}
.toast[data-status='info'] .toast-icon,
.toast[data-status='info'] .toast-title {
.toast[data-status='info'] .icon,
.toast[data-status='info'] .title {
color: var(--bui-fg-info);
}
.toast[data-status='success'] .toast-icon,
.toast[data-status='success'] .toast-title {
.toast[data-status='success'] .icon,
.toast[data-status='success'] .title {
color: var(--bui-fg-success);
}
.toast[data-status='warning'] .toast-icon,
.toast[data-status='warning'] .toast-title {
.toast[data-status='warning'] .icon,
.toast[data-status='warning'] .title {
color: var(--bui-fg-warning);
}
.toast[data-status='danger'] .toast-icon,
.toast[data-status='danger'] .toast-title {
.toast[data-status='danger'] .icon,
.toast[data-status='danger'] .title {
color: var(--bui-fg-danger);
}
.toast-wrapper {
.wrapper {
display: flex;
align-items: flex-start;
gap: var(--bui-space-3);
@@ -126,14 +120,14 @@
min-width: 0;
}
.toast-content {
.content {
display: flex;
flex-direction: column;
gap: var(--bui-space-1);
}
/* Icon */
.toast-icon {
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
@@ -141,13 +135,13 @@
margin-top: var(--bui-space-0_5);
}
.toast-icon svg {
.icon svg {
width: 1rem;
height: 1rem;
}
/* Title */
.toast-title {
.title {
font-weight: var(--bui-font-weight-bold);
font-size: var(--bui-font-size-3);
word-wrap: break-word;
@@ -155,7 +149,7 @@
}
/* Description */
.toast-description {
.description {
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-3);
opacity: 0.9;
@@ -163,14 +157,14 @@
}
/* Links */
.toast-links {
.links {
display: flex;
flex-wrap: wrap;
gap: var(--bui-space-3);
margin-top: var(--bui-space-1);
}
.toast-links a {
.links a {
font-family: var(--bui-font-regular);
padding: 0;
margin: 0;
@@ -184,7 +178,7 @@
text-decoration-color: color-mix(in srgb, currentColor 30%, transparent);
}
.toast-links a:hover {
.links a:hover {
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: min(2px, max(1px, 0.05em));
@@ -193,7 +187,7 @@
}
/* Close Button */
.toast-close-button {
.closeButton {
flex-shrink: 0;
display: flex;
align-items: center;
@@ -211,20 +205,20 @@
color: var(--bui-fg-primary);
}
.toast-close-button svg {
.closeButton svg {
width: 1rem;
height: 1rem;
}
.toast-close-button:hover {
.closeButton:hover {
background-color: var(--bui-bg-neutral-on-surface-1-hover);
}
.toast-close-button:focus-visible {
.closeButton:focus-visible {
outline: 2px solid var(--bui-border-focus);
outline-offset: 1px;
}
.toast-close-button:active {
.closeButton:active {
background-color: var(--bui-bg-neutral-on-surface-1-pressed);
}
@@ -18,9 +18,13 @@ import { useState } from 'react';
import preview from '../../../../../.storybook/preview';
import { Button, Flex, Text } from '../../../../../packages/ui/src';
/* eslint-enable @backstage/no-relative-monorepo-imports */
import { ToastContainer, toastQueue } from './index';
import { ToastQueue } from '@react-stately/toast';
import { ToastContainer } from './index';
import type { ToastContent } from './types';
import { MemoryRouter } from 'react-router-dom';
const toastQueue = new ToastQueue<ToastContent>({ maxVisibleToasts: 4 });
const meta = preview.meta({
title: 'Plugins/App/Toast',
component: ToastContainer,
+9 -8
View File
@@ -26,6 +26,7 @@ import {
RiCloseLine,
} from '@remixicon/react';
import type { ToastProps } from './types';
import styles from './Toast.module.css';
// Track which toasts are being manually closed (vs auto-timeout)
// This allows different exit animations for each case
@@ -205,7 +206,7 @@ export const Toast = forwardRef(
<motion.div
{...ariaProps}
ref={toastRef}
className="toast"
className={styles.toast}
style={
{
'--toast-index': index,
@@ -224,17 +225,17 @@ export const Toast = forwardRef(
transition={{ type: 'spring', stiffness: 400, damping: 35 }}
data-status={finalStatus}
>
<div className="toast-wrapper">
{statusIcon && <div className="toast-icon">{statusIcon}</div>}
<div className="toast-content">
<div {...titleProps} className="toast-title">
<div className={styles.wrapper}>
{statusIcon && <div className={styles.icon}>{statusIcon}</div>}
<div className={styles.content}>
<div {...titleProps} className={styles.title}>
{content.title}
</div>
{content.description && (
<div className="toast-description">{content.description}</div>
<div className={styles.description}>{content.description}</div>
)}
{content.links && content.links.length > 0 && (
<div className="toast-links">
<div className={styles.links}>
{content.links.map(link => (
<a key={link.href} href={link.href}>
{link.label}
@@ -248,7 +249,7 @@ export const Toast = forwardRef(
<button
{...buttonProps}
ref={closeButtonRef}
className="toast-close-button"
className={styles.closeButton}
>
<RiCloseLine aria-hidden="true" />
</button>
@@ -21,6 +21,7 @@ import { AnimatePresence } from 'motion/react';
import type { ToastContainerProps } from './types';
import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode';
import { Toast } from './Toast';
import styles from './Toast.module.css';
/**
* A ToastContainer displays one or more toast notifications in the bottom-center of the screen.
@@ -119,7 +120,7 @@ export const ToastContainer = forwardRef(
<div
{...regionProps}
ref={containerRef}
className={className || 'toast-container'}
className={className || styles.container}
data-theme-mode={invertedThemeMode}
data-hover-locked={isHoverLocked ? '' : undefined}
onMouseEnter={() => setIsHovered(true)}
@@ -26,7 +26,6 @@ import { Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
import { ToastDisplay } from './ToastDisplay';
import { ToastApiForwarder } from '../../apis';
import { toastQueue } from './ToastQueue';
// Mock AlertApi with proper Observable implementation
class MockAlertApi implements AlertApi {
@@ -53,10 +52,6 @@ describe('ToastDisplay', () => {
let alertApi: MockAlertApi;
beforeEach(() => {
// Clear the toast queue before each test
while (toastQueue.visibleToasts.length > 0) {
toastQueue.close(toastQueue.visibleToasts[0].key);
}
toastApi = new ToastApiForwarder();
alertApi = new MockAlertApi();
});
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import { toastApiRef } from '@backstage/frontend-plugin-api';
import { ToastQueue } from '@react-stately/toast';
import { ToastContainer } from './ToastContainer';
import { toastQueue } from './ToastQueue';
import type { ToastDisplayProps, ToastContent } from './types';
import './Toast.css';
/**
* Maps AlertApi severity to Toast status.
@@ -85,6 +84,11 @@ export function ToastDisplay(props: ToastDisplayProps) {
const toastApi = useApi(toastApiRef);
const { transientTimeoutMs = 5000 } = props;
// Create toast queue once per component instance
const [toastQueue] = useState(
() => new ToastQueue<ToastContent>({ maxVisibleToasts: 4 }),
);
// Track toast keys for programmatic close
const toastKeyMap = useRef<Map<string, string>>(new Map());
@@ -108,7 +112,7 @@ export function ToastDisplay(props: ToastDisplayProps) {
});
return () => subscription.unsubscribe();
}, [toastApi]);
}, [toastApi, toastQueue]);
// Subscribe to ToastApi close events for programmatic dismissal
useEffect(() => {
@@ -121,7 +125,7 @@ export function ToastDisplay(props: ToastDisplayProps) {
});
return () => subscription.unsubscribe();
}, [toastApi]);
}, [toastApi, toastQueue]);
// Subscribe to AlertApi (deprecated - provides backward compatibility during migration)
// This subscription will be removed when AlertApi is fully deprecated
@@ -140,7 +144,7 @@ export function ToastDisplay(props: ToastDisplayProps) {
});
return () => subscription.unsubscribe();
}, [alertApi, transientTimeoutMs]);
}, [alertApi, transientTimeoutMs, toastQueue]);
return <ToastContainer queue={toastQueue} />;
}
@@ -1,47 +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 { ToastQueue } from '@react-stately/toast';
import type { ToastContent } from './types';
/**
* Global toast queue for displaying toast notifications throughout the application.
*
* @remarks
* This uses React Stately's ToastQueue for state management with motion/react
* for smooth enter/exit animations.
*
* @example
* ```tsx
* import { toastQueue } from './ToastQueue';
*
* // Show a toast
* toastQueue.add({ title: 'Success!', status: 'success' });
*
* // Show with auto-dismiss
* toastQueue.add({ title: 'Saved' }, { timeout: 5000 });
*
* // Programmatic dismiss
* const key = toastQueue.add({ title: 'Processing...' });
* // Later...
* toastQueue.close(key);
* ```
*
* @internal
*/
export const toastQueue = new ToastQueue<ToastContent>({
maxVisibleToasts: 4,
});
@@ -20,5 +20,4 @@ export type { ToastDisplayProps } from './types';
// Internal exports (used within the plugin only)
export { ToastContainer } from './ToastContainer';
export { toastQueue } from './ToastQueue';
export type { ToastContent, ToastLink, ToastContainerProps } from './types';