Address another set of feedbacks

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-09 12:03:30 +00:00
committed by Patrik Oldsberg
parent 033ec31a33
commit d007f23162
14 changed files with 221 additions and 205 deletions
+11 -12
View File
@@ -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.
+5 -5
View File
@@ -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.
@@ -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<ToastApiMessageWithKey>;
/**
* Observe close events for programmatic toast dismissal.
* Emits the key of the toast that should be closed.
*/
close$(): Observable<string>;
};
/**
+49 -53
View File
@@ -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);
});
});
});
+42 -31
View File
@@ -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<T> {
*/
export class ToastApiForwarder implements ToastApi {
private readonly subject = new PublishSubject<ToastApiMessageWithKey>();
private readonly closeSubject = new PublishSubject<string>();
private readonly recentToasts: ToastApiMessageWithKey[] = [];
private readonly closedKeys = new Set<string>();
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<ToastApiMessageWithKey> {
@@ -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<string> {
return this.closeSubject.asObservable();
}
}
@@ -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<ToastContent>({ maxVisibleToasts: 4 });
const toastQueue = new ToastQueue<ToastApiMessageContent>({
maxVisibleToasts: 4,
});
const meta = preview.meta({
title: 'Plugins/App/Toast',
+2 -2
View File
@@ -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<string>();
* @internal
*/
export const Toast = forwardRef(
(props: ToastProps, ref: Ref<HTMLDivElement>) => {
(props: ToastApiMessageProps, ref: Ref<HTMLDivElement>) => {
const {
toast,
state,
@@ -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<HTMLDivElement>) => {
(props: ToastApiMessageContainerProps, ref: Ref<HTMLDivElement>) => {
const { queue, className } = props;
// Subscribe to the toast queue state
@@ -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));
});
@@ -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<ToastContent>({ maxVisibleToasts: 4 }),
() => new ToastQueue<ToastApiMessageContent>({ maxVisibleToasts: 4 }),
);
// Track toast keys for programmatic close
const toastKeyMap = useRef<Map<string, string>>(new Map());
// Subscribe to ToastApi
useEffect(() => {
const subscription = toastApi.toast$().subscribe(toast => {
const content: ToastContent = {
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),
};
+6 -2
View File
@@ -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';
+9 -9
View File
@@ -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<ToastContent>;
toast: QueuedToast<ToastApiMessageContent>;
/** Toast state from useToastQueue */
state: ToastState<ToastContent>;
state: ToastState<ToastApiMessageContent>;
/** 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<ToastContent>;
queue: ToastQueue<ToastApiMessageContent>;
/** 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.
+45 -37
View File
@@ -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<ThemeMode>('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';
}
+1 -1
View File
@@ -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';