Add new ToastApi

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-05 14:15:32 +00:00
committed by Patrik Oldsberg
parent 7e743f4ad9
commit 91d9dccb5c
19 changed files with 2894 additions and 885 deletions
@@ -0,0 +1,123 @@
/*
* 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 { createApiRef, ApiRef } from '../system';
import { Observable } from '@backstage/types';
import { ReactNode, ReactElement } from 'react';
/**
* Link item for toast notifications.
*
* @public
*/
export type ToastLink = {
/** Display text for the link */
label: string;
/** URL the link points to */
href: string;
};
/**
* Message handled by the {@link ToastApi}.
*
* @public
*/
export type ToastMessage = {
/** Title of the toast (required) */
title: ReactNode;
/** 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;
/** Optional array of links to display */
links?: ToastLink[];
/** Timeout in milliseconds before auto-dismiss. If not set, toast is permanent. */
timeout?: number;
};
/**
* Internal toast message with key for tracking.
*
* @internal
*/
export type ToastMessageWithKey = ToastMessage & {
/** Unique key for the toast, used for programmatic dismiss */
key: string;
};
/**
* The toast API is used to display toast notifications to the user.
*
* @remarks
* This API provides richer notification capabilities than the AlertApi,
* including title/description, custom icons, links, and per-toast timeout control.
*
* @example
* ```tsx
* const toastApi = useApi(toastApiRef);
*
* // Full-featured toast
* toastApi.post({
* title: 'Entity saved',
* description: 'Your changes have been saved successfully.',
* status: 'success',
* timeout: 5000,
* links: [{ label: 'View entity', href: '/catalog/default/component/my-service' }],
* });
*
* // Simple toast
* toastApi.post({ title: 'Processing...', status: 'info' });
*
* // Programmatic dismiss
* const key = toastApi.post({ title: 'Uploading...', status: 'info' });
* // Later...
* toastApi.close(key);
* ```
*
* @public
*/
export type ToastApi = {
/**
* Post a toast notification for display to the user.
*
* @param toast - The toast message to display
* @returns A unique key that can be used to programmatically dismiss the toast
*/
post(toast: ToastMessage): string;
/**
* Programmatically close/dismiss a toast by its key.
*
* @param key - The key returned from post()
*/
close(key: string): void;
/**
* Observe toasts posted by other parts of the application.
*/
toast$(): Observable<ToastMessageWithKey>;
};
/**
* The {@link ApiRef} of {@link ToastApi}.
*
* @public
*/
export const toastApiRef: ApiRef<ToastApi> = createApiRef({
id: 'core.toast',
});
@@ -48,4 +48,5 @@ export * from './OAuthRequestApi';
export * from './RouteResolutionApi';
export * from './StorageApi';
export * from './AnalyticsApi';
export * from './ToastApi';
export * from './TranslationApi';
+10 -1
View File
@@ -20,7 +20,9 @@
"directory": "plugins/app"
},
"license": "Apache-2.0",
"sideEffects": false,
"sideEffects": [
"*.css"
],
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha/index.ts",
@@ -63,8 +65,15 @@
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
"@react-aria/toast": "^3.0.9",
"@react-hookz/web": "^24.0.0",
"@remixicon/react": "^4.6.0",
"motion": "^12.0.0",
"react-aria": "^3.41.1",
"react-aria-components": "^1.14.0",
"react-stately": "^3.35.0",
"react-use": "^17.2.4",
"zen-observable": "^0.10.0",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -100,6 +100,14 @@ describe('appModulePublicSignIn', () => {
data-unified-theme-stack="[{"mode":"light","name":"backstage"}]"
>
<div>
<div
aria-label="0 notifications."
class="toast-container"
data-react-aria-top-layer="true"
data-theme-mode="dark"
role="region"
tabindex="-1"
/>
<form
action="http://localhost/"
method="POST"
+128
View File
@@ -0,0 +1,128 @@
/*
* 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 {
ToastApi,
ToastMessage,
ToastMessageWithKey,
} from '@backstage/frontend-plugin-api';
import { Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
let toastKeyCounter = 0;
/**
* Generates a unique key for a toast message.
*/
function generateToastKey(): string {
toastKeyCounter += 1;
return `toast-${toastKeyCounter}-${Date.now()}`;
}
/**
* A simple publish subject for broadcasting values to subscribers.
*/
class PublishSubject<T> {
private subscribers = new Set<ZenObservable.SubscriptionObserver<T>>();
private isClosed = false;
private readonly observable = new ObservableImpl<T>(subscriber => {
if (this.isClosed) {
subscriber.complete();
return () => {};
}
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
next(value: T) {
if (this.isClosed) {
throw new Error('PublishSubject is closed');
}
this.subscribers.forEach(subscriber => subscriber.next(value));
}
subscribe(observer: ZenObservable.Observer<T>): ZenObservable.Subscription {
return this.observable.subscribe(observer);
}
/**
* Creates an Observable that replays buffered values and then subscribes to live updates.
*/
asObservable(replayBuffer: T[] = []): Observable<T> {
return new ObservableImpl<T>(subscriber => {
// Replay buffered values
for (const value of replayBuffer) {
subscriber.next(value);
}
// Subscribe to live updates
return this.subscribe(subscriber);
});
}
}
/**
* Base implementation for the ToastApi that forwards toast messages to consumers.
*
* Recent toasts are buffered and replayed to new subscribers to prevent
* missing toasts that were posted before subscription.
*
* @internal
*/
export class ToastApiForwarder implements ToastApi {
private readonly subject = new PublishSubject<ToastMessageWithKey>();
private readonly closeSubject = new PublishSubject<string>();
private readonly recentToasts: ToastMessageWithKey[] = [];
private readonly maxBufferSize = 10;
post(toast: ToastMessage): string {
const key = generateToastKey();
const toastWithKey: ToastMessageWithKey = { ...toast, key };
this.recentToasts.push(toastWithKey);
if (this.recentToasts.length > this.maxBufferSize) {
this.recentToasts.shift();
}
this.subject.next(toastWithKey);
return key;
}
close(key: string): void {
// Remove from recent buffer if still there
const index = this.recentToasts.findIndex(t => t.key === key);
if (index !== -1) {
this.recentToasts.splice(index, 1);
}
this.closeSubject.next(key);
}
toast$(): Observable<ToastMessageWithKey> {
return this.subject.asObservable(this.recentToasts);
}
/**
* Observe close requests for toasts.
* This is used internally by the ToastDisplay to know when to dismiss a toast programmatically.
*
* @internal
*/
close$(): Observable<string> {
return this.closeSubject.asObservable();
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { ToastApiForwarder } from './ToastApiForwarder';
+239
View File
@@ -0,0 +1,239 @@
/*
* 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.
*/
/* Toast Container - Container for all toasts (bottom center) */
.toast-container {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 100050;
width: 300px;
outline: none;
}
@media (min-width: 500px) {
.toast-container {
width: 350px;
bottom: 2rem;
}
}
/* Individual Toast */
.toast {
/* CSS Variables */
--toast-peek: 0.75rem;
--toast-scale: calc(max(0, 1 - (var(--toast-index) * 0.05)));
/* Layout */
position: absolute;
bottom: 0;
left: 0;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
display: flex;
align-items: flex-start;
padding: 1rem;
gap: 0.75rem;
/* 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;
outline: none;
cursor: default;
user-select: none;
/* All toasts need pointer-events to trigger hover expansion */
pointer-events: auto;
/* Stacking */
z-index: calc(1000 - var(--toast-index));
transform-origin: bottom center;
/* Shadow */
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);
}
.toast:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
/* Extend hover area above each toast to fill gaps when expanded */
/* This prevents the stack from collapsing when hovering between toasts */
.toast::before {
content: '';
position: absolute;
bottom: 100%;
left: 0;
right: 0;
/* Height matches the expanded gap to ensure continuous hover */
height: 12px;
pointer-events: auto;
}
/* Status variants - color icon and title */
.toast[data-status='info'] .toast-icon,
.toast[data-status='info'] .toast-title {
color: #3b82f6;
}
.toast[data-status='success'] .toast-icon,
.toast[data-status='success'] .toast-title {
color: #22c55e;
}
.toast[data-status='warning'] .toast-icon,
.toast[data-status='warning'] .toast-title {
color: #f59e0b;
}
.toast[data-status='danger'] .toast-icon,
.toast[data-status='danger'] .toast-title {
color: #ef4444;
}
.toast-wrapper {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.toast-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Icon */
.toast-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.125rem;
}
.toast-icon svg {
width: 1rem;
height: 1rem;
}
/* Title */
.toast-title {
font-weight: 600;
font-size: 0.875rem;
word-wrap: break-word;
margin-top: 0.125rem;
}
/* Description */
.toast-description {
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
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;
}
.toast-links a {
font-family: var(--bui-font-regular);
padding: 0;
margin: 0;
cursor: pointer;
display: inline-block;
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: min(2px, max(1px, 0.05em));
text-underline-offset: calc(0.025em + 2px);
text-decoration-color: color-mix(in srgb, currentColor 30%, transparent);
}
.toast-links a:hover {
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: min(2px, max(1px, 0.05em));
text-underline-offset: calc(0.025em + 2px);
text-decoration-color: color-mix(in srgb, currentColor 30%, transparent);
}
/* Close Button */
.toast-close-button {
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: 0.25rem;
background: transparent;
color: inherit;
cursor: pointer;
transition: background-color 0.15s ease;
}
.toast-close-button svg {
width: 1rem;
height: 1rem;
}
.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);
}
.toast-close-button:focus-visible {
outline: 2px solid #6366f1;
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);
}
@@ -0,0 +1,624 @@
/*
* 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';
/* eslint-disable @backstage/no-relative-monorepo-imports */
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 { MemoryRouter } from 'react-router-dom';
const meta = preview.meta({
title: 'Plugins/App/Toast',
component: ToastContainer,
parameters: {
layout: 'centered',
},
});
const randomToasts = [
// Title only - short
{ title: 'Saved', status: 'success' as const },
{ title: 'Error', status: 'danger' as const },
{ title: 'New notification', status: 'info' as const },
{ title: 'Warning', status: 'warning' 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 + short description
{
title: 'Files uploaded',
description: '3 files uploaded.',
status: 'success' as const,
},
{
title: 'Update available',
description: 'Version 2.0 is ready.',
status: 'info' as const,
},
{
title: 'Request failed',
description: 'Please try again.',
status: 'danger' as const,
},
{
title: 'Storage warning',
description: '90% used.',
status: 'warning' as const,
},
// Title + medium description
{
title: 'Deployment complete',
description:
'Your application has been deployed to production successfully.',
status: 'success' as const,
},
{
title: 'Session expiring',
description:
'Your session will expire in 5 minutes. Please save your work.',
status: 'warning' as const,
},
{
title: 'Permission denied',
description: 'You do not have access to perform this action.',
status: 'danger' as const,
},
// Title + long description
{
title: 'Sync completed',
description:
'All your files have been synchronized across devices. This includes 47 documents, 23 images, and 12 configuration files that were updated in the last hour.',
status: 'success' as const,
},
{
title: 'Rate limit exceeded',
description:
'You have exceeded the maximum number of API requests allowed. Please wait a few minutes before trying again or upgrade your plan for higher limits.',
status: 'warning' as const,
},
{
title: 'Critical error',
description:
'The server encountered an unexpected error while processing your request. Our team has been notified and is investigating the issue. Please try again later.',
status: 'danger' as const,
},
// Long title only
{
title: 'Your subscription has been renewed successfully for another year',
status: 'success' as const,
},
{
title: 'Multiple users are currently editing this document',
status: 'info' as const,
},
];
export const Default = meta.story({
render: () => (
<>
<ToastContainer queue={toastQueue} />
<Flex gap="3">
<Button
onPress={() => {
const toast =
randomToasts[Math.floor(Math.random() * randomToasts.length)];
toastQueue.add(toast);
}}
>
Add Random Toast
</Button>
<Button
variant="secondary"
onPress={() => {
toastQueue.clear();
}}
>
Clear All Toasts
</Button>
</Flex>
</>
),
});
export const StatusVariants = meta.story({
render: () => (
<>
<ToastContainer queue={toastQueue} />
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
title: 'Informational message',
description: 'Here is some helpful information.',
status: 'info',
})
}
>
Info Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Success!',
description: 'Your changes have been saved.',
status: 'success',
})
}
>
Success Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Warning',
description: 'This action may have consequences.',
status: 'warning',
})
}
>
Warning Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Error',
description: 'Something went wrong.',
status: 'danger',
})
}
>
Danger Toast
</Button>
</Flex>
</>
),
});
export const WithoutDescription = meta.story({
render: () => (
<>
<ToastContainer queue={toastQueue} />
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
title: 'File saved',
status: 'success',
})
}
>
Simple Success
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Check for updates',
status: 'info',
})
}
>
Simple Info
</Button>
</Flex>
</>
),
});
export const WithoutIcons = meta.story({
render: () => (
<>
<ToastContainer queue={toastQueue} />
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
title: 'Toast without icon',
description: 'This toast has no icon displayed.',
icon: false,
})
}
>
No Icon
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Success without icon',
status: 'success',
icon: false,
})
}
>
Success No Icon
</Button>
</Flex>
</>
),
});
export const WithLinks = meta.story({
render: () => (
<MemoryRouter>
<ToastContainer queue={toastQueue} />
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
title: 'Deployment complete',
description: 'Your application has been deployed.',
status: 'success',
links: [
{ label: 'View logs', href: '/logs' },
{ label: 'Open dashboard', href: '/dashboard' },
],
})
}
>
Toast with Links
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Error occurred',
description: 'Something went wrong during the build.',
status: 'danger',
links: [{ label: 'View error details', href: '/errors' }],
})
}
>
Single Link
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'New update available',
status: 'info',
links: [
{ label: 'Release notes', href: '/releases' },
{ label: 'Update now', href: '/update' },
],
})
}
>
Links without Description
</Button>
</Flex>
</MemoryRouter>
),
});
export const AutoDismiss = meta.story({
render: () => (
<>
<ToastContainer queue={toastQueue} />
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 3 seconds',
description: 'This toast will disappear automatically.',
status: 'info',
},
{ timeout: 3000 },
)
}
>
3 Second Toast
</Button>
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 5 seconds',
description: 'Recommended minimum timeout for accessibility.',
status: 'success',
},
{ timeout: 5000 },
)
}
>
5 Second Toast
</Button>
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 10 seconds',
description: 'Longer timeout for more content.',
status: 'warning',
},
{ timeout: 10000 },
)
}
>
10 Second Toast
</Button>
</Flex>
</>
),
});
function ProgrammaticDismissStory() {
const [toastKey, setToastKey] = useState<string | null>(null);
return (
<>
<ToastContainer queue={toastQueue} />
<Button
onPress={() => {
if (!toastKey) {
const key = toastQueue.add(
{
title: 'Processing...',
description: 'Click the button again to dismiss.',
status: 'info',
},
{
onClose: () => setToastKey(null),
},
);
setToastKey(key);
} else {
toastQueue.close(toastKey);
}
}}
>
{toastKey ? 'Dismiss Toast' : 'Show Toast'}
</Button>
</>
);
}
export const ProgrammaticDismiss = meta.story({
render: () => <ProgrammaticDismissStory />,
});
export const QueueManagement = meta.story({
render: () => (
<>
<ToastContainer queue={toastQueue} />
<Flex gap="3">
<Button
onPress={() => {
toastQueue.add({
title: 'First toast',
description: 'This is the first toast in the queue.',
status: 'info',
});
setTimeout(() => {
toastQueue.add({
title: 'Second toast',
description: 'This is the second toast.',
status: 'success',
});
}, 300);
setTimeout(() => {
toastQueue.add({
title: 'Third toast',
description: 'This is the third toast.',
status: 'warning',
});
}, 600);
}}
>
Show Multiple Toasts
</Button>
<Button
onPress={() => {
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
toastQueue.add({
title: `Toast #${i}`,
description: `This is toast number ${i}.`,
status: ['info', 'success', 'warning', 'danger'][
(i - 1) % 4
] as 'info' | 'success' | 'warning' | 'danger',
});
}, i * 200);
}
}}
>
Show 5 Toasts
</Button>
<Button variant="secondary" onPress={() => toastQueue.clear()}>
Clear All Toasts
</Button>
</Flex>
</>
),
});
export const AlertApiIntegration = meta.story({
name: 'AlertApi Integration',
render: () => {
// This story demonstrates how alerts posted via the AlertApi
// would be displayed using the ToastDisplay component.
// The ToastDisplay bridges AlertApi.post() calls to the toast queue.
return (
<>
<ToastContainer queue={toastQueue} />
<Flex direction="column" gap="4">
<Flex direction="column" gap="2">
<Text variant="body-medium" weight="bold">
AlertApi Severity Mapping:
</Text>
<Text variant="body-small">success success (green)</Text>
<Text variant="body-small">info info (blue)</Text>
<Text variant="body-small">warning warning (orange)</Text>
<Text variant="body-small">error danger (red)</Text>
</Flex>
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
title: 'Entity saved successfully',
status: 'success',
})
}
>
Success Alert
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Catalog refresh in progress',
status: 'info',
})
}
>
Info Alert
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Entity validation has warnings',
status: 'warning',
})
}
>
Warning Alert
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Failed to fetch entity',
status: 'danger',
})
}
>
Error Alert
</Button>
</Flex>
</Flex>
</>
);
},
});
/**
* This story tests the real AlertApi integration.
* It uses the alertApi from the TestApiProvider (set up in storybook preview)
* and shows how alerts posted via alertApi.post() appear.
*
* Note: The storybook preview.tsx renders AlertDisplay from core-components,
* which still uses Material UI. To test the new ToastDisplay, run the actual
* Backstage app where the app plugin's elements.tsx is used.
*/
function RealAlertApiStory() {
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
const { useApi, alertApiRef } = require('@backstage/core-plugin-api');
const alertApi = useApi(alertApiRef);
return (
<Flex direction="column" gap="4">
<Flex direction="column" gap="2">
<Text variant="body-medium" weight="bold">
Real AlertApi Test
</Text>
<Text variant="body-small">
These buttons call alertApi.post() directly. Alerts appear in the OLD
AlertDisplay (top of screen) because Storybook uses core-components.
</Text>
<Text variant="body-small">
To test the NEW ToastDisplay, run: yarn start
</Text>
</Flex>
<Flex gap="3">
<Button
onPress={() =>
alertApi.post({
message: 'Entity saved successfully!',
severity: 'success',
display: 'transient',
})
}
>
Success (transient)
</Button>
<Button
onPress={() =>
alertApi.post({
message: 'Catalog refresh in progress',
severity: 'info',
display: 'transient',
})
}
>
Info (transient)
</Button>
<Button
onPress={() =>
alertApi.post({
message: 'Entity validation has warnings',
severity: 'warning',
display: 'transient',
})
}
>
Warning (transient)
</Button>
<Button
onPress={() =>
alertApi.post({
message: 'Failed to fetch entity from catalog',
severity: 'error',
display: 'transient',
})
}
>
Error (transient)
</Button>
</Flex>
<Flex gap="3">
<Button
variant="secondary"
onPress={() =>
alertApi.post({
message: 'This alert stays until dismissed',
severity: 'info',
display: 'permanent',
})
}
>
Permanent Alert
</Button>
<Button
variant="secondary"
onPress={() =>
alertApi.post({
message: 'Critical error - requires attention!',
severity: 'error',
display: 'permanent',
})
}
>
Permanent Error
</Button>
</Flex>
</Flex>
);
}
export const RealAlertApi = meta.story({
name: 'Real AlertApi Test',
render: () => <RealAlertApiStory />,
});
export default meta;
+271
View File
@@ -0,0 +1,271 @@
/*
* 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,
useRef,
useLayoutEffect,
useState,
} from 'react';
import { useToast } from '@react-aria/toast';
import { Button } from 'react-aria-components';
import { motion } from 'motion/react';
import {
RiInformationLine,
RiCheckLine,
RiErrorWarningLine,
RiAlertLine,
RiCloseLine,
} from '@remixicon/react';
import type { ToastProps } from './types';
// Track which toasts are being manually closed (vs auto-timeout)
// This allows different exit animations for each case
const manuallyClosingToasts = new Set<string>();
/**
* A Toast displays a brief, temporary notification of actions, errors, or other events in an application.
*
* @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.
*
* @internal
*/
export const Toast = forwardRef(
(props: ToastProps, ref: Ref<HTMLDivElement>) => {
const {
toast,
state,
index = 0,
isExpanded = false,
onClose,
status,
icon,
expandedY: expandedYProp = 0,
collapsedHeight,
naturalHeight,
onHeightChange,
} = props;
// Use internal ref if none provided
const internalRef = useRef<HTMLDivElement>(null);
const toastRef = (ref as React.RefObject<HTMLDivElement>) || internalRef;
// Get ARIA props from useToast hook
const { toastProps, titleProps, closeButtonProps } = useToast(
{ toast },
state,
toastRef,
);
// Extract only ARIA and accessibility props from toastProps to avoid
// conflicts with motion.div's event handler types (motion has its own drag API)
const ariaProps = {
role: toastProps.role,
tabIndex: toastProps.tabIndex,
'aria-label': toastProps['aria-label'],
'aria-labelledby': toastProps['aria-labelledby'],
'aria-describedby': toastProps['aria-describedby'],
'aria-posinset': toastProps['aria-posinset'],
'aria-setsize': toastProps['aria-setsize'],
};
// Track whether we've measured this toast's natural height
const [hasMeasured, setHasMeasured] = useState(false);
// Store the measured natural height locally to avoid re-measurement issues
const naturalHeightRef = useRef<number | null>(null);
// Measure this toast's natural height on mount (before paint)
// Using useLayoutEffect ensures we measure before the browser paints
useLayoutEffect(() => {
if (!onHeightChange) return;
if (naturalHeightRef.current) return; // Already measured
const element = toastRef.current;
if (!element) return;
// Measure immediately - useLayoutEffect runs before paint
const height = element.getBoundingClientRect().height;
if (height > 0) {
naturalHeightRef.current = height;
onHeightChange(toast.key, height);
setHasMeasured(true);
}
}, [toast.key, onHeightChange, toastRef]);
// Close button handler
const handleClose = () => {
// Mark this toast as manually closed for exit animation
manuallyClosingToasts.add(toast.key);
onClose?.();
state.close(toast.key);
};
// 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 <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();
// Calculate stacking values based on index
// Collapsed: each toast behind scales down 5% and peeks up 12px
const collapsedScale = Math.max(0.85, 1 - index * 0.05);
const collapsedY = -index * 12;
// Use expanded or collapsed values based on hover state
// expandedYProp is pre-calculated based on actual toast heights
const animateY = isExpanded ? expandedYProp : collapsedY;
const animateScale = isExpanded ? 1 : collapsedScale;
const stackZIndex = 1000 - index;
// Check if this toast is being manually closed
const isManualClose = manuallyClosingToasts.has(toast.key);
// Different exit animations for manual close vs auto-timeout
// Manual close: slide down from front, stay on top
// Auto-timeout: fade out in place, stay in stack position
const exitAnimation = isManualClose
? { opacity: 0, y: 100, scale: 1, zIndex: 2000 }
: {
opacity: 0,
y: animateY + 50,
scale: animateScale,
zIndex: stackZIndex,
};
// Height animation for back toasts:
// - Front toast (index 0): never set height, uses natural CSS height
// - Back toasts: animate between collapsedHeight and their own naturalHeight
const measuredHeight = naturalHeight || naturalHeightRef.current;
const isBackToast = index > 0;
const hasValidMeasurements =
hasMeasured && collapsedHeight && measuredHeight;
// For back toasts with valid measurements, calculate target height
// Otherwise, let CSS handle it naturally
const animateProps: {
opacity: number;
y: number;
scale: number;
zIndex: number;
height?: number;
} = {
opacity: 1,
y: animateY,
scale: animateScale,
zIndex: stackZIndex,
...(isBackToast && hasValidMeasurements
? { height: isExpanded ? measuredHeight : collapsedHeight }
: {}),
};
const shouldClipContent =
isBackToast && hasValidMeasurements && !isExpanded;
return (
<motion.div
{...ariaProps}
ref={toastRef}
className="toast"
style={
{
'--toast-index': index,
overflow: shouldClipContent ? 'hidden' : undefined,
} as React.CSSProperties
}
initial={{ opacity: 0, y: 100, scale: 1 }}
animate={animateProps}
exit={exitAnimation}
onAnimationComplete={definition => {
// Clean up the manual close tracking after exit animation
if (definition === 'exit') {
manuallyClosingToasts.delete(toast.key);
}
}}
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">
{content.title}
</div>
{content.description && (
<div className="toast-description">{content.description}</div>
)}
{content.links && content.links.length > 0 && (
<div className="toast-links">
{content.links.map((link, idx) => (
<a key={idx} href={link.href}>
{link.label}
</a>
))}
</div>
)}
</div>
</div>
<Button
slot="close"
className="toast-close-button"
aria-label={closeButtonProps['aria-label']}
onPress={handleClose}
>
<RiCloseLine aria-hidden="true" />
</Button>
</motion.div>
);
},
);
Toast.displayName = 'Toast';
@@ -0,0 +1,151 @@
/*
* 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, useState, useRef, useCallback, useMemo } from 'react';
import { useToastRegion } from '@react-aria/toast';
import { useToastQueue } from 'react-stately';
import { AnimatePresence } from 'motion/react';
import type { ToastContainerProps } from './types';
import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode';
import { Toast } from './Toast';
/**
* A ToastContainer displays one or more toast notifications in the bottom-center of the screen.
*
* @remarks
* The ToastContainer component should typically be placed once at the root of your application.
* It manages the display and stacking of all toast notifications added to its queue.
* Toasts appear in the bottom-center with deep stacking when multiple are visible.
* Toast containers are ARIA landmark regions that can be navigated using F6 (forward) and
* Shift+F6 (backward) for keyboard accessibility.
*
* @internal
*/
export const ToastContainer = forwardRef(
(props: ToastContainerProps, ref: Ref<HTMLDivElement>) => {
const { queue, className } = props;
// Subscribe to the toast queue state
const state = useToastQueue(queue);
// Use internal ref if none provided
const internalRef = useRef<HTMLDivElement>(null);
const containerRef =
(ref as React.RefObject<HTMLDivElement>) || internalRef;
// Get ARIA props for the toast region
const { regionProps } = useToastRegion({}, state, containerRef);
// Track hover state for expanding/collapsing the stack
const [isHovered, setIsHovered] = useState(false);
// Lock expanded state after close to prevent stack collapse during exit animation
const [isHoverLocked, setIsHoverLocked] = useState(false);
const unlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Toasts are expanded when hovered, focused, or locked
const isExpanded = isHovered || isHoverLocked;
// Track heights of all toasts by their key
const [toastHeights, setToastHeights] = useState<Record<string, number>>(
{},
);
// Callback for toasts to report their height
const handleHeightChange = useCallback((key: string, height: number) => {
setToastHeights(prev => {
if (prev[key] === height) return prev;
return { ...prev, [key]: height };
});
}, []);
// Calculate expanded Y positions and get front toast height
const { expandedYPositions, frontToastHeight } = useMemo(() => {
const gap = 8;
const positions: Record<string, number> = {};
let frontHeight = 60; // Default fallback
// visibleToasts[0] is the front toast (newest)
const toasts = state.visibleToasts;
if (toasts.length > 0 && toastHeights[toasts[0].key]) {
frontHeight = toastHeights[toasts[0].key];
}
// Calculate cumulative Y position for each toast when expanded
// Position is negative Y (moving up from bottom)
let cumulativeY = 0;
for (let i = 0; i < toasts.length; i++) {
positions[toasts[i].key] = -cumulativeY;
const height = toastHeights[toasts[i].key] || 60;
cumulativeY += height + gap;
}
return { expandedYPositions: positions, frontToastHeight: frontHeight };
}, [state.visibleToasts, toastHeights]);
// Get inverted theme mode for toasts (light when app is dark, dark when app is light)
const invertedThemeMode = useInvertedThemeMode();
const handleClose = () => {
// Lock the expanded state while toast is being removed
setIsHoverLocked(true);
// Clear any pending unlock
if (unlockTimerRef.current) {
clearTimeout(unlockTimerRef.current);
}
// Unlock after a short delay to allow exit animation to complete
unlockTimerRef.current = setTimeout(() => {
setIsHoverLocked(false);
}, 500);
};
return (
<div
{...regionProps}
ref={containerRef}
className={className || 'toast-container'}
data-theme-mode={invertedThemeMode}
data-hover-locked={isHoverLocked ? '' : undefined}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsHovered(true)}
onBlur={() => setIsHovered(false)}
>
<AnimatePresence>
{state.visibleToasts.map((toast, index) => (
<Toast
key={toast.key}
toast={toast}
state={state}
index={index}
isExpanded={isExpanded}
onClose={handleClose}
expandedY={expandedYPositions[toast.key] ?? 0}
collapsedHeight={index > 0 ? frontToastHeight : undefined}
naturalHeight={toastHeights[toast.key]}
onHeightChange={handleHeightChange}
/>
))}
</AnimatePresence>
</div>
);
},
);
ToastContainer.displayName = 'ToastContainer';
@@ -0,0 +1,144 @@
/*
* 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 { useEffect, useRef } from 'react';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import { toastApiRef } from '@backstage/frontend-plugin-api';
import { ToastApiForwarder } from '../../apis';
import { ToastContainer } from './ToastContainer';
import { toastQueue } from './ToastQueue';
import type { ToastDisplayProps, ToastContent } from './types';
import './Toast.css';
/**
* Maps AlertApi severity to Toast status.
* AlertApi uses 'error' while Toast uses 'danger' for the same semantic meaning.
*/
function mapSeverity(
severity: 'success' | 'info' | 'warning' | 'error' | undefined,
): ToastContent['status'] {
if (severity === 'error') {
return 'danger';
}
return severity ?? 'success';
}
/**
* ToastDisplay bridges both the ToastApi and AlertApi with the Toast notification system.
*
* @remarks
* This component subscribes to:
* - `toastApi.toast$()` - New toast notifications with full features (title, description, links, icons)
* - `alertApi.alert$()` - Legacy alerts for backward compatibility (message maps to title only)
*
* For ToastApi:
* - Uses toast content directly (title, description, status, icon, links)
* - Uses the provided timeout from the toast message
*
* For AlertApi (legacy):
* - `alert.message` → `toast.title`
* - `alert.severity` → `toast.status` ('error' maps to 'danger')
* - `alert.display` → `timeout` (transient gets default timeout, permanent stays until dismissed)
*
* @example
* ```tsx
* // In your app root element extension
* <ToastDisplay transientTimeoutMs={5000} />
*
* // Using the new ToastApi:
* toastApi.post({
* title: 'Entity saved',
* description: 'Your changes have been saved successfully.',
* status: 'success',
* timeout: 5000,
* });
*
* // Using the legacy AlertApi:
* alertApi.post({ message: 'Saved!', severity: 'success', display: 'transient' });
* ```
*
* @public
*/
export function ToastDisplay(props: ToastDisplayProps) {
const alertApi = useApi(alertApiRef);
const toastApi = useApi(toastApiRef);
const { transientTimeoutMs = 5000 } = props;
// Track toast keys for programmatic close
const toastKeyMap = useRef<Map<string, string>>(new Map());
// Subscribe to ToastApi
useEffect(() => {
const subscription = toastApi.toast$().subscribe(toast => {
const content: ToastContent = {
title: toast.title,
description: toast.description,
status: toast.status ?? 'success',
icon: toast.icon,
links: toast.links,
};
// Use the timeout from the toast message if provided
const options = toast.timeout ? { timeout: toast.timeout } : {};
const queueKey = toastQueue.add(content, options);
// Track the mapping from API key to queue key for programmatic close
toastKeyMap.current.set(toast.key, queueKey);
});
return () => subscription.unsubscribe();
}, [toastApi]);
// Subscribe to ToastApi close events
useEffect(() => {
// The ToastApiForwarder exposes a close$() method for programmatic close
const forwarder = toastApi as ToastApiForwarder;
if (!forwarder.close$) {
return undefined;
}
const subscription = forwarder.close$().subscribe(apiKey => {
const queueKey = toastKeyMap.current.get(apiKey);
if (queueKey) {
toastQueue.close(queueKey);
toastKeyMap.current.delete(apiKey);
}
});
return () => subscription.unsubscribe();
}, [toastApi]);
// Subscribe to AlertApi (legacy support)
useEffect(() => {
const subscription = alertApi.alert$().subscribe(alert => {
const content: ToastContent = {
title: alert.message,
status: mapSeverity(alert.severity),
};
// Transient alerts auto-dismiss after timeout, permanent alerts stay until dismissed
const options =
alert.display === 'transient' ? { timeout: transientTimeoutMs } : {};
toastQueue.add(content, options);
});
return () => subscription.unsubscribe();
}, [alertApi, transientTimeoutMs]);
return <ToastContainer queue={toastQueue} />;
}
@@ -0,0 +1,47 @@
/*
* 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';
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,
});
+25
View File
@@ -0,0 +1,25 @@
/*
* 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.
*/
export { ToastDisplay } from './ToastDisplay';
export { ToastContainer } from './ToastContainer';
export { toastQueue } from './ToastQueue';
export type {
ToastContent,
ToastLink,
ToastDisplayProps,
ToastContainerProps,
} from './types';
+105
View File
@@ -0,0 +1,105 @@
/*
* 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 type { ReactElement, ReactNode } from 'react';
import type { ToastQueue, ToastState, QueuedToast } from 'react-stately';
/**
* Link item for toast notifications
* @public
*/
export interface ToastLink {
/** Display text for the link */
label: string;
/** URL the link points to */
href: string;
}
/**
* Content for a toast notification
* @public
*/
export interface ToastContent {
/** Title of the toast (required) */
title: ReactNode;
/** Optional description text */
description?: ReactNode;
/** Status variant of the toast */
status?: 'info' | 'success' | 'warning' | 'danger';
/** Whether to show an icon */
icon?: boolean | ReactElement;
/** Optional array of links to display */
links?: ToastLink[];
}
/**
* Props for the Toast component
* @internal
*/
export interface ToastProps {
/** Toast object from the queue */
toast: QueuedToast<ToastContent>;
/** Toast state from useToastQueue */
state: ToastState<ToastContent>;
/** Index of the toast in the visible toasts array */
index?: number;
/** Whether the toast stack is expanded (hovered/focused) */
isExpanded?: boolean;
/** Callback when toast is closed */
onClose?: () => void;
/** Override status from content */
status?: 'info' | 'success' | 'warning' | 'danger';
/** Override icon from content */
icon?: boolean | ReactElement;
/** 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) */
collapsedHeight?: number;
/** This toast's natural height (for smooth animation) */
naturalHeight?: number;
/** Callback to report this toast's natural height */
onHeightChange?: (key: string, height: number) => void;
}
/**
* Props for the ToastContainer component
* @public
*/
export interface ToastContainerProps {
/** Toast queue instance */
queue: ToastQueue<ToastContent>;
/** Custom class name */
className?: string;
}
/**
* Props for the ToastDisplay component (AlertApi bridge)
* @public
*/
export interface ToastDisplayProps {
/**
* Number of milliseconds a transient alert will stay open for.
* @default 5000
*/
transientTimeoutMs?: number;
/**
* @deprecated Toast uses fixed bottom-center positioning. This prop is ignored.
*/
anchorOrigin?: {
vertical: 'top' | 'bottom';
horizontal: 'left' | 'center' | 'right';
};
}
+15 -1
View File
@@ -37,6 +37,7 @@ import {
VMwareCloudAuth,
OpenShiftAuth,
} from '../../../packages/core-app-api/src/apis/implementations';
import { ToastApiForwarder } from './apis';
import {
alertApiRef,
@@ -59,7 +60,11 @@ import {
vmwareCloudAuthApiRef,
openshiftAuthApiRef,
} from '@backstage/core-plugin-api';
import { ApiBlueprint, dialogApiRef } from '@backstage/frontend-plugin-api';
import {
ApiBlueprint,
dialogApiRef,
toastApiRef,
} from '@backstage/frontend-plugin-api';
import {
ScmAuth,
ScmIntegrationsApi,
@@ -103,6 +108,15 @@ export const apis = [
factory: () => new AlertApiForwarder(),
}),
}),
ApiBlueprint.make({
name: 'toast',
params: defineParams =>
defineParams({
api: toastApiRef,
deps: {},
factory: () => new ToastApiForwarder(),
}),
}),
analyticsApi,
ApiBlueprint.make({
name: 'error',
+5 -2
View File
@@ -14,8 +14,9 @@
* limitations under the License.
*/
import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
import { OAuthRequestDialog } from '@backstage/core-components';
import { AppRootElementBlueprint } from '@backstage/frontend-plugin-api';
import { ToastDisplay } from '../components/Toast';
export const oauthRequestDialogAppRootElement = AppRootElementBlueprint.make({
name: 'oauth-request-dialog',
@@ -41,7 +42,9 @@ export const alertDisplayAppRootElement =
},
factory: (originalFactory, { config }) => {
return originalFactory({
element: <AlertDisplay {...config} />,
element: (
<ToastDisplay transientTimeoutMs={config.transientTimeoutMs} />
),
});
},
});
@@ -0,0 +1,67 @@
/*
* 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, useEffect } from 'react';
type ThemeMode = 'light' | 'dark';
/**
* Detects the current theme mode from the DOM and returns the inverted value.
* Checks both document.documentElement and document.body for the data-theme-mode attribute.
*
* @returns The inverted theme mode ('light' when app is dark, 'dark' when app is light)
* @internal
*/
export function useInvertedThemeMode(): ThemeMode {
const [invertedThemeMode, setInvertedThemeMode] = useState<ThemeMode>('dark');
useEffect(() => {
const detectTheme = (): ThemeMode => {
// Check both html and body elements for the theme attribute
const htmlTheme =
document.documentElement.getAttribute('data-theme-mode');
const bodyTheme = document.body.getAttribute('data-theme-mode');
// Prefer body (used by UnifiedThemeProvider) over html
const currentTheme = bodyTheme || htmlTheme;
// If current theme is dark, return light (inverted), otherwise return dark
return currentTheme === 'dark' ? 'light' : 'dark';
};
// Initial detection
setInvertedThemeMode(detectTheme());
// Watch for theme changes on both html and body
const observer = new MutationObserver(() => {
setInvertedThemeMode(detectTheme());
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme-mode'],
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['data-theme-mode'],
});
return () => observer.disconnect();
}, []);
return invertedThemeMode;
}
+9
View File
@@ -15,3 +15,12 @@
*/
export { appPlugin as default } from './plugin';
// Toast components for alert display
export { ToastDisplay, ToastContainer, toastQueue } from './components/Toast';
export type {
ToastContent,
ToastLink,
ToastDisplayProps,
ToastContainerProps,
} from './components/Toast';
+905 -881
View File
File diff suppressed because it is too large Load Diff