From d007f231629775de293c1133e2d0fe424b299b08 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 9 Feb 2026 12:03:30 +0000 Subject: [PATCH] Address another set of feedbacks Signed-off-by: Charles de Dreuille --- .changeset/deprecate-alert-api.md | 23 ++-- .changeset/toast-api-introduction.md | 10 +- .../src/apis/definitions/ToastApi.ts | 45 ++++---- .../app/src/apis/ToastApiForwarder.test.ts | 102 +++++++++--------- plugins/app/src/apis/ToastApiForwarder.ts | 73 +++++++------ .../src/components/Toast/Toast.stories.tsx | 6 +- plugins/app/src/components/Toast/Toast.tsx | 4 +- .../src/components/Toast/ToastContainer.tsx | 4 +- .../components/Toast/ToastDisplay.test.tsx | 7 +- .../app/src/components/Toast/ToastDisplay.tsx | 42 +++----- plugins/app/src/components/Toast/index.ts | 8 +- plugins/app/src/components/Toast/types.ts | 18 ++-- plugins/app/src/hooks/useInvertedThemeMode.ts | 82 +++++++------- plugins/app/src/index.ts | 2 +- 14 files changed, 221 insertions(+), 205 deletions(-) diff --git a/.changeset/deprecate-alert-api.md b/.changeset/deprecate-alert-api.md index c9cd98a5a1..6334d03167 100644 --- a/.changeset/deprecate-alert-api.md +++ b/.changeset/deprecate-alert-api.md @@ -1,7 +1,5 @@ --- '@backstage/frontend-plugin-api': patch -'@backstage/core-plugin-api': patch -'@backstage/core-app-api': patch --- Deprecated `AlertApi` in favor of the new `ToastApi`. @@ -16,18 +14,18 @@ Deprecated `AlertApi` in favor of the new `ToastApi`. - **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 key returned from `post()` +- **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 `string` (key for dismiss) | +| 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** @@ -46,11 +44,12 @@ alertApi.post({ import { toastApiRef, useApi } from '@backstage/frontend-plugin-api'; const toastApi = useApi(toastApiRef); -toastApi.post({ +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. diff --git a/.changeset/toast-api-introduction.md b/.changeset/toast-api-introduction.md index b5fab6cf35..8f44c4eae1 100644 --- a/.changeset/toast-api-introduction.md +++ b/.changeset/toast-api-introduction.md @@ -1,6 +1,6 @@ --- -'@backstage/frontend-plugin-api': minor -'@backstage/plugin-app': minor +'@backstage/frontend-plugin-api': patch +'@backstage/plugin-app': patch --- Introduced a new `ToastApi` for displaying rich toast notifications in the new frontend system. @@ -11,7 +11,7 @@ The new `ToastApi` provides enhanced notification capabilities compared to the e - **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 key returned from `post()` +- **Programmatic Dismiss**: Toasts can be dismissed programmatically using the `close()` handle returned from `post()` **Usage:** @@ -30,9 +30,9 @@ toastApi.post({ }); // Programmatic dismiss -const key = toastApi.post({ title: 'Uploading...', status: 'info' }); +const { close } = toastApi.post({ title: 'Uploading...', status: 'info' }); // Later... -toastApi.close(key); +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. diff --git a/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts index cccd6cde00..b56c9bd5da 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts @@ -49,13 +49,33 @@ export type ToastApiMessage = { }; /** - * Toast message with key, as returned by the toast$() observable. + * Handle returned by {@link ToastApi.post} that allows programmatic control + * of the posted toast. + * + * @public + */ +export type ToastApiPostResult = { + /** Dismiss the toast. */ + close(): void; +}; + +/** + * Toast message with key, as emitted by the toast$() observable. * * @public */ export type ToastApiMessageWithKey = ToastApiMessage & { - /** Unique key for the toast, used for programmatic dismiss */ + /** Unique key for the toast, used internally for tracking */ key: string; + /** Dismiss this toast programmatically */ + close(): void; + /** + * Register a callback that fires when this toast is closed. + * Used internally by the toast display to sync with the rendering queue. + * + * @internal + */ + onClose(callback: () => void): void; }; /** @@ -82,9 +102,9 @@ export type ToastApiMessageWithKey = ToastApiMessage & { * toastApi.post({ title: 'Processing...', status: 'info' }); * * // Programmatic dismiss - * const key = toastApi.post({ title: 'Uploading...', status: 'info' }); + * const { close } = toastApi.post({ title: 'Uploading...', status: 'info' }); * // Later... - * toastApi.close(key); + * close(); * ``` * * @public @@ -94,27 +114,14 @@ export type ToastApi = { * Post a toast notification for display to the user. * * @param toast - The toast message to display - * @returns A unique key that can be used to programmatically dismiss the toast + * @returns A handle with a `close()` method to programmatically dismiss the toast */ - post(toast: ToastApiMessage): string; - - /** - * Programmatically close/dismiss a toast by its key. - * - * @param key - The key returned from post() - */ - close(key: string): void; + post(toast: ToastApiMessage): ToastApiPostResult; /** * Observe toasts posted by other parts of the application. */ toast$(): Observable; - - /** - * Observe close events for programmatic toast dismissal. - * Emits the key of the toast that should be closed. - */ - close$(): Observable; }; /** diff --git a/plugins/app/src/apis/ToastApiForwarder.test.ts b/plugins/app/src/apis/ToastApiForwarder.test.ts index 1dbebb1ef7..6a0b152322 100644 --- a/plugins/app/src/apis/ToastApiForwarder.test.ts +++ b/plugins/app/src/apis/ToastApiForwarder.test.ts @@ -24,13 +24,11 @@ describe('ToastApiForwarder', () => { }); describe('post', () => { - it('should return a unique key for each toast', () => { - const key1 = forwarder.post({ title: 'Toast 1' }); - const key2 = forwarder.post({ title: 'Toast 2' }); + it('should return a result with a close method', () => { + const result = forwarder.post({ title: 'Toast 1' }); - expect(key1).toBeDefined(); - expect(key2).toBeDefined(); - expect(key1).not.toBe(key2); + expect(result).toBeDefined(); + expect(typeof result.close).toBe('function'); }); it('should emit toast to subscribers', () => { @@ -78,23 +76,40 @@ describe('ToastApiForwarder', () => { }); describe('close', () => { - it('should emit close event to subscribers', () => { - const closedKeys: string[] = []; + it('should notify onClose listeners when close() is called', () => { + const onCloseFn = jest.fn(); - forwarder.close$().subscribe(key => { - closedKeys.push(key); + const received: Array<{ onClose: (cb: () => void) => void }> = []; + forwarder.toast$().subscribe(toast => { + received.push(toast); }); - const key = forwarder.post({ title: 'Test' }); - forwarder.close(key); + const result = forwarder.post({ title: 'Test' }); + received[0].onClose(onCloseFn); + result.close(); - expect(closedKeys).toHaveLength(1); - expect(closedKeys[0]).toBe(key); + 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 key = forwarder.post({ title: 'Test' }); - forwarder.close(key); + const result = forwarder.post({ title: 'Test' }); + result.close(); // New subscriber should not receive the closed toast const received: Array<{ key: string }> = []; @@ -104,6 +119,22 @@ describe('ToastApiForwarder', () => { 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', () => { @@ -134,10 +165,10 @@ describe('ToastApiForwarder', () => { }); it('should not replay closed toasts to new subscribers', async () => { - const key1 = forwarder.post({ title: 'Toast 1' }); + const result1 = forwarder.post({ title: 'Toast 1' }); forwarder.post({ title: 'Toast 2' }); - forwarder.close(key1); + result1.close(); const received: Array<{ title: unknown }> = []; @@ -184,22 +215,6 @@ describe('ToastApiForwarder', () => { }); }); - describe('close$ observable', () => { - it('should allow multiple subscribers', () => { - const subscriber1: string[] = []; - const subscriber2: string[] = []; - - forwarder.close$().subscribe(key => subscriber1.push(key)); - forwarder.close$().subscribe(key => subscriber2.push(key)); - - const key = forwarder.post({ title: 'Test' }); - forwarder.close(key); - - expect(subscriber1).toEqual([key]); - expect(subscriber2).toEqual([key]); - }); - }); - describe('subscription cleanup', () => { it('should stop receiving toasts after unsubscribe', () => { const received: Array<{ title: unknown }> = []; @@ -215,24 +230,5 @@ describe('ToastApiForwarder', () => { expect(received).toHaveLength(1); expect(received[0].title).toBe('Before unsubscribe'); }); - - it('should stop receiving close events after unsubscribe', () => { - const closedKeys: string[] = []; - - const subscription = forwarder.close$().subscribe(key => { - closedKeys.push(key); - }); - - const key1 = forwarder.post({ title: 'Toast 1' }); - forwarder.close(key1); - - subscription.unsubscribe(); - - const key2 = forwarder.post({ title: 'Toast 2' }); - forwarder.close(key2); - - expect(closedKeys).toHaveLength(1); - expect(closedKeys[0]).toBe(key1); - }); }); }); diff --git a/plugins/app/src/apis/ToastApiForwarder.ts b/plugins/app/src/apis/ToastApiForwarder.ts index 800dcad038..5dc7b7e240 100644 --- a/plugins/app/src/apis/ToastApiForwarder.ts +++ b/plugins/app/src/apis/ToastApiForwarder.ts @@ -18,6 +18,7 @@ import { ToastApi, ToastApiMessage, ToastApiMessageWithKey, + ToastApiPostResult, } from '@backstage/frontend-plugin-api'; import { Observable } from '@backstage/types'; import ObservableImpl from 'zen-observable'; @@ -86,14 +87,51 @@ class PublishSubject { */ export class ToastApiForwarder implements ToastApi { private readonly subject = new PublishSubject(); - private readonly closeSubject = new PublishSubject(); private readonly recentToasts: ToastApiMessageWithKey[] = []; private readonly closedKeys = new Set(); private readonly maxBufferSize = 10; - post(toast: ToastApiMessage): string { + post(toast: ToastApiMessage): ToastApiPostResult { const key = generateToastKey(); - const toastWithKey: ToastApiMessageWithKey = { ...toast, key }; + 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: ToastApiMessageWithKey = { + ...toast, + key, + close, + onClose, + }; this.recentToasts.push(toastWithKey); if (this.recentToasts.length > this.maxBufferSize) { @@ -101,24 +139,7 @@ export class ToastApiForwarder implements ToastApi { } this.subject.next(toastWithKey); - return key; - } - - close(key: string): void { - // 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); - } - this.closeSubject.next(key); - - // Clean up old closed keys when buffer is cleared - if (this.recentToasts.length === 0) { - this.closedKeys.clear(); - } + return { close }; } toast$(): Observable { @@ -128,14 +149,4 @@ export class ToastApiForwarder implements ToastApi { ); return this.subject.asObservable(activeToasts); } - - /** - * Observe close requests for toasts. - * This is used internally by the ToastDisplay to know when to dismiss a toast programmatically. - * - * @internal - */ - close$(): Observable { - return this.closeSubject.asObservable(); - } } diff --git a/plugins/app/src/components/Toast/Toast.stories.tsx b/plugins/app/src/components/Toast/Toast.stories.tsx index 21f8a40e3c..f008bc2550 100644 --- a/plugins/app/src/components/Toast/Toast.stories.tsx +++ b/plugins/app/src/components/Toast/Toast.stories.tsx @@ -20,10 +20,12 @@ 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 { ToastContent } from './types'; +import type { ToastApiMessageContent } from './types'; import { MemoryRouter } from 'react-router-dom'; -const toastQueue = new ToastQueue({ maxVisibleToasts: 4 }); +const toastQueue = new ToastQueue({ + maxVisibleToasts: 4, +}); const meta = preview.meta({ title: 'Plugins/App/Toast', diff --git a/plugins/app/src/components/Toast/Toast.tsx b/plugins/app/src/components/Toast/Toast.tsx index 0a62c129e0..331a53958a 100644 --- a/plugins/app/src/components/Toast/Toast.tsx +++ b/plugins/app/src/components/Toast/Toast.tsx @@ -25,7 +25,7 @@ import { RiAlertLine, RiCloseLine, } from '@remixicon/react'; -import type { ToastProps } from './types'; +import type { ToastApiMessageProps } from './types'; import styles from './Toast.module.css'; // Track which toasts are being manually closed (vs auto-timeout) @@ -43,7 +43,7 @@ const manuallyClosingToasts = new Set(); * @internal */ export const Toast = forwardRef( - (props: ToastProps, ref: Ref) => { + (props: ToastApiMessageProps, ref: Ref) => { const { toast, state, diff --git a/plugins/app/src/components/Toast/ToastContainer.tsx b/plugins/app/src/components/Toast/ToastContainer.tsx index 94394e204d..361ec13082 100644 --- a/plugins/app/src/components/Toast/ToastContainer.tsx +++ b/plugins/app/src/components/Toast/ToastContainer.tsx @@ -18,7 +18,7 @@ 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 { ToastContainerProps } from './types'; +import type { ToastApiMessageContainerProps } from './types'; import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode'; import { Toast } from './Toast'; import styles from './Toast.module.css'; @@ -36,7 +36,7 @@ import styles from './Toast.module.css'; * @internal */ export const ToastContainer = forwardRef( - (props: ToastContainerProps, ref: Ref) => { + (props: ToastApiMessageContainerProps, ref: Ref) => { const { queue, className } = props; // Subscribe to the toast queue state diff --git a/plugins/app/src/components/Toast/ToastDisplay.test.tsx b/plugins/app/src/components/Toast/ToastDisplay.test.tsx index 53730ba37a..45a01eec86 100644 --- a/plugins/app/src/components/Toast/ToastDisplay.test.tsx +++ b/plugins/app/src/components/Toast/ToastDisplay.test.tsx @@ -134,13 +134,14 @@ describe('ToastDisplay', () => { it('should allow programmatic dismiss via close()', async () => { renderToastDisplay(); - let toastKey: string; + let closeToast: () => void; await act(async () => { - toastKey = toastApi.post({ + const result = toastApi.post({ title: 'Dismissable Toast', status: 'info', }); + closeToast = result.close; }); await expect( @@ -148,7 +149,7 @@ describe('ToastDisplay', () => { ).resolves.toBeInTheDocument(); await act(async () => { - toastApi.close(toastKey!); + closeToast!(); // Wait for animation await new Promise(resolve => setTimeout(resolve, 600)); }); diff --git a/plugins/app/src/components/Toast/ToastDisplay.tsx b/plugins/app/src/components/Toast/ToastDisplay.tsx index 21791b8256..059f9e8231 100644 --- a/plugins/app/src/components/Toast/ToastDisplay.tsx +++ b/plugins/app/src/components/Toast/ToastDisplay.tsx @@ -14,12 +14,15 @@ * limitations under the License. */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { alertApiRef, useApi } from '@backstage/core-plugin-api'; import { toastApiRef } from '@backstage/frontend-plugin-api'; import { ToastQueue } from '@react-stately/toast'; import { ToastContainer } from './ToastContainer'; -import type { ToastDisplayProps, ToastContent } from './types'; +import type { + ToastApiMessageDisplayProps, + ToastApiMessageContent, +} from './types'; /** * Maps AlertApi severity to Toast status. @@ -27,7 +30,7 @@ import type { ToastDisplayProps, ToastContent } from './types'; */ function mapSeverity( severity: 'success' | 'info' | 'warning' | 'error' | undefined, -): ToastContent['status'] { +): ToastApiMessageContent['status'] { if (severity === 'error') { return 'danger'; } @@ -49,7 +52,7 @@ function mapSeverity( * **ToastApi (recommended):** * - Uses toast content directly (title, description, status, icon, links) * - Uses the provided timeout from the toast message - * - Supports programmatic dismiss via returned key + * - Supports programmatic dismiss via the returned `close()` handle * * **AlertApi (deprecated - please migrate to ToastApi):** * - `alert.message` → `toast.title` @@ -64,12 +67,13 @@ function mapSeverity( * // Using the new ToastApi (recommended): * import { toastApiRef, useApi } from '@backstage/frontend-plugin-api'; * const toastApi = useApi(toastApiRef); - * toastApi.post({ + * 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'; @@ -79,23 +83,20 @@ function mapSeverity( * * @public */ -export function ToastDisplay(props: ToastDisplayProps) { +export function ToastDisplay(props: ToastApiMessageDisplayProps) { const alertApi = useApi(alertApiRef); const toastApi = useApi(toastApiRef); const { transientTimeoutMs = 5000 } = props; // Create toast queue once per component instance const [toastQueue] = useState( - () => new ToastQueue({ maxVisibleToasts: 4 }), + () => new ToastQueue({ maxVisibleToasts: 4 }), ); - // Track toast keys for programmatic close - const toastKeyMap = useRef>(new Map()); - // Subscribe to ToastApi useEffect(() => { const subscription = toastApi.toast$().subscribe(toast => { - const content: ToastContent = { + const content: ToastApiMessageContent = { title: toast.title, description: toast.description, status: toast.status ?? 'success', @@ -107,21 +108,8 @@ export function ToastDisplay(props: ToastDisplayProps) { const queueKey = toastQueue.add(content, options); - // Track the mapping from API key to queue key for programmatic close - toastKeyMap.current.set(toast.key, queueKey); - }); - - return () => subscription.unsubscribe(); - }, [toastApi, toastQueue]); - - // Subscribe to ToastApi close events for programmatic dismissal - useEffect(() => { - const subscription = toastApi.close$().subscribe(apiKey => { - const queueKey = toastKeyMap.current.get(apiKey); - if (queueKey) { - toastQueue.close(queueKey); - toastKeyMap.current.delete(apiKey); - } + // When the toast is programmatically closed, remove it from the queue + toast.onClose(() => toastQueue.close(queueKey)); }); return () => subscription.unsubscribe(); @@ -131,7 +119,7 @@ export function ToastDisplay(props: ToastDisplayProps) { // This subscription will be removed when AlertApi is fully deprecated useEffect(() => { const subscription = alertApi.alert$().subscribe(alert => { - const content: ToastContent = { + const content: ToastApiMessageContent = { title: alert.message, status: mapSeverity(alert.severity), }; diff --git a/plugins/app/src/components/Toast/index.ts b/plugins/app/src/components/Toast/index.ts index f7b7ce71ef..220f0f3646 100644 --- a/plugins/app/src/components/Toast/index.ts +++ b/plugins/app/src/components/Toast/index.ts @@ -16,8 +16,12 @@ // Public exports export { ToastDisplay } from './ToastDisplay'; -export type { ToastDisplayProps } from './types'; +export type { ToastApiMessageDisplayProps } from './types'; // Internal exports (used within the plugin only) export { ToastContainer } from './ToastContainer'; -export type { ToastContent, ToastLink, ToastContainerProps } from './types'; +export type { + ToastApiMessageContent, + ToastApiMessageLink, + ToastApiMessageContainerProps, +} from './types'; diff --git a/plugins/app/src/components/Toast/types.ts b/plugins/app/src/components/Toast/types.ts index 9eb2148af3..68f98f219f 100644 --- a/plugins/app/src/components/Toast/types.ts +++ b/plugins/app/src/components/Toast/types.ts @@ -21,7 +21,7 @@ import type { ToastQueue, ToastState, QueuedToast } from 'react-stately'; * Link item for toast notifications * @internal */ -export interface ToastLink { +export interface ToastApiMessageLink { /** Display text for the link */ label: string; /** URL the link points to */ @@ -32,7 +32,7 @@ export interface ToastLink { * Content for a toast notification * @internal */ -export interface ToastContent { +export interface ToastApiMessageContent { /** Title of the toast (required) */ title: ReactNode; /** Optional description text */ @@ -40,18 +40,18 @@ export interface ToastContent { /** Status variant of the toast */ status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; /** Optional array of links to display */ - links?: ToastLink[]; + links?: ToastApiMessageLink[]; } /** * Props for the Toast component * @internal */ -export interface ToastProps { +export interface ToastApiMessageProps { /** Toast object from the queue */ - toast: QueuedToast; + toast: QueuedToast; /** Toast state from useToastQueue */ - state: ToastState; + state: ToastState; /** Index of the toast in the visible toasts array */ index?: number; /** Whether the toast stack is expanded (hovered/focused) */ @@ -74,9 +74,9 @@ export interface ToastProps { * Props for the ToastContainer component * @internal */ -export interface ToastContainerProps { +export interface ToastApiMessageContainerProps { /** Toast queue instance */ - queue: ToastQueue; + queue: ToastQueue; /** Custom class name */ className?: string; } @@ -85,7 +85,7 @@ export interface ToastContainerProps { * Props for the ToastDisplay component (AlertApi bridge) * @public */ -export interface ToastDisplayProps { +export interface ToastApiMessageDisplayProps { /** * Number of milliseconds a transient alert will stay open for. * Defaults to 5000ms. diff --git a/plugins/app/src/hooks/useInvertedThemeMode.ts b/plugins/app/src/hooks/useInvertedThemeMode.ts index cdad328fd5..19673fc8dd 100644 --- a/plugins/app/src/hooks/useInvertedThemeMode.ts +++ b/plugins/app/src/hooks/useInvertedThemeMode.ts @@ -14,57 +14,65 @@ * limitations under the License. */ -import { useState, useEffect } from 'react'; +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'; /** - * Detects the current theme mode from the DOM and returns the inverted value. - * Checks both document.documentElement and document.body for the data-theme-mode attribute. + * Returns the inverted theme mode 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 [invertedThemeMode, setInvertedThemeMode] = useState('dark'); + const appThemeApi = useApi(appThemeApiRef); + + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + + // Track system color scheme preference for "auto" mode + const mediaQuery = useMemo( + () => window.matchMedia('(prefers-color-scheme: dark)'), + [], + ); + const [prefersDark, setPrefersDark] = useState(mediaQuery.matches); useEffect(() => { - const detectTheme = (): ThemeMode => { - // Check both html and body elements for the theme attribute - const htmlTheme = - document.documentElement.getAttribute('data-theme-mode'); - const bodyTheme = document.body.getAttribute('data-theme-mode'); + const listener = (e: MediaQueryListEvent) => setPrefersDark(e.matches); + mediaQuery.addEventListener('change', listener); + return () => mediaQuery.removeEventListener('change', listener); + }, [mediaQuery]); - // Prefer body (used by UnifiedThemeProvider) over html - const currentTheme = bodyTheme || htmlTheme; + // 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 current theme is dark, return light (inverted), otherwise return dark - return currentTheme === 'dark' ? 'light' : 'dark'; - }; + if (themeId !== undefined) { + currentVariant = themes.find(t => t.id === themeId)?.variant; + } - // Initial detection - setInvertedThemeMode(detectTheme()); - - // Watch for theme changes on both html and body - const observer = new MutationObserver(() => { - setInvertedThemeMode(detectTheme()); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-theme-mode'], - }); - - // Body might not exist in some edge cases (e.g., during SSR or early lifecycle) - if (document.body) { - observer.observe(document.body, { - attributes: true, - attributeFilter: ['data-theme-mode'], - }); + if (!currentVariant) { + if (prefersDark) { + currentVariant = themes.find(t => t.variant === 'dark')?.variant; } + currentVariant ??= themes.find(t => t.variant === 'light')?.variant; + currentVariant ??= themes[0]?.variant; + } - return () => observer.disconnect(); - }, []); - - return invertedThemeMode; + // 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'; } diff --git a/plugins/app/src/index.ts b/plugins/app/src/index.ts index ac1df118e3..3acae34951 100644 --- a/plugins/app/src/index.ts +++ b/plugins/app/src/index.ts @@ -18,4 +18,4 @@ export { appPlugin as default } from './plugin'; // Toast components for alert display export { ToastDisplay } from './components/Toast'; -export type { ToastDisplayProps } from './components/Toast'; +export type { ToastApiMessageDisplayProps } from './components/Toast';