Cleanup Toast after feedbacks
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
aa1d73a798
commit
033ec31a33
+25
-31
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user