Merge pull request #32542 from backstage/bui-toast
feat: Introduce ToastApi for rich notifications in the new frontend system
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
Fixed --bui-fg-success token in light mode to be more accessible.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Deprecated `AlertApiForwarder` in favor of the new `ToastApi`. The `AlertApiForwarder` now emits a console warning on first use, guiding developers to migrate to `ToastApi` from `@backstage/frontend-plugin-api`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': patch
|
||||
---
|
||||
|
||||
Deprecated `AlertApi`, `AlertMessage`, and `alertApiRef` in favor of the new `ToastApi` from `@backstage/frontend-plugin-api`.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
---
|
||||
|
||||
Deprecated `AlertApi` in favor of the new `ToastApi`.
|
||||
|
||||
`AlertApi` is now deprecated and will be removed in a future release. Please migrate to `ToastApi` which provides richer notification features.
|
||||
|
||||
**Why migrate?**
|
||||
|
||||
`ToastApi` offers enhanced capabilities over `AlertApi`:
|
||||
|
||||
- **Title and Description**: Display a prominent title with optional description text
|
||||
- **Action Links**: Include clickable links within notifications
|
||||
- **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 `close()` handle returned from `post()`
|
||||
|
||||
**Migration Guide**
|
||||
|
||||
| AlertApi | ToastApi |
|
||||
| -------------------------------------------- | ------------------------------------------ |
|
||||
| `message: string` | `title: ReactNode` |
|
||||
| `severity: 'error'` | `status: 'danger'` |
|
||||
| `severity: 'success' \| 'info' \| 'warning'` | `status: 'success' \| 'info' \| 'warning'` |
|
||||
| `display: 'transient'` | `timeout: 5000` (or custom ms) |
|
||||
| `display: 'permanent'` | omit `timeout` |
|
||||
| `post()` returns `void` | `post()` returns `{ close(): void }` |
|
||||
|
||||
**Example Migration**
|
||||
|
||||
```typescript
|
||||
// Before (AlertApi)
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const alertApi = useApi(alertApiRef);
|
||||
alertApi.post({
|
||||
message: 'Entity saved successfully',
|
||||
severity: 'success',
|
||||
display: 'transient',
|
||||
});
|
||||
|
||||
// After (ToastApi)
|
||||
import { toastApiRef, useApi } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const toastApi = useApi(toastApiRef);
|
||||
const toast = toastApi.post({
|
||||
title: 'Entity saved successfully',
|
||||
status: 'success',
|
||||
timeout: 5000,
|
||||
});
|
||||
// Later: toast.close() to dismiss programmatically
|
||||
```
|
||||
|
||||
**Note**: During the migration period, both APIs work simultaneously. The `ToastDisplay` component subscribes to both `AlertApi` and `ToastApi`, so existing code continues to work while you migrate incrementally.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
'@backstage/plugin-app': patch
|
||||
---
|
||||
|
||||
Introduced a new `ToastApi` for displaying rich toast notifications in the new frontend system.
|
||||
|
||||
The new `ToastApi` provides enhanced notification capabilities compared to the existing `AlertApi`:
|
||||
|
||||
- **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
|
||||
- **Status Variants**: Support for neutral, info, success, warning, and danger statuses
|
||||
- **Programmatic Dismiss**: Toasts can be dismissed programmatically using the `close()` handle returned from `post()`
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { toastApiRef, useApi } from '@backstage/frontend-plugin-api';
|
||||
|
||||
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/entity' }],
|
||||
});
|
||||
|
||||
// Programmatic dismiss
|
||||
const { close } = toastApi.post({ title: 'Uploading...', status: 'info' });
|
||||
// Later...
|
||||
close();
|
||||
```
|
||||
|
||||
The `ToastDisplay` component subscribes to both `ToastApi` and `AlertApi`, providing a migration path where both systems work side by side until `AlertApi` is fully deprecated.
|
||||
@@ -18,6 +18,7 @@ const allStories = isChromatic
|
||||
'packages/ui',
|
||||
'packages/core-components',
|
||||
'packages/app',
|
||||
'plugins/app',
|
||||
'plugins/org',
|
||||
'plugins/search',
|
||||
'plugins/search-react',
|
||||
|
||||
@@ -70,7 +70,7 @@ import { StorageValueSnapshot } from '@backstage/core-plugin-api';
|
||||
import { SubRouteRef } from '@backstage/core-plugin-api';
|
||||
import { vmwareCloudAuthApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export class AlertApiForwarder implements AlertApi {
|
||||
// (undocumented)
|
||||
alert$(): Observable<AlertMessage>;
|
||||
|
||||
@@ -26,13 +26,25 @@ import ObservableImpl from 'zen-observable';
|
||||
* missing alerts that were posted before subscription.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use ToastApi instead. AlertApi will be removed in a future release.
|
||||
*/
|
||||
export class AlertApiForwarder implements AlertApi {
|
||||
private readonly subject = new PublishSubject<AlertMessage>();
|
||||
private readonly recentAlerts: AlertMessage[] = [];
|
||||
private readonly maxBufferSize = 10;
|
||||
private hasWarnedDeprecation = false;
|
||||
|
||||
post(alert: AlertMessage) {
|
||||
if (!this.hasWarnedDeprecation) {
|
||||
this.hasWarnedDeprecation = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'AlertApi is deprecated and will be removed in a future release. ' +
|
||||
'Please migrate to ToastApi from @backstage/frontend-plugin-api. ' +
|
||||
'ToastApi provides richer features including title/description, links, icons, and per-toast timeouts. ' +
|
||||
'Example: toastApi.post({ title: "Saved!", status: "success", timeout: 5000 })',
|
||||
);
|
||||
}
|
||||
this.recentAlerts.push(alert);
|
||||
if (this.recentAlerts.length > this.maxBufferSize) {
|
||||
this.recentAlerts.shift();
|
||||
|
||||
@@ -14,6 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated AlertApi is deprecated. Use ToastApi from `@backstage/frontend-plugin-api` instead.
|
||||
*
|
||||
* ToastApi provides richer notification features including title/description,
|
||||
* action links, custom icons, per-toast timeout control, and programmatic dismiss.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Before (AlertApi)
|
||||
* import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
* alertApi.post({ message: 'Saved!', severity: 'success', display: 'transient' });
|
||||
*
|
||||
* // After (ToastApi)
|
||||
* import { toastApiRef } from '@backstage/frontend-plugin-api';
|
||||
* toastApi.post({ title: 'Saved!', status: 'success', timeout: 5000 });
|
||||
* ```
|
||||
*/
|
||||
export {
|
||||
type AlertApi,
|
||||
type AlertMessage,
|
||||
|
||||
@@ -1026,6 +1026,8 @@ describe('createApp', () => {
|
||||
<api:app/dialog out=[core.api.factory] />
|
||||
<api:app/discovery out=[core.api.factory] />
|
||||
<api:app/alert out=[core.api.factory] />
|
||||
<api:app/toast-forwarder out=[core.api.factory] />
|
||||
<api:app/toast out=[core.api.factory] />
|
||||
<api:app/analytics out=[core.api.factory] />
|
||||
<api:app/error out=[core.api.factory] />
|
||||
<api:app/storage out=[core.api.factory] />
|
||||
|
||||
@@ -25,18 +25,18 @@ import { ReactNode } from 'react';
|
||||
import { SwappableComponentRef as SwappableComponentRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import type { z } from 'zod';
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type AlertApi = {
|
||||
post(alert: AlertMessage): void;
|
||||
alert$(): Observable<AlertMessage>;
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export const alertApiRef: ApiRef_2<AlertApi, 'core.alert'> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type AlertMessage = {
|
||||
message: string;
|
||||
severity?: 'success' | 'info' | 'warning' | 'error';
|
||||
@@ -2241,6 +2241,34 @@ export const swappableComponentsApiRef: ApiRef_2<
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ToastApi = {
|
||||
post(toast: ToastApiMessage): ToastApiPostResult;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ToastApiMessage = {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||
links?: ToastApiMessageLink[];
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ToastApiMessageLink = {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ToastApiPostResult = {
|
||||
close(): void;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const toastApiRef: ApiRef<ToastApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TranslationApi = {
|
||||
getTranslation<
|
||||
|
||||
@@ -21,6 +21,14 @@ import { Observable } from '@backstage/types';
|
||||
* Message handled by the {@link AlertApi}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link ToastApiMessage} from {@link ToastApi} instead. AlertApi will be removed in a future release.
|
||||
*
|
||||
* Migration guide:
|
||||
* - `message` becomes `title`
|
||||
* - `severity: 'error'` becomes `status: 'danger'`
|
||||
* - `severity: 'success' | 'info' | 'warning'` becomes `status: 'success' | 'info' | 'warning'`
|
||||
* - `display: 'transient'` becomes `timeout: 5000` (or custom milliseconds)
|
||||
* - `display: 'permanent'` means omitting `timeout`
|
||||
*/
|
||||
export type AlertMessage = {
|
||||
message: string;
|
||||
@@ -33,6 +41,23 @@ export type AlertMessage = {
|
||||
* The alert API is used to report alerts to the app, and display them to the user.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link ToastApi} instead. AlertApi will be removed in a future release.
|
||||
*
|
||||
* ToastApi provides richer notification features including:
|
||||
* - Title and optional description
|
||||
* - Action links
|
||||
* - Custom icons
|
||||
* - Per-toast timeout control
|
||||
* - Programmatic dismiss via returned key
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Before (AlertApi)
|
||||
* alertApi.post({ message: 'Saved!', severity: 'success', display: 'transient' });
|
||||
*
|
||||
* // After (ToastApi)
|
||||
* toastApi.post({ title: 'Saved!', status: 'success', timeout: 5000 });
|
||||
* ```
|
||||
*/
|
||||
export type AlertApi = {
|
||||
/**
|
||||
@@ -50,6 +75,7 @@ export type AlertApi = {
|
||||
* The {@link ApiRef} of {@link AlertApi}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link toastApiRef} instead. AlertApi will be removed in a future release.
|
||||
*/
|
||||
export const alertApiRef = createApiRef<AlertApi>().with({
|
||||
id: 'core.alert',
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Link item for toast notifications.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ToastApiMessageLink = {
|
||||
/** Display text for the link */
|
||||
label: string;
|
||||
/** URL the link points to */
|
||||
href: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message handled by the {@link ToastApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ToastApiMessage = {
|
||||
/** Title of the toast (required) */
|
||||
title: ReactNode;
|
||||
/** Optional description text */
|
||||
description?: ReactNode;
|
||||
/** Status variant of the toast - defaults to 'success' */
|
||||
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||
/** Optional array of links to display */
|
||||
links?: ToastApiMessageLink[];
|
||||
/** Timeout in milliseconds before auto-dismiss. If not set, toast is permanent. */
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle returned by {@link ToastApi.post} that allows programmatic control
|
||||
* of the posted toast.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ToastApiPostResult = {
|
||||
/** Dismiss the toast. */
|
||||
close(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, 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 { close } = toastApi.post({ title: 'Uploading...', status: 'info' });
|
||||
* // Later...
|
||||
* close();
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ToastApi = {
|
||||
/**
|
||||
* Post a toast notification for display to the user.
|
||||
*
|
||||
* @param toast - The toast message to display
|
||||
* @returns A handle with a `close()` method to programmatically dismiss the toast
|
||||
*/
|
||||
post(toast: ToastApiMessage): ToastApiPostResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* The {@link ApiRef} of {@link ToastApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const toastApiRef: ApiRef<ToastApi> = createApiRef({
|
||||
id: 'core.toast',
|
||||
});
|
||||
@@ -48,6 +48,7 @@ export * from './OAuthRequestApi';
|
||||
export * from './RouteResolutionApi';
|
||||
export * from './StorageApi';
|
||||
export * from './AnalyticsApi';
|
||||
export * from './ToastApi';
|
||||
export * from './TranslationApi';
|
||||
export * from './PluginHeaderActionsApi';
|
||||
export * from './PluginWrapperApi';
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
*/
|
||||
|
||||
@layer tokens {
|
||||
/* Light theme tokens */
|
||||
:root {
|
||||
/* Light theme tokens (also used for nested light theme switching) */
|
||||
:root,
|
||||
[data-theme-mode='light'] {
|
||||
/* Font families */
|
||||
--bui-font-regular: system-ui;
|
||||
--bui-font-monospace: ui-monospace, 'Menlo', 'Monaco', 'Consolas',
|
||||
@@ -119,7 +120,7 @@
|
||||
--bui-fg-info-on-bg: #173da6;
|
||||
--bui-fg-danger: #ec3b18;
|
||||
--bui-fg-warning: #ef7a32;
|
||||
--bui-fg-success: #1ed760;
|
||||
--bui-fg-success: #1aaf4f;
|
||||
--bui-fg-info: #0d74ce;
|
||||
|
||||
/* Border colors */
|
||||
|
||||
@@ -65,8 +65,14 @@
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||
"@react-aria/button": "^3.14.3",
|
||||
"@react-aria/toast": "^3.0.9",
|
||||
"@react-hookz/web": "^24.0.0",
|
||||
"@react-stately/toast": "^3.1.2",
|
||||
"@remixicon/react": "^4.6.0",
|
||||
"motion": "^12.0.0",
|
||||
"react-use": "^17.2.4",
|
||||
"zen-observable": "^0.10.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -720,6 +720,36 @@ const appPlugin: OverridableFrontendPlugin<
|
||||
params: ApiFactory<TApi, TImpl, TDeps>,
|
||||
) => ExtensionBlueprintParams<AnyApiFactory>;
|
||||
}>;
|
||||
'api:app/toast': OverridableExtensionDefinition<{
|
||||
kind: 'api';
|
||||
name: 'toast';
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
|
||||
inputs: {};
|
||||
params: <
|
||||
TApi,
|
||||
TImpl extends TApi,
|
||||
TDeps extends { [name in string]: unknown },
|
||||
>(
|
||||
params: ApiFactory<TApi, TImpl, TDeps>,
|
||||
) => ExtensionBlueprintParams<AnyApiFactory>;
|
||||
}>;
|
||||
'api:app/toast-forwarder': OverridableExtensionDefinition<{
|
||||
kind: 'api';
|
||||
name: 'toast-forwarder';
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
|
||||
inputs: {};
|
||||
params: <
|
||||
TApi,
|
||||
TImpl extends TApi,
|
||||
TDeps extends { [name in string]: unknown },
|
||||
>(
|
||||
params: ApiFactory<TApi, TImpl, TDeps>,
|
||||
) => ExtensionBlueprintParams<AnyApiFactory>;
|
||||
}>;
|
||||
'api:app/translations': OverridableExtensionDefinition<{
|
||||
config: {};
|
||||
configInput: {};
|
||||
|
||||
@@ -100,6 +100,14 @@ describe('appModulePublicSignIn', () => {
|
||||
data-unified-theme-stack="[{"mode":"light","name":"backstage"}]"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="0 notifications."
|
||||
class="container"
|
||||
data-react-aria-top-layer="true"
|
||||
data-theme-mode="dark"
|
||||
role="region"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<form
|
||||
action="http://localhost/"
|
||||
method="POST"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* 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 { ToastApiForwarder } from './ToastApiForwarder';
|
||||
|
||||
describe('ToastApiForwarder', () => {
|
||||
let forwarder: ToastApiForwarder;
|
||||
|
||||
beforeEach(() => {
|
||||
forwarder = new ToastApiForwarder();
|
||||
});
|
||||
|
||||
describe('post', () => {
|
||||
it('should return a result with a close method', () => {
|
||||
const result = forwarder.post({ title: 'Toast 1' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.close).toBe('function');
|
||||
});
|
||||
|
||||
it('should emit toast to subscribers', () => {
|
||||
const received: Array<{ title: unknown; key: string }> = [];
|
||||
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
forwarder.post({ title: 'Test Toast', status: 'success' });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].title).toBe('Test Toast');
|
||||
expect(received[0].key).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include all toast properties in emitted message', () => {
|
||||
const received: Array<{
|
||||
title: unknown;
|
||||
description?: unknown;
|
||||
status?: string;
|
||||
timeout?: number;
|
||||
}> = [];
|
||||
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
forwarder.post({
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
status: 'warning',
|
||||
timeout: 5000,
|
||||
links: [{ label: 'Link', href: '/test' }],
|
||||
});
|
||||
|
||||
expect(received[0]).toMatchObject({
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
status: 'warning',
|
||||
timeout: 5000,
|
||||
links: [{ label: 'Link', href: '/test' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should notify onClose listeners when close() is called', () => {
|
||||
const onCloseFn = jest.fn();
|
||||
|
||||
const received: Array<{ onClose: (cb: () => void) => void }> = [];
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
const result = forwarder.post({ title: 'Test' });
|
||||
received[0].onClose(onCloseFn);
|
||||
result.close();
|
||||
|
||||
expect(onCloseFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should only close once even if called multiple times', () => {
|
||||
const onCloseFn = jest.fn();
|
||||
|
||||
const received: Array<{ onClose: (cb: () => void) => void }> = [];
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
const result = forwarder.post({ title: 'Test' });
|
||||
received[0].onClose(onCloseFn);
|
||||
result.close();
|
||||
result.close();
|
||||
|
||||
expect(onCloseFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should remove toast from replay buffer', () => {
|
||||
const result = forwarder.post({ title: 'Test' });
|
||||
result.close();
|
||||
|
||||
// New subscriber should not receive the closed toast
|
||||
const received: Array<{ key: string }> = [];
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should immediately call onClose callback if already closed', () => {
|
||||
const onCloseFn = jest.fn();
|
||||
|
||||
const received: Array<{ onClose: (cb: () => void) => void }> = [];
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
const result = forwarder.post({ title: 'Test' });
|
||||
result.close();
|
||||
|
||||
// Register callback after close - should fire immediately
|
||||
received[0].onClose(onCloseFn);
|
||||
expect(onCloseFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast$ replay', () => {
|
||||
it('should replay recent toasts to new subscribers', async () => {
|
||||
forwarder.post({ title: 'Toast 1' });
|
||||
forwarder.post({ title: 'Toast 2' });
|
||||
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const subscription = forwarder.toast$().subscribe({
|
||||
next: toast => {
|
||||
received.push(toast);
|
||||
// After receiving replayed toasts, unsubscribe
|
||||
if (received.length === 2) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
// Also resolve after a short timeout in case no toasts are replayed
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
|
||||
expect(received).toHaveLength(2);
|
||||
expect(received[0].title).toBe('Toast 1');
|
||||
expect(received[1].title).toBe('Toast 2');
|
||||
});
|
||||
|
||||
it('should not replay closed toasts to new subscribers', async () => {
|
||||
const result1 = forwarder.post({ title: 'Toast 1' });
|
||||
forwarder.post({ title: 'Toast 2' });
|
||||
|
||||
result1.close();
|
||||
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const subscription = forwarder.toast$().subscribe({
|
||||
next: toast => {
|
||||
received.push(toast);
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].title).toBe('Toast 2');
|
||||
});
|
||||
|
||||
it('should limit replay buffer size', async () => {
|
||||
// Post more than maxBufferSize (10) toasts
|
||||
for (let i = 0; i < 15; i++) {
|
||||
forwarder.post({ title: `Toast ${i}` });
|
||||
}
|
||||
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const subscription = forwarder.toast$().subscribe({
|
||||
next: toast => {
|
||||
received.push(toast);
|
||||
if (received.length === 10) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
|
||||
// Should only have last 10 toasts
|
||||
expect(received).toHaveLength(10);
|
||||
expect(received[0].title).toBe('Toast 5');
|
||||
expect(received[9].title).toBe('Toast 14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscription cleanup', () => {
|
||||
it('should stop receiving toasts after unsubscribe', () => {
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
const subscription = forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
forwarder.post({ title: 'Before unsubscribe' });
|
||||
subscription.unsubscribe();
|
||||
forwarder.post({ title: 'After unsubscribe' });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].title).toBe('Before unsubscribe');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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 {
|
||||
ToastApiMessage,
|
||||
ToastApiPostResult,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import {
|
||||
ToastApiForwarderApi,
|
||||
ToastApiForwarderMessage,
|
||||
} from './toastApiForwarderRef';
|
||||
|
||||
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 ToastApiForwarderApi {
|
||||
private readonly subject = new PublishSubject<ToastApiForwarderMessage>();
|
||||
private readonly recentToasts: ToastApiForwarderMessage[] = [];
|
||||
private readonly closedKeys = new Set<string>();
|
||||
private readonly maxBufferSize = 10;
|
||||
|
||||
post(toast: ToastApiMessage): ToastApiPostResult {
|
||||
const key = generateToastKey();
|
||||
const closeCallbacks: Array<() => void> = [];
|
||||
let closed = false;
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
|
||||
// Track closed keys to prevent replaying dismissed toasts
|
||||
this.closedKeys.add(key);
|
||||
|
||||
// Remove from recent buffer if still there
|
||||
const index = this.recentToasts.findIndex(t => t.key === key);
|
||||
if (index !== -1) {
|
||||
this.recentToasts.splice(index, 1);
|
||||
}
|
||||
|
||||
// Clean up old closed keys when buffer is cleared
|
||||
if (this.recentToasts.length === 0) {
|
||||
this.closedKeys.clear();
|
||||
}
|
||||
|
||||
// Notify registered listeners (e.g. the toast display)
|
||||
closeCallbacks.forEach(fn => fn());
|
||||
};
|
||||
|
||||
const onClose = (callback: () => void) => {
|
||||
if (closed) {
|
||||
callback();
|
||||
} else {
|
||||
closeCallbacks.push(callback);
|
||||
}
|
||||
};
|
||||
|
||||
const toastWithKey: ToastApiForwarderMessage = {
|
||||
...toast,
|
||||
key,
|
||||
close,
|
||||
onClose,
|
||||
};
|
||||
|
||||
this.recentToasts.push(toastWithKey);
|
||||
if (this.recentToasts.length > this.maxBufferSize) {
|
||||
this.recentToasts.shift();
|
||||
}
|
||||
this.subject.next(toastWithKey);
|
||||
|
||||
return { close };
|
||||
}
|
||||
|
||||
toast$(): Observable<ToastApiForwarderMessage> {
|
||||
// Filter out any toasts that were closed to handle race conditions
|
||||
const activeToasts = this.recentToasts.filter(
|
||||
t => !this.closedKeys.has(t.key),
|
||||
);
|
||||
return this.subject.asObservable(activeToasts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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';
|
||||
export { toastApiForwarderRef } from './toastApiForwarderRef';
|
||||
export type { ToastApiForwarderApi } from './toastApiForwarderRef';
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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,
|
||||
ToastApiMessage,
|
||||
createApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Internal enriched toast message emitted by the forwarder's observable.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type ToastApiForwarderMessage = ToastApiMessage & {
|
||||
/** Unique key for tracking this toast */
|
||||
key: string;
|
||||
/** Dismiss this toast programmatically */
|
||||
close(): void;
|
||||
/**
|
||||
* Register a callback that fires when this toast is closed.
|
||||
* Used by the toast display to sync with the rendering queue.
|
||||
*/
|
||||
onClose(callback: () => void): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal extension of the public ToastApi that adds an observable
|
||||
* for the toast display to subscribe to. This interface is only used
|
||||
* within the app plugin and can be freely evolved.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type ToastApiForwarderApi = ToastApi & {
|
||||
/** Observe toasts posted by other parts of the application. */
|
||||
toast$(): Observable<ToastApiForwarderMessage>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal API ref for the toast forwarder. The public `toastApiRef`
|
||||
* delegates to this, and the toast display subscribes to it directly.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const toastApiForwarderRef = createApiRef<ToastApiForwarderApi>({
|
||||
id: 'app.toast.internal-forwarder',
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* 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) */
|
||||
.container {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100050;
|
||||
width: 300px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
.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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.surface {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: var(--bui-space-4);
|
||||
gap: var(--bui-space-3);
|
||||
border-radius: var(--bui-radius-3);
|
||||
font-size: var(--bui-font-size-3);
|
||||
background-color: var(--bui-bg-app);
|
||||
border: 1px solid var(--bui-border-1);
|
||||
color: var(--bui-fg-primary);
|
||||
box-shadow: var(--bui-shadow);
|
||||
}
|
||||
|
||||
.toast:focus-visible {
|
||||
outline: 2px solid var(--bui-ring);
|
||||
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='neutral'] .title {
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
.toast[data-status='info'] .icon,
|
||||
.toast[data-status='info'] .title {
|
||||
color: var(--bui-fg-info);
|
||||
}
|
||||
|
||||
.toast[data-status='success'] .icon,
|
||||
.toast[data-status='success'] .title {
|
||||
color: var(--bui-fg-success);
|
||||
}
|
||||
|
||||
.toast[data-status='warning'] .icon,
|
||||
.toast[data-status='warning'] .title {
|
||||
color: var(--bui-fg-warning);
|
||||
}
|
||||
|
||||
.toast[data-status='danger'] .icon,
|
||||
.toast[data-status='danger'] .title {
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--bui-space-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--bui-space-1);
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: var(--bui-space-0_5);
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.title {
|
||||
font-weight: var(--bui-font-weight-bold);
|
||||
font-size: var(--bui-font-size-3);
|
||||
word-wrap: break-word;
|
||||
margin-top: var(--bui-space-0_5);
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.description {
|
||||
color: var(--bui-fg-secondary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
opacity: 0.9;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--bui-space-3);
|
||||
margin-top: var(--bui-space-1);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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 */
|
||||
.closeButton {
|
||||
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: var(--bui-radius-2);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
.closeButton svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background-color: var(--bui-bg-neutral-1-hover);
|
||||
}
|
||||
|
||||
.closeButton:focus-visible {
|
||||
outline: 2px solid var(--bui-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.closeButton:active {
|
||||
background-color: var(--bui-bg-neutral-1-pressed);
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
/*
|
||||
* 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 { ToastQueue } from '@react-stately/toast';
|
||||
import { ToastContainer } from './index';
|
||||
import type { ToastApiMessageContent } from './types';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const toastQueue = new ToastQueue<ToastApiMessageContent>({
|
||||
maxVisibleToasts: 4,
|
||||
});
|
||||
|
||||
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: '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',
|
||||
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: 'Clipboard updated',
|
||||
description: 'Text copied.',
|
||||
status: 'neutral' 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: 'Preferences updated',
|
||||
description: 'Your display settings have been saved to your profile.',
|
||||
status: 'neutral' 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,
|
||||
},
|
||||
{
|
||||
title: 'Your workspace has been switched to the new project',
|
||||
status: 'neutral' 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: 'Neutral message',
|
||||
description: 'A simple notification without emphasis.',
|
||||
status: 'neutral',
|
||||
})
|
||||
}
|
||||
>
|
||||
Neutral Toast
|
||||
</Button>
|
||||
<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 NeutralStatus = meta.story({
|
||||
render: () => (
|
||||
<>
|
||||
<ToastContainer queue={toastQueue} />
|
||||
<Flex gap="3">
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Neutral toast',
|
||||
description: 'This toast has no icon and uses the primary color.',
|
||||
status: 'neutral',
|
||||
})
|
||||
}
|
||||
>
|
||||
Neutral
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Simple neutral message',
|
||||
status: 'neutral',
|
||||
})
|
||||
}
|
||||
>
|
||||
Neutral Simple
|
||||
</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: ['neutral', 'info', 'success', 'warning', 'danger'][
|
||||
(i - 1) % 5
|
||||
] as 'neutral' | '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>
|
||||
<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({
|
||||
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;
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* 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, useRef, useLayoutEffect, useState } from 'react';
|
||||
import { useToast } from '@react-aria/toast';
|
||||
import { useButton } from '@react-aria/button';
|
||||
import { motion } from 'motion/react';
|
||||
import { Box } from '@backstage/ui';
|
||||
import {
|
||||
RiInformationLine,
|
||||
RiCheckLine,
|
||||
RiErrorWarningLine,
|
||||
RiAlertLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react';
|
||||
import type { ToastApiMessageProps } from './types';
|
||||
import styles from './Toast.module.css';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { BgReset } from '../../../../../packages/ui/src/hooks/useBg';
|
||||
|
||||
// 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 (neutral, info, success, warning, danger) and can display
|
||||
* a title and description. Toasts can be dismissed manually or automatically.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const Toast = forwardRef(
|
||||
(props: ToastApiMessageProps, ref: Ref<HTMLDivElement>) => {
|
||||
const {
|
||||
toast,
|
||||
state,
|
||||
index = 0,
|
||||
isExpanded = false,
|
||||
onClose,
|
||||
status,
|
||||
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,
|
||||
);
|
||||
|
||||
// Close button ref for useButton hook
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 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 button props from useButton hook
|
||||
const { buttonProps } = useButton(
|
||||
{
|
||||
'aria-label': closeButtonProps['aria-label'],
|
||||
onPress: handleClose,
|
||||
},
|
||||
closeButtonRef,
|
||||
);
|
||||
|
||||
// Get content from toast
|
||||
const content = toast.content;
|
||||
const finalStatus = status || content.status || 'info';
|
||||
|
||||
// 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" />;
|
||||
}
|
||||
};
|
||||
|
||||
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={styles.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}
|
||||
>
|
||||
<BgReset>
|
||||
<Box className={styles.surface}>
|
||||
<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={styles.description}>
|
||||
{content.description}
|
||||
</div>
|
||||
)}
|
||||
{content.links && content.links.length > 0 && (
|
||||
<div className={styles.links}>
|
||||
{content.links.map(link => (
|
||||
<a key={link.href} href={link.href}>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* eslint-disable-next-line react/forbid-elements */}
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={closeButtonRef}
|
||||
className={styles.closeButton}
|
||||
>
|
||||
<RiCloseLine aria-hidden="true" />
|
||||
</button>
|
||||
</Box>
|
||||
</BgReset>
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Toast.displayName = 'Toast';
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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/toast';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import type { ToastApiMessageContainerProps } 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.
|
||||
*
|
||||
* @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: ToastApiMessageContainerProps, 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 || styles.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,255 @@
|
||||
/*
|
||||
* 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 { render, screen, act, waitFor } from '@testing-library/react';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import {
|
||||
alertApiRef,
|
||||
AlertApi,
|
||||
AlertMessage,
|
||||
appThemeApiRef,
|
||||
AppThemeApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import { ToastDisplay } from './ToastDisplay';
|
||||
import { ToastApiForwarder, toastApiForwarderRef } from '../../apis';
|
||||
|
||||
// Mock AlertApi with proper Observable implementation
|
||||
class MockAlertApi implements AlertApi {
|
||||
private subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<AlertMessage>
|
||||
>();
|
||||
|
||||
post(alert: AlertMessage) {
|
||||
this.subscribers.forEach(subscriber => subscriber.next(alert));
|
||||
}
|
||||
|
||||
alert$(): Observable<AlertMessage> {
|
||||
return new ObservableImpl<AlertMessage>(subscriber => {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mock AppThemeApi
|
||||
const mockAppThemeApi: AppThemeApi = {
|
||||
getInstalledThemes: () => [
|
||||
{
|
||||
id: 'light',
|
||||
variant: 'light',
|
||||
title: 'Light',
|
||||
Provider: ({ children }: any) => children,
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
variant: 'dark',
|
||||
title: 'Dark',
|
||||
Provider: ({ children }: any) => children,
|
||||
},
|
||||
],
|
||||
getActiveThemeId: () => 'light',
|
||||
activeThemeId$: () =>
|
||||
new ObservableImpl<string | undefined>(subscriber => {
|
||||
subscriber.next('light');
|
||||
return () => {};
|
||||
}) as Observable<string | undefined>,
|
||||
setActiveThemeId: () => {},
|
||||
};
|
||||
|
||||
describe('ToastDisplay', () => {
|
||||
let toastApi: ToastApiForwarder;
|
||||
let alertApi: MockAlertApi;
|
||||
|
||||
beforeEach(() => {
|
||||
toastApi = new ToastApiForwarder();
|
||||
alertApi = new MockAlertApi();
|
||||
});
|
||||
|
||||
const renderToastDisplay = () => {
|
||||
return render(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[alertApiRef, alertApi],
|
||||
[toastApiForwarderRef, toastApi],
|
||||
[appThemeApiRef, mockAppThemeApi],
|
||||
]}
|
||||
>
|
||||
<ToastDisplay />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('ToastApi integration', () => {
|
||||
it('should display a toast when posted via ToastApi', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({
|
||||
title: 'Test Toast Title',
|
||||
status: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Test Toast Title'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display toast with description', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({
|
||||
title: 'Title',
|
||||
description: 'This is a description',
|
||||
status: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(screen.findByText('Title')).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('This is a description'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display toast with links', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({
|
||||
title: 'Toast with link',
|
||||
links: [{ label: 'Click here', href: '/test' }],
|
||||
});
|
||||
});
|
||||
|
||||
const link = await screen.findByText('Click here');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', '/test');
|
||||
});
|
||||
|
||||
it('should display multiple toasts', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({ title: 'Toast 1', status: 'success' });
|
||||
toastApi.post({ title: 'Toast 2', status: 'warning' });
|
||||
toastApi.post({ title: 'Toast 3', status: 'danger' });
|
||||
});
|
||||
|
||||
await expect(screen.findByText('Toast 1')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Toast 2')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Toast 3')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow programmatic dismiss via close()', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
let closeToast: () => void;
|
||||
|
||||
await act(async () => {
|
||||
const result = toastApi.post({
|
||||
title: 'Dismissable Toast',
|
||||
status: 'info',
|
||||
});
|
||||
closeToast = result.close;
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Dismissable Toast'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
closeToast!();
|
||||
// Wait for animation
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Dismissable Toast')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlertApi integration (legacy)', () => {
|
||||
it('should display alert as toast when posted via AlertApi', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
alertApi.post({
|
||||
message: 'Legacy Alert Message',
|
||||
severity: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Legacy Alert Message'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should map alert error severity to danger status', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
alertApi.post({
|
||||
message: 'Error Alert',
|
||||
severity: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
const toast = await screen.findByText('Error Alert');
|
||||
expect(toast.closest('[data-status]')).toHaveAttribute(
|
||||
'data-status',
|
||||
'danger',
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to success status when no severity', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
alertApi.post({
|
||||
message: 'No Severity Alert',
|
||||
});
|
||||
});
|
||||
|
||||
const toast = await screen.findByText('No Severity Alert');
|
||||
expect(toast.closest('[data-status]')).toHaveAttribute(
|
||||
'data-status',
|
||||
'success',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent usage', () => {
|
||||
it('should handle both ToastApi and AlertApi messages', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({ title: 'New Toast', status: 'success' });
|
||||
alertApi.post({ message: 'Legacy Alert', severity: 'warning' });
|
||||
});
|
||||
|
||||
await expect(screen.findByText('New Toast')).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Legacy Alert'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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, useState } from 'react';
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { ToastQueue } from '@react-stately/toast';
|
||||
import { ToastContainer } from './ToastContainer';
|
||||
import { toastApiForwarderRef } from '../../apis';
|
||||
import type {
|
||||
ToastApiMessageDisplayProps,
|
||||
ToastApiMessageContent,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): ToastApiMessageContent['status'] {
|
||||
if (severity === 'error') {
|
||||
return 'danger';
|
||||
}
|
||||
return severity ?? 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* ToastDisplay bridges both the ToastApi and AlertApi with the Toast notification system.
|
||||
*
|
||||
* @remarks
|
||||
* This component provides a migration bridge between the deprecated AlertApi and the new ToastApi.
|
||||
* During the migration period, it subscribes to both APIs simultaneously, allowing plugins to
|
||||
* migrate incrementally without breaking existing functionality.
|
||||
*
|
||||
* **Subscriptions:**
|
||||
* - `toastApi.toast$()` - New toast notifications with full features (title, description, links, icons)
|
||||
* - `alertApi.alert$()` - Deprecated alerts for backward compatibility (message maps to title only)
|
||||
*
|
||||
* **ToastApi (recommended):**
|
||||
* - Uses toast content directly (title, description, status, icon, links)
|
||||
* - Uses the provided timeout from the toast message
|
||||
* - Supports programmatic dismiss via the returned `close()` handle
|
||||
*
|
||||
* **AlertApi (deprecated - please migrate to ToastApi):**
|
||||
* - `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 (recommended):
|
||||
* import { toastApiRef, useApi } from '@backstage/frontend-plugin-api';
|
||||
* const toastApi = useApi(toastApiRef);
|
||||
* const { close } = toastApi.post({
|
||||
* title: 'Entity saved',
|
||||
* description: 'Your changes have been saved successfully.',
|
||||
* status: 'success',
|
||||
* timeout: 5000,
|
||||
* });
|
||||
* // Later: close() to dismiss programmatically
|
||||
*
|
||||
* // Using the deprecated AlertApi (migrate to ToastApi):
|
||||
* import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
* const alertApi = useApi(alertApiRef);
|
||||
* alertApi.post({ message: 'Saved!', severity: 'success', display: 'transient' });
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function ToastDisplay(props: ToastApiMessageDisplayProps) {
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const toastApi = useApi(toastApiForwarderRef);
|
||||
const { transientTimeoutMs = 5000 } = props;
|
||||
|
||||
// Create toast queue once per component instance
|
||||
const [toastQueue] = useState(
|
||||
() => new ToastQueue<ToastApiMessageContent>({ maxVisibleToasts: 4 }),
|
||||
);
|
||||
|
||||
// Subscribe to ToastApi
|
||||
useEffect(() => {
|
||||
const subscription = toastApi.toast$().subscribe(toast => {
|
||||
const content: ToastApiMessageContent = {
|
||||
title: toast.title,
|
||||
description: toast.description,
|
||||
status: toast.status ?? 'success',
|
||||
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);
|
||||
|
||||
// When the toast is programmatically closed, remove it from the queue
|
||||
toast.onClose(() => toastQueue.close(queueKey));
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [toastApi, toastQueue]);
|
||||
|
||||
// Subscribe to AlertApi (deprecated - provides backward compatibility during migration)
|
||||
// This subscription will be removed when AlertApi is fully deprecated
|
||||
useEffect(() => {
|
||||
const subscription = alertApi.alert$().subscribe(alert => {
|
||||
const content: ToastApiMessageContent = {
|
||||
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, toastQueue]);
|
||||
|
||||
return <ToastContainer queue={toastQueue} />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Public exports
|
||||
export { ToastDisplay } from './ToastDisplay';
|
||||
export type { ToastApiMessageDisplayProps } from './types';
|
||||
|
||||
// Internal exports (used within the plugin only)
|
||||
export { ToastContainer } from './ToastContainer';
|
||||
export type {
|
||||
ToastApiMessageContent,
|
||||
ToastApiMessageLink,
|
||||
ToastApiMessageContainerProps,
|
||||
} from './types';
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 { ReactNode } from 'react';
|
||||
import type { ToastQueue, ToastState, QueuedToast } from 'react-stately';
|
||||
|
||||
/**
|
||||
* Link item for toast notifications
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastApiMessageLink {
|
||||
/** Display text for the link */
|
||||
label: string;
|
||||
/** URL the link points to */
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for a toast notification
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastApiMessageContent {
|
||||
/** Title of the toast (required) */
|
||||
title: ReactNode;
|
||||
/** Optional description text */
|
||||
description?: ReactNode;
|
||||
/** Status variant of the toast */
|
||||
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||
/** Optional array of links to display */
|
||||
links?: ToastApiMessageLink[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the Toast component
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastApiMessageProps {
|
||||
/** Toast object from the queue */
|
||||
toast: QueuedToast<ToastApiMessageContent>;
|
||||
/** Toast state from useToastQueue */
|
||||
state: ToastState<ToastApiMessageContent>;
|
||||
/** 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?: '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) */
|
||||
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
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastApiMessageContainerProps {
|
||||
/** Toast queue instance */
|
||||
queue: ToastQueue<ToastApiMessageContent>;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the ToastDisplay component (AlertApi bridge)
|
||||
* @public
|
||||
*/
|
||||
export interface ToastApiMessageDisplayProps {
|
||||
/**
|
||||
* Number of milliseconds a transient alert will stay open for.
|
||||
* Defaults to 5000ms.
|
||||
*/
|
||||
transientTimeoutMs?: number;
|
||||
/**
|
||||
* Position of the toast on screen.
|
||||
* @deprecated Toast uses fixed bottom-center positioning. This prop is ignored.
|
||||
*/
|
||||
anchorOrigin?: {
|
||||
vertical: 'top' | 'bottom';
|
||||
horizontal: 'left' | 'center' | 'right';
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
VMwareCloudAuth,
|
||||
OpenShiftAuth,
|
||||
} from '../../../packages/core-app-api/src/apis/implementations';
|
||||
import { ToastApiForwarder, toastApiForwarderRef } 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,24 @@ export const apis = [
|
||||
factory: () => new AlertApiForwarder(),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'toast-forwarder',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: toastApiForwarderRef,
|
||||
deps: {},
|
||||
factory: () => new ToastApiForwarder(),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'toast',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: toastApiRef,
|
||||
deps: { forwarder: toastApiForwarderRef },
|
||||
factory: ({ forwarder }) => forwarder,
|
||||
}),
|
||||
}),
|
||||
analyticsApi,
|
||||
ApiBlueprint.make({
|
||||
name: 'error',
|
||||
|
||||
@@ -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,150 @@
|
||||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { appThemeApiRef, AppThemeApi } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { useInvertedThemeMode } from './useInvertedThemeMode';
|
||||
|
||||
// Helper to create a mock AppThemeApi
|
||||
function createMockAppThemeApi(opts?: {
|
||||
activeThemeId?: string;
|
||||
themes?: Array<{ id: string; variant: 'light' | 'dark'; title: string }>;
|
||||
}): AppThemeApi & { setActiveThemeId: (id?: string) => void } {
|
||||
const themes = opts?.themes ?? [
|
||||
{ id: 'light', variant: 'light' as const, title: 'Light' },
|
||||
{ id: 'dark', variant: 'dark' as const, title: 'Dark' },
|
||||
];
|
||||
let activeId = opts?.activeThemeId;
|
||||
const subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<string | undefined>
|
||||
>();
|
||||
|
||||
return {
|
||||
getInstalledThemes: () =>
|
||||
themes.map(t => ({
|
||||
...t,
|
||||
Provider: ({ children }: { children?: ReactNode }) => children,
|
||||
})) as ReturnType<AppThemeApi['getInstalledThemes']>,
|
||||
getActiveThemeId: () => activeId,
|
||||
activeThemeId$: () =>
|
||||
new ObservableImpl<string | undefined>(subscriber => {
|
||||
subscribers.add(subscriber);
|
||||
subscriber.next(activeId);
|
||||
return () => {
|
||||
subscribers.delete(subscriber);
|
||||
};
|
||||
}) as Observable<string | undefined>,
|
||||
setActiveThemeId: (id?: string) => {
|
||||
activeId = id;
|
||||
subscribers.forEach(s => s.next(id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Creates a wrapper that provides the AppThemeApi via TestApiProvider
|
||||
function createWrapper(appThemeApi: AppThemeApi) {
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
createElement(
|
||||
TestApiProvider as any,
|
||||
{ apis: [[appThemeApiRef, appThemeApi]] },
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
describe('useInvertedThemeMode', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return dark when active theme is light', () => {
|
||||
const appThemeApi = createMockAppThemeApi({ activeThemeId: 'light' });
|
||||
const { result } = renderHook(() => useInvertedThemeMode(), {
|
||||
wrapper: createWrapper(appThemeApi),
|
||||
});
|
||||
expect(result.current).toBe('dark');
|
||||
});
|
||||
|
||||
it('should return light when active theme is dark', () => {
|
||||
const appThemeApi = createMockAppThemeApi({ activeThemeId: 'dark' });
|
||||
const { result } = renderHook(() => useInvertedThemeMode(), {
|
||||
wrapper: createWrapper(appThemeApi),
|
||||
});
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
|
||||
it('should use system preference when no theme is selected (auto mode, prefers dark)', () => {
|
||||
const appThemeApi = createMockAppThemeApi({ activeThemeId: undefined });
|
||||
const { result } = renderHook(() => useInvertedThemeMode(), {
|
||||
wrapper: createWrapper(appThemeApi),
|
||||
});
|
||||
// matchMedia is unavailable in jsdom → prefersDark defaults to false
|
||||
// → picks light theme → inverted is dark
|
||||
expect(result.current).toBe('dark');
|
||||
});
|
||||
|
||||
it('should update when active theme changes', async () => {
|
||||
const appThemeApi = createMockAppThemeApi({ activeThemeId: 'light' });
|
||||
const { result } = renderHook(() => useInvertedThemeMode(), {
|
||||
wrapper: createWrapper(appThemeApi),
|
||||
});
|
||||
expect(result.current).toBe('dark');
|
||||
|
||||
await act(async () => {
|
||||
appThemeApi.setActiveThemeId('dark');
|
||||
});
|
||||
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
|
||||
it('should fall back to first installed theme when no match', () => {
|
||||
const appThemeApi = createMockAppThemeApi({
|
||||
activeThemeId: undefined,
|
||||
themes: [{ id: 'custom', variant: 'dark', title: 'Custom' }],
|
||||
});
|
||||
const { result } = renderHook(() => useInvertedThemeMode(), {
|
||||
wrapper: createWrapper(appThemeApi),
|
||||
});
|
||||
// Only dark theme, system prefers light (default) but no light theme
|
||||
// → falls back to first theme (dark) → inverted is light
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
|
||||
it('should default to dark when no themes are installed', () => {
|
||||
const appThemeApi = createMockAppThemeApi({
|
||||
activeThemeId: undefined,
|
||||
themes: [],
|
||||
});
|
||||
const { result } = renderHook(() => useInvertedThemeMode(), {
|
||||
wrapper: createWrapper(appThemeApi),
|
||||
});
|
||||
// No themes → can't determine variant → defaults to 'dark'
|
||||
expect(result.current).toBe('dark');
|
||||
});
|
||||
|
||||
it('should handle matchMedia being unavailable gracefully', () => {
|
||||
const appThemeApi = createMockAppThemeApi({ activeThemeId: undefined });
|
||||
const { result } = renderHook(() => useInvertedThemeMode(), {
|
||||
wrapper: createWrapper(appThemeApi),
|
||||
});
|
||||
// matchMedia is not available in jsdom → prefersDark defaults to false
|
||||
// → picks light variant → inverted is dark
|
||||
expect(result.current).toBe('dark');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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, useMemo } from 'react';
|
||||
import { useApi, appThemeApiRef } from '@backstage/core-plugin-api';
|
||||
import useObservable from 'react-use/esm/useObservable';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* Returns the inverted theme mode based on the active app theme.
|
||||
* Uses the AppThemeApi to detect the current theme variant reactively.
|
||||
* When the theme is set to "auto" (no explicit selection), falls back to
|
||||
* the system preference via `prefers-color-scheme`.
|
||||
*
|
||||
* @returns The inverted theme mode ('light' when app is dark, 'dark' when app is light)
|
||||
* @internal
|
||||
*/
|
||||
export function useInvertedThemeMode(): ThemeMode {
|
||||
const appThemeApi = useApi(appThemeApiRef);
|
||||
|
||||
const themeId = useObservable(
|
||||
appThemeApi.activeThemeId$(),
|
||||
appThemeApi.getActiveThemeId(),
|
||||
);
|
||||
|
||||
// Track system color scheme preference for "auto" mode.
|
||||
// Guard against environments where matchMedia is unavailable (e.g. jsdom in tests).
|
||||
const mediaQuery = useMemo(
|
||||
() =>
|
||||
typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)')
|
||||
: undefined,
|
||||
[],
|
||||
);
|
||||
const [prefersDark, setPrefersDark] = useState(mediaQuery?.matches ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaQuery) return undefined;
|
||||
const listener = (e: MediaQueryListEvent) => setPrefersDark(e.matches);
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
return () => mediaQuery.removeEventListener('change', listener);
|
||||
}, [mediaQuery]);
|
||||
|
||||
// Resolve the active theme's variant, matching AppThemeProvider's logic:
|
||||
// 1. If a theme is explicitly selected, use its variant
|
||||
// 2. Otherwise (auto mode), use system preference to pick dark or light
|
||||
// 3. Fall back to the first installed theme
|
||||
const themes = appThemeApi.getInstalledThemes();
|
||||
let currentVariant: ThemeMode | undefined;
|
||||
|
||||
if (themeId !== undefined) {
|
||||
currentVariant = themes.find(t => t.id === themeId)?.variant;
|
||||
}
|
||||
|
||||
if (!currentVariant) {
|
||||
if (prefersDark) {
|
||||
currentVariant = themes.find(t => t.variant === 'dark')?.variant;
|
||||
}
|
||||
currentVariant ??= themes.find(t => t.variant === 'light')?.variant;
|
||||
currentVariant ??= themes[0]?.variant;
|
||||
}
|
||||
|
||||
// Invert: if current is dark, toast should be light, and vice versa
|
||||
// Default to 'dark' if we can't determine (safe for most light-themed apps)
|
||||
if (currentVariant === 'dark') {
|
||||
return 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
@@ -4120,16 +4120,22 @@ __metadata:
|
||||
"@material-ui/core": "npm:^4.9.13"
|
||||
"@material-ui/icons": "npm:^4.9.1"
|
||||
"@material-ui/lab": "npm:^4.0.0-alpha.61"
|
||||
"@react-aria/button": "npm:^3.14.3"
|
||||
"@react-aria/toast": "npm:^3.0.9"
|
||||
"@react-hookz/web": "npm:^24.0.0"
|
||||
"@react-stately/toast": "npm:^3.1.2"
|
||||
"@remixicon/react": "npm:^4.6.0"
|
||||
"@testing-library/jest-dom": "npm:^6.0.0"
|
||||
"@testing-library/react": "npm:^16.0.0"
|
||||
"@testing-library/user-event": "npm:^14.0.0"
|
||||
"@types/react": "npm:^18.0.0"
|
||||
motion: "npm:^12.0.0"
|
||||
msw: "npm:^1.0.0"
|
||||
react: "npm:^18.0.2"
|
||||
react-dom: "npm:^18.0.2"
|
||||
react-router-dom: "npm:^6.30.2"
|
||||
react-use: "npm:^17.2.4"
|
||||
zen-observable: "npm:^0.10.0"
|
||||
zod: "npm:^3.25.76"
|
||||
peerDependencies:
|
||||
"@types/react": ^17.0.0 || ^18.0.0
|
||||
@@ -15704,7 +15710,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/button@npm:^3.14.5":
|
||||
"@react-aria/button@npm:^3.14.3, @react-aria/button@npm:^3.14.5":
|
||||
version: 3.14.5
|
||||
resolution: "@react-aria/button@npm:3.14.5"
|
||||
dependencies:
|
||||
@@ -16453,7 +16459,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/toast@npm:^3.0.11":
|
||||
"@react-aria/toast@npm:^3.0.11, @react-aria/toast@npm:^3.0.9":
|
||||
version: 3.0.11
|
||||
resolution: "@react-aria/toast@npm:3.0.11"
|
||||
dependencies:
|
||||
@@ -16978,7 +16984,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/toast@npm:^3.1.3":
|
||||
"@react-stately/toast@npm:^3.1.2, @react-stately/toast@npm:^3.1.3":
|
||||
version: 3.1.3
|
||||
resolution: "@react-stately/toast@npm:3.1.3"
|
||||
dependencies:
|
||||
@@ -32111,6 +32117,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"framer-motion@npm:^12.38.0":
|
||||
version: 12.38.0
|
||||
resolution: "framer-motion@npm:12.38.0"
|
||||
dependencies:
|
||||
motion-dom: "npm:^12.38.0"
|
||||
motion-utils: "npm:^12.36.0"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/4d529d1648a8e31ec9859e7ff1296b7e4ef0028eb09cbc7d626068766ab53e486038b431fac33b1438a1cc076a244e6843c5a8c0f38442885832308452b4b25e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"framer-motion@npm:^6.5.1":
|
||||
version: 6.5.1
|
||||
resolution: "framer-motion@npm:6.5.1"
|
||||
@@ -39449,6 +39477,43 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-dom@npm:^12.38.0":
|
||||
version: 12.38.0
|
||||
resolution: "motion-dom@npm:12.38.0"
|
||||
dependencies:
|
||||
motion-utils: "npm:^12.36.0"
|
||||
checksum: 10/78c040b46d93273932cf80c70e39845be5a442dcaf18d4345b45a9193de9dfa87c885b609943cb652115e4eac5d46ef40b452185073dd43fc328b134f9975e90
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-utils@npm:^12.36.0":
|
||||
version: 12.36.0
|
||||
resolution: "motion-utils@npm:12.36.0"
|
||||
checksum: 10/c4a2a7ffac48ca44082d6d31b115f245025060a7e69d70dac062646d8f96c39e5662a7c8a51f255566fdf8e719ef1269a8e9aa3a04fc263bb65b5a7b61331901
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion@npm:^12.0.0":
|
||||
version: 12.38.0
|
||||
resolution: "motion@npm:12.38.0"
|
||||
dependencies:
|
||||
framer-motion: "npm:^12.38.0"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/d7ae2ba3cc112c4467822956b92065239640b9c62204d3bee1780da9fc0147185373534138d39975e82bf73b5f1b28d3fb3581031e4e7e0cfb230472767bd10d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mri@npm:1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "mri@npm:1.1.4"
|
||||
|
||||
Reference in New Issue
Block a user