Remove unnecessary Toast in BUI
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
91d9dccb5c
commit
20b5470692
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/ui': minor
|
||||
---
|
||||
|
||||
Added Toast component in Backstage UI with support for status variants, auto-dismiss, links, and stacking.
|
||||
@@ -47,13 +47,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"@react-aria/toast": "^3.0.9",
|
||||
"@remixicon/react": "^4.6.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^12.0.0",
|
||||
"react-aria-components": "^1.14.0",
|
||||
"react-stately": "^3.35.0"
|
||||
"react-aria-components": "^1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@layer tokens, base, components, utilities;
|
||||
|
||||
@layer components {
|
||||
/* Toast Container - Container for all toasts (bottom center) */
|
||||
.bui-ToastContainer {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100050;
|
||||
width: 300px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
.bui-ToastContainer {
|
||||
width: 350px;
|
||||
bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Individual Toast */
|
||||
.bui-Toast {
|
||||
/* CSS Variables */
|
||||
--toast-peek: 0.75rem;
|
||||
--toast-scale: calc(max(0, 1 - (var(--toast-index) * 0.05)));
|
||||
|
||||
/* Layout */
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: var(--bui-space-4);
|
||||
gap: var(--bui-space-3);
|
||||
|
||||
/* Appearance */
|
||||
border-radius: var(--bui-radius-3);
|
||||
background-color: var(--bui-bg-surface-1);
|
||||
font-family: var(--bui-font-regular);
|
||||
font-size: var(--bui-font-size-3);
|
||||
outline: none;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
/* All toasts need pointer-events to trigger hover expansion */
|
||||
pointer-events: auto;
|
||||
|
||||
/* Stacking */
|
||||
z-index: calc(1000 - var(--toast-index));
|
||||
transform-origin: bottom center;
|
||||
|
||||
/* Shadow */
|
||||
box-shadow: 0 4px 12px -2px rgba(0 0 0 / 0.4);
|
||||
|
||||
/* Focus ring */
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bui-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extend hover area above each toast to fill gaps when expanded */
|
||||
/* This prevents the stack from collapsing when hovering between toasts */
|
||||
.bui-Toast::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* Height matches the expanded gap (80px) to ensure continuous hover */
|
||||
height: 12px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Status variants - color icon and title only */
|
||||
.bui-Toast[data-status='info'] .bui-ToastIcon,
|
||||
.bui-Toast[data-status='info'] .bui-ToastTitle {
|
||||
color: var(--bui-fg-info);
|
||||
}
|
||||
|
||||
.bui-Toast[data-status='success'] .bui-ToastIcon,
|
||||
.bui-Toast[data-status='success'] .bui-ToastTitle {
|
||||
color: var(--bui-fg-success);
|
||||
}
|
||||
|
||||
.bui-Toast[data-status='warning'] .bui-ToastIcon,
|
||||
.bui-Toast[data-status='warning'] .bui-ToastTitle {
|
||||
color: var(--bui-fg-warning);
|
||||
}
|
||||
|
||||
.bui-Toast[data-status='danger'] .bui-ToastIcon,
|
||||
.bui-Toast[data-status='danger'] .bui-ToastTitle {
|
||||
color: var(--bui-fg-danger);
|
||||
}
|
||||
|
||||
.bui-ToastWrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--bui-space-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bui-ToastContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--bui-space-1);
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.bui-ToastIcon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.125rem;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.bui-ToastTitle {
|
||||
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 */
|
||||
.bui-ToastDescription {
|
||||
color: var(--bui-fg-secondary);
|
||||
font-size: var(--bui-font-size-3);
|
||||
opacity: 0.9;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.bui-ToastLinks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--bui-space-3);
|
||||
margin-top: var(--bui-space-1);
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.bui-ToastCloseButton {
|
||||
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);
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bui-bg-neutral-on-surface-1-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bui-border-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--bui-bg-neutral-on-surface-1-pressed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import preview from '../../../../../.storybook/preview';
|
||||
import { ToastContainer, toastQueue } from './index';
|
||||
import { Flex } from '../Flex';
|
||||
import { Button } from '../Button';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const meta = preview.meta({
|
||||
title: 'Backstage UI/Toast',
|
||||
component: ToastContainer,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
});
|
||||
|
||||
const randomToasts = [
|
||||
// Title only - short
|
||||
{ title: 'Saved', status: 'success' as const },
|
||||
{ title: 'Error', status: 'danger' as const },
|
||||
{ title: 'New notification', status: 'info' as const },
|
||||
{ title: 'Warning', status: 'warning' as const },
|
||||
// Title only - medium
|
||||
{ title: 'Changes saved successfully', status: 'success' as const },
|
||||
{ title: 'Connection restored', status: 'info' as const },
|
||||
{ title: 'Action could not be completed', status: 'danger' as const },
|
||||
// Title + short description
|
||||
{
|
||||
title: 'Files uploaded',
|
||||
description: '3 files uploaded.',
|
||||
status: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: 'Update available',
|
||||
description: 'Version 2.0 is ready.',
|
||||
status: 'info' as const,
|
||||
},
|
||||
{
|
||||
title: 'Request failed',
|
||||
description: 'Please try again.',
|
||||
status: 'danger' as const,
|
||||
},
|
||||
{
|
||||
title: 'Storage warning',
|
||||
description: '90% used.',
|
||||
status: 'warning' as const,
|
||||
},
|
||||
// Title + medium description
|
||||
{
|
||||
title: 'Deployment complete',
|
||||
description:
|
||||
'Your application has been deployed to production successfully.',
|
||||
status: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: 'Session expiring',
|
||||
description:
|
||||
'Your session will expire in 5 minutes. Please save your work.',
|
||||
status: 'warning' as const,
|
||||
},
|
||||
{
|
||||
title: 'Permission denied',
|
||||
description: 'You do not have access to perform this action.',
|
||||
status: 'danger' as const,
|
||||
},
|
||||
// Title + long description
|
||||
{
|
||||
title: 'Sync completed',
|
||||
description:
|
||||
'All your files have been synchronized across devices. This includes 47 documents, 23 images, and 12 configuration files that were updated in the last hour.',
|
||||
status: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: 'Rate limit exceeded',
|
||||
description:
|
||||
'You have exceeded the maximum number of API requests allowed. Please wait a few minutes before trying again or upgrade your plan for higher limits.',
|
||||
status: 'warning' as const,
|
||||
},
|
||||
{
|
||||
title: 'Critical error',
|
||||
description:
|
||||
'The server encountered an unexpected error while processing your request. Our team has been notified and is investigating the issue. Please try again later.',
|
||||
status: 'danger' as const,
|
||||
},
|
||||
// Long title only
|
||||
{
|
||||
title: 'Your subscription has been renewed successfully for another year',
|
||||
status: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: 'Multiple users are currently editing this document',
|
||||
status: 'info' as const,
|
||||
},
|
||||
];
|
||||
|
||||
export const Default = meta.story({
|
||||
render: () => (
|
||||
<>
|
||||
<ToastContainer queue={toastQueue} />
|
||||
<Flex gap="3">
|
||||
<Button
|
||||
onPress={() => {
|
||||
const toast =
|
||||
randomToasts[Math.floor(Math.random() * randomToasts.length)];
|
||||
toastQueue.add(toast);
|
||||
}}
|
||||
>
|
||||
Add Random Toast
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() => {
|
||||
toastQueue.clear();
|
||||
}}
|
||||
>
|
||||
Clear All Toasts
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const StatusVariants = meta.story({
|
||||
render: () => (
|
||||
<>
|
||||
<ToastContainer queue={toastQueue} />
|
||||
<Flex gap="3">
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Informational message',
|
||||
description: 'Here is some helpful information.',
|
||||
status: 'info',
|
||||
})
|
||||
}
|
||||
>
|
||||
Info Toast
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Success!',
|
||||
description: 'Your changes have been saved.',
|
||||
status: 'success',
|
||||
})
|
||||
}
|
||||
>
|
||||
Success Toast
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Warning',
|
||||
description: 'This action may have consequences.',
|
||||
status: 'warning',
|
||||
})
|
||||
}
|
||||
>
|
||||
Warning Toast
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Error',
|
||||
description: 'Something went wrong.',
|
||||
status: 'danger',
|
||||
})
|
||||
}
|
||||
>
|
||||
Danger Toast
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const WithoutDescription = meta.story({
|
||||
render: () => (
|
||||
<>
|
||||
<ToastContainer queue={toastQueue} />
|
||||
<Flex gap="3">
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'File saved',
|
||||
status: 'success',
|
||||
})
|
||||
}
|
||||
>
|
||||
Simple Success
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Check for updates',
|
||||
status: 'info',
|
||||
})
|
||||
}
|
||||
>
|
||||
Simple Info
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const WithoutIcons = meta.story({
|
||||
render: () => (
|
||||
<>
|
||||
<ToastContainer queue={toastQueue} />
|
||||
<Flex gap="3">
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Toast without icon',
|
||||
description: 'This toast has no icon displayed.',
|
||||
icon: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
No Icon
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Success without icon',
|
||||
status: 'success',
|
||||
icon: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
Success No Icon
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const WithLinks = meta.story({
|
||||
render: () => (
|
||||
<MemoryRouter>
|
||||
<ToastContainer queue={toastQueue} />
|
||||
<Flex gap="3">
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Deployment complete',
|
||||
description: 'Your application has been deployed.',
|
||||
status: 'success',
|
||||
links: [
|
||||
{ label: 'View logs', href: '/logs' },
|
||||
{ label: 'Open dashboard', href: '/dashboard' },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Toast with Links
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'Error occurred',
|
||||
description: 'Something went wrong during the build.',
|
||||
status: 'danger',
|
||||
links: [{ label: 'View error details', href: '/errors' }],
|
||||
})
|
||||
}
|
||||
>
|
||||
Single Link
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
title: 'New update available',
|
||||
status: 'info',
|
||||
links: [
|
||||
{ label: 'Release notes', href: '/releases' },
|
||||
{ label: 'Update now', href: '/update' },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Links without Description
|
||||
</Button>
|
||||
</Flex>
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
|
||||
export const AutoDismiss = meta.story({
|
||||
render: () => (
|
||||
<>
|
||||
<ToastContainer queue={toastQueue} />
|
||||
<Flex gap="3">
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add(
|
||||
{
|
||||
title: 'Auto dismiss in 3 seconds',
|
||||
description: 'This toast will disappear automatically.',
|
||||
status: 'info',
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
}
|
||||
>
|
||||
3 Second Toast
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add(
|
||||
{
|
||||
title: 'Auto dismiss in 5 seconds',
|
||||
description: 'Recommended minimum timeout for accessibility.',
|
||||
status: 'success',
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
}
|
||||
>
|
||||
5 Second Toast
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add(
|
||||
{
|
||||
title: 'Auto dismiss in 10 seconds',
|
||||
description: 'Longer timeout for more content.',
|
||||
status: 'warning',
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
}
|
||||
>
|
||||
10 Second Toast
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const ProgrammaticDismiss = meta.story({
|
||||
render: () => {
|
||||
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 QueueManagement = meta.story({
|
||||
render: () => (
|
||||
<>
|
||||
<ToastContainer queue={toastQueue} />
|
||||
<Flex gap="3">
|
||||
<Button
|
||||
onPress={() => {
|
||||
toastQueue.add({
|
||||
title: 'First toast',
|
||||
description: 'This is the first toast in the queue.',
|
||||
status: 'info',
|
||||
});
|
||||
setTimeout(() => {
|
||||
toastQueue.add({
|
||||
title: 'Second toast',
|
||||
description: 'This is the second toast.',
|
||||
status: 'success',
|
||||
});
|
||||
}, 300);
|
||||
setTimeout(() => {
|
||||
toastQueue.add({
|
||||
title: 'Third toast',
|
||||
description: 'This is the third toast.',
|
||||
status: 'warning',
|
||||
});
|
||||
}, 600);
|
||||
}}
|
||||
>
|
||||
Show Multiple Toasts
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
setTimeout(() => {
|
||||
toastQueue.add({
|
||||
title: `Toast #${i}`,
|
||||
description: `This is toast number ${i}.`,
|
||||
status: ['info', 'success', 'warning', 'danger'][
|
||||
(i - 1) % 4
|
||||
] as 'info' | 'success' | 'warning' | 'danger',
|
||||
});
|
||||
}, i * 200);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Show 5 Toasts
|
||||
</Button>
|
||||
<Button onPress={() => toastQueue.clear()}>Clear All Toasts</Button>
|
||||
</Flex>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export default meta;
|
||||
@@ -1,309 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
Ref,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useToast } from '@react-aria/toast';
|
||||
import { useButton } from 'react-aria';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
RiInformationLine,
|
||||
RiCheckLine,
|
||||
RiErrorWarningLine,
|
||||
RiAlertLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react';
|
||||
import type { ToastProps } from './types';
|
||||
import { useDefinition } from '../../hooks/useDefinition';
|
||||
import { ToastDefinition } from './definition';
|
||||
import { Link } from '../Link';
|
||||
|
||||
// Track which toasts are being manually closed (vs auto-timeout)
|
||||
// This allows different exit animations for each case
|
||||
const manuallyClosingToasts = new Set<string>();
|
||||
|
||||
/**
|
||||
* A Toast displays a brief, temporary notification of actions, errors, or other events in an application.
|
||||
*
|
||||
* @remarks
|
||||
* The Toast component is used internally by ToastContainer and managed by a ToastQueue.
|
||||
* It supports multiple status variants (info, success, warning, danger) and can display
|
||||
* a title, description, and optional icon. Toasts can be dismissed manually or automatically.
|
||||
*
|
||||
* @example
|
||||
* Basic usage with queue:
|
||||
* ```tsx
|
||||
* import { toastQueue } from '@backstage/ui';
|
||||
*
|
||||
* toastQueue.add({ title: 'File saved successfully', status: 'success' });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* With description and auto-dismiss:
|
||||
* ```tsx
|
||||
* toastQueue.add(
|
||||
* {
|
||||
* title: 'Update available',
|
||||
* description: 'A new version is ready to install.',
|
||||
* status: 'info'
|
||||
* },
|
||||
* { timeout: 5000 }
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const Toast = forwardRef(
|
||||
(props: ToastProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { ownProps, restProps, dataAttributes } = useDefinition(
|
||||
ToastDefinition,
|
||||
props,
|
||||
);
|
||||
const {
|
||||
classes,
|
||||
toast,
|
||||
state,
|
||||
index = 0,
|
||||
isExpanded = false,
|
||||
onClose,
|
||||
status,
|
||||
icon,
|
||||
expandedY: expandedYProp = 0,
|
||||
collapsedHeight,
|
||||
naturalHeight,
|
||||
onHeightChange,
|
||||
} = ownProps;
|
||||
|
||||
// Use internal ref if none provided
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const toastRef = (ref as React.RefObject<HTMLDivElement>) || internalRef;
|
||||
|
||||
// Get ARIA props from useToast hook
|
||||
const { toastProps, titleProps, closeButtonProps } = useToast(
|
||||
{ toast },
|
||||
state,
|
||||
toastRef,
|
||||
);
|
||||
|
||||
// Extract only ARIA and accessibility props from toastProps to avoid
|
||||
// conflicts with motion.div's event handler types (motion has its own drag API)
|
||||
const ariaProps = {
|
||||
role: toastProps.role,
|
||||
tabIndex: toastProps.tabIndex,
|
||||
'aria-label': toastProps['aria-label'],
|
||||
'aria-labelledby': toastProps['aria-labelledby'],
|
||||
'aria-describedby': toastProps['aria-describedby'],
|
||||
'aria-posinset': toastProps['aria-posinset'],
|
||||
'aria-setsize': toastProps['aria-setsize'],
|
||||
};
|
||||
|
||||
// Track whether we've measured this toast's natural height
|
||||
const [hasMeasured, setHasMeasured] = useState(false);
|
||||
// Store the measured natural height locally to avoid re-measurement issues
|
||||
const naturalHeightRef = useRef<number | null>(null);
|
||||
|
||||
// Measure this toast's natural height on mount (before paint)
|
||||
// Using useLayoutEffect ensures we measure before the browser paints
|
||||
useLayoutEffect(() => {
|
||||
if (!onHeightChange) return;
|
||||
if (naturalHeightRef.current) return; // Already measured
|
||||
|
||||
const element = toastRef.current;
|
||||
if (!element) return;
|
||||
|
||||
// Measure immediately - useLayoutEffect runs before paint
|
||||
const height = element.getBoundingClientRect().height;
|
||||
if (height > 0) {
|
||||
naturalHeightRef.current = height;
|
||||
onHeightChange(toast.key, height);
|
||||
setHasMeasured(true);
|
||||
}
|
||||
}, [toast.key, onHeightChange]);
|
||||
|
||||
// Close button ref and props
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const { buttonProps } = useButton(
|
||||
{
|
||||
...closeButtonProps,
|
||||
onPress: () => {
|
||||
// Mark this toast as manually closed for exit animation
|
||||
manuallyClosingToasts.add(toast.key);
|
||||
onClose?.();
|
||||
state.close(toast.key);
|
||||
},
|
||||
},
|
||||
closeButtonRef,
|
||||
);
|
||||
|
||||
// Get content from toast
|
||||
const content = toast.content;
|
||||
const finalStatus = status || content.status || 'info';
|
||||
const finalIcon = icon !== undefined ? icon : content.icon;
|
||||
|
||||
// Determine which icon to render
|
||||
const getStatusIcon = (): ReactElement | null => {
|
||||
// If icon is explicitly false, don't render any icon
|
||||
if (finalIcon === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If icon is a custom React element, use it
|
||||
if (isValidElement(finalIcon)) {
|
||||
return finalIcon;
|
||||
}
|
||||
|
||||
// If icon is true or undefined (default to true for toasts), auto-select based on status
|
||||
if (finalIcon === true || finalIcon === undefined) {
|
||||
switch (finalStatus) {
|
||||
case 'success':
|
||||
return <RiCheckLine aria-hidden="true" />;
|
||||
case 'warning':
|
||||
return <RiErrorWarningLine aria-hidden="true" />;
|
||||
case 'danger':
|
||||
return <RiAlertLine aria-hidden="true" />;
|
||||
case 'info':
|
||||
default:
|
||||
return <RiInformationLine aria-hidden="true" />;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: no icon
|
||||
return null;
|
||||
};
|
||||
|
||||
const statusIcon = getStatusIcon();
|
||||
|
||||
// Calculate stacking values based on index
|
||||
// Collapsed: each toast behind scales down 5% and peeks up 12px
|
||||
const collapsedScale = Math.max(0.85, 1 - index * 0.05);
|
||||
const collapsedY = -index * 12;
|
||||
|
||||
// Use expanded or collapsed values based on hover state
|
||||
// expandedYProp is pre-calculated based on actual toast heights
|
||||
const animateY = isExpanded ? expandedYProp : collapsedY;
|
||||
const animateScale = isExpanded ? 1 : collapsedScale;
|
||||
const stackZIndex = 1000 - index;
|
||||
|
||||
// Check if this toast is being manually closed
|
||||
const isManualClose = manuallyClosingToasts.has(toast.key);
|
||||
|
||||
// Different exit animations for manual close vs auto-timeout
|
||||
// Manual close: slide down from front, stay on top
|
||||
// Auto-timeout: fade out in place, stay in stack position
|
||||
const exitAnimation = isManualClose
|
||||
? { opacity: 0, y: 100, scale: 1, zIndex: 2000 }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: animateY + 50,
|
||||
scale: animateScale,
|
||||
zIndex: stackZIndex,
|
||||
};
|
||||
|
||||
// Height animation for back toasts:
|
||||
// - Front toast (index 0): never set height, uses natural CSS height
|
||||
// - Back toasts: animate between collapsedHeight and their own naturalHeight
|
||||
const measuredHeight = naturalHeight || naturalHeightRef.current;
|
||||
const isBackToast = index > 0;
|
||||
const hasValidMeasurements =
|
||||
hasMeasured && collapsedHeight && measuredHeight;
|
||||
|
||||
// For back toasts with valid measurements, calculate target height
|
||||
// Otherwise, let CSS handle it naturally
|
||||
let animateProps: {
|
||||
opacity: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
zIndex: number;
|
||||
height?: number;
|
||||
} = {
|
||||
opacity: 1,
|
||||
y: animateY,
|
||||
scale: animateScale,
|
||||
zIndex: stackZIndex,
|
||||
};
|
||||
|
||||
if (isBackToast && hasValidMeasurements) {
|
||||
animateProps.height = isExpanded ? measuredHeight : collapsedHeight;
|
||||
}
|
||||
|
||||
const shouldClipContent =
|
||||
isBackToast && hasValidMeasurements && !isExpanded;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
{...ariaProps}
|
||||
ref={toastRef}
|
||||
className={classes.root}
|
||||
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 }}
|
||||
{...dataAttributes}
|
||||
data-status={finalStatus}
|
||||
{...restProps}
|
||||
>
|
||||
<div className={classes.wrapper}>
|
||||
{statusIcon && <div className={classes.icon}>{statusIcon}</div>}
|
||||
<div className={classes.content}>
|
||||
<div {...titleProps} className={classes.title}>
|
||||
{content.title}
|
||||
</div>
|
||||
{content.description && (
|
||||
<div className={classes.description}>{content.description}</div>
|
||||
)}
|
||||
{content.links && content.links.length > 0 && (
|
||||
<div className={classes.links}>
|
||||
{content.links.map((link, idx) => (
|
||||
<Link key={idx} href={link.href} variant="body-small">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={closeButtonRef}
|
||||
className={classes.closeButton}
|
||||
>
|
||||
<RiCloseLine aria-hidden="true" />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Toast.displayName = 'Toast';
|
||||
@@ -1,174 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef, Ref, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useToastRegion } from '@react-aria/toast';
|
||||
import { useToastQueue } from 'react-stately';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import type { ToastContainerProps } from './types';
|
||||
import { useDefinition } from '../../hooks/useDefinition';
|
||||
import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode';
|
||||
import { ToastContainerDefinition } from './definition';
|
||||
import { Toast } from './Toast';
|
||||
|
||||
/**
|
||||
* A ToastContainer displays one or more toast notifications in the bottom-right corner.
|
||||
*
|
||||
* @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-right corner 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.
|
||||
*
|
||||
* @example
|
||||
* Basic setup in app root:
|
||||
* ```tsx
|
||||
* import { ToastContainer, toastQueue } from '@backstage/ui';
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <>
|
||||
* <ToastContainer queue={toastQueue} />
|
||||
* <YourAppContent />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const ToastContainer = forwardRef(
|
||||
(props: ToastContainerProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { ownProps, restProps, dataAttributes } = useDefinition(
|
||||
ToastContainerDefinition,
|
||||
props,
|
||||
);
|
||||
const { classes, queue, className } = ownProps;
|
||||
|
||||
// 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 || classes.container}
|
||||
data-theme-mode={invertedThemeMode}
|
||||
data-hover-locked={isHoverLocked ? '' : undefined}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onFocus={() => setIsHovered(true)}
|
||||
onBlur={() => setIsHovered(false)}
|
||||
{...dataAttributes}
|
||||
{...restProps}
|
||||
>
|
||||
<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';
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ToastQueue } from 'react-stately';
|
||||
import type { ToastContent } from './types';
|
||||
|
||||
/**
|
||||
* Global toast queue for displaying toast notifications throughout the application.
|
||||
*
|
||||
* @remarks
|
||||
* This uses React Stately's ToastQueue for state management with motion/react
|
||||
* for smooth enter/exit animations.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { toastQueue } from '@backstage/ui';
|
||||
*
|
||||
* // Show a toast
|
||||
* toastQueue.add({ title: 'Success!', status: 'success' });
|
||||
*
|
||||
* // Show with auto-dismiss
|
||||
* toastQueue.add({ title: 'Saved' }, { timeout: 5000 });
|
||||
*
|
||||
* // Programmatic dismiss
|
||||
* const key = toastQueue.add({ title: 'Processing...' });
|
||||
* // Later...
|
||||
* toastQueue.close(key);
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const toastQueue = new ToastQueue<ToastContent>({
|
||||
maxVisibleToasts: 4,
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { defineComponent } from '../../hooks/useDefinition';
|
||||
import type { ToastOwnProps, ToastContainerOwnProps } from './types';
|
||||
import styles from './Toast.module.css';
|
||||
|
||||
/**
|
||||
* Component definition for Toast
|
||||
* @internal
|
||||
*/
|
||||
export const ToastDefinition = defineComponent<ToastOwnProps>()({
|
||||
styles,
|
||||
classNames: {
|
||||
root: 'bui-Toast',
|
||||
wrapper: 'bui-ToastWrapper',
|
||||
content: 'bui-ToastContent',
|
||||
title: 'bui-ToastTitle',
|
||||
description: 'bui-ToastDescription',
|
||||
links: 'bui-ToastLinks',
|
||||
icon: 'bui-ToastIcon',
|
||||
closeButton: 'bui-ToastCloseButton',
|
||||
},
|
||||
surface: 'container',
|
||||
propDefs: {
|
||||
toast: {},
|
||||
state: {},
|
||||
index: {},
|
||||
isExpanded: {},
|
||||
onClose: {},
|
||||
status: { dataAttribute: true },
|
||||
icon: {},
|
||||
surface: {},
|
||||
className: {},
|
||||
style: {},
|
||||
expandedY: {},
|
||||
collapsedHeight: {},
|
||||
naturalHeight: {},
|
||||
onHeightChange: {},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Component definition for ToastContainer
|
||||
* @public
|
||||
*/
|
||||
export const ToastContainerDefinition =
|
||||
defineComponent<ToastContainerOwnProps>()({
|
||||
styles,
|
||||
classNames: {
|
||||
container: 'bui-ToastContainer',
|
||||
},
|
||||
propDefs: {
|
||||
queue: {},
|
||||
className: {},
|
||||
},
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ToastContainer } from './ToastContainer';
|
||||
export { ToastContainerDefinition } from './definition';
|
||||
export { toastQueue } from './ToastQueue';
|
||||
export type {
|
||||
ToastLink,
|
||||
ToastContent,
|
||||
ToastContainerOwnProps,
|
||||
ToastContainerProps,
|
||||
} from './types';
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { ToastQueue, ToastState, QueuedToast } from 'react-stately';
|
||||
import type { Responsive, ContainerSurfaceProps } from '../../types';
|
||||
|
||||
/**
|
||||
* Link item for toast notifications
|
||||
* @public
|
||||
*/
|
||||
export interface ToastLink {
|
||||
/** Display text for the link */
|
||||
label: string;
|
||||
/** URL the link points to */
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for a toast notification
|
||||
* @public
|
||||
*/
|
||||
export interface ToastContent {
|
||||
/** Title of the toast (required) */
|
||||
title: ReactNode;
|
||||
/** Optional description text */
|
||||
description?: ReactNode;
|
||||
/** Status variant of the toast */
|
||||
status?: 'info' | 'success' | 'warning' | 'danger';
|
||||
/** Whether to show an icon */
|
||||
icon?: boolean | ReactElement;
|
||||
/** Optional array of links to display */
|
||||
links?: ToastLink[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Own props for the Toast component
|
||||
* @internal
|
||||
*/
|
||||
export type ToastOwnProps = ContainerSurfaceProps & {
|
||||
/** Toast object from the queue */
|
||||
toast: QueuedToast<ToastContent>;
|
||||
/** Toast state from useToastQueue */
|
||||
state: ToastState<ToastContent>;
|
||||
/** Index of the toast in the visible toasts array */
|
||||
index?: number;
|
||||
/** Whether the toast stack is expanded (hovered/focused) */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when toast is closed */
|
||||
onClose?: () => void;
|
||||
/** Override status from content */
|
||||
status?: Responsive<'info' | 'success' | 'warning' | 'danger'>;
|
||||
/** Override icon from content */
|
||||
icon?: boolean | ReactElement;
|
||||
/** Pre-calculated Y position when expanded (based on heights of toasts below) */
|
||||
expandedY?: number;
|
||||
/** Height to use when collapsed (front toast's height, for uniform stacking) */
|
||||
collapsedHeight?: number;
|
||||
/** This toast's natural height (for smooth animation) */
|
||||
naturalHeight?: number;
|
||||
/** Callback to report this toast's natural height */
|
||||
onHeightChange?: (key: string, height: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Properties for {@link Toast}
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastProps extends ToastOwnProps {}
|
||||
|
||||
/**
|
||||
* Own props for the ToastContainer component
|
||||
* @public
|
||||
*/
|
||||
export type ToastContainerOwnProps = {
|
||||
/** Toast queue instance */
|
||||
queue: ToastQueue<ToastContent>;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Properties for {@link ToastContainer}
|
||||
* @public
|
||||
*/
|
||||
export interface ToastContainerProps extends ToastContainerOwnProps {}
|
||||
@@ -53,11 +53,6 @@ export { SearchFieldDefinition } from './components/SearchField/definition';
|
||||
export { SelectDefinition } from './components/Select/definition';
|
||||
export { SkeletonDefinition } from './components/Skeleton/definition';
|
||||
export { SwitchDefinition } from './components/Switch/definition';
|
||||
export {
|
||||
ToastDefinition,
|
||||
ToastContainerDefinition,
|
||||
ToastContainerDefinition as ToastRegionDefinition,
|
||||
} from './components/Toast/definition';
|
||||
export { ToggleButtonDefinition } from './components/ToggleButton/definition';
|
||||
export { ToggleButtonGroupDefinition } from './components/ToggleButtonGroup/definition';
|
||||
export { TableDefinition } from './components/Table/definition';
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
*/
|
||||
|
||||
export * from './useDefinition';
|
||||
export * from './useInvertedThemeMode';
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* Detects the current theme mode from the DOM and returns the inverted value.
|
||||
* Checks both document.documentElement and document.body for the data-theme-mode attribute.
|
||||
*
|
||||
* @returns The inverted theme mode ('light' when app is dark, 'dark' when app is light)
|
||||
*/
|
||||
export function useInvertedThemeMode(): ThemeMode {
|
||||
const [invertedThemeMode, setInvertedThemeMode] = useState<ThemeMode>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
const detectTheme = (): ThemeMode => {
|
||||
// Check both html and body elements for the theme attribute
|
||||
const htmlTheme =
|
||||
document.documentElement.getAttribute('data-theme-mode');
|
||||
const bodyTheme = document.body.getAttribute('data-theme-mode');
|
||||
|
||||
// Prefer body (used by UnifiedThemeProvider) over html
|
||||
const currentTheme = bodyTheme || htmlTheme;
|
||||
|
||||
// If current theme is dark, return light (inverted), otherwise return dark
|
||||
return currentTheme === 'dark' ? 'light' : 'dark';
|
||||
};
|
||||
|
||||
// Initial detection
|
||||
setInvertedThemeMode(detectTheme());
|
||||
|
||||
// Watch for theme changes on both html and body
|
||||
const observer = new MutationObserver(() => {
|
||||
setInvertedThemeMode(detectTheme());
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme-mode'],
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme-mode'],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return invertedThemeMode;
|
||||
}
|
||||
@@ -56,7 +56,6 @@ export * from './components/Link';
|
||||
export * from './components/Select';
|
||||
export * from './components/Skeleton';
|
||||
export * from './components/Switch';
|
||||
export * from './components/Toast';
|
||||
export * from './components/ToggleButton';
|
||||
export * from './components/ToggleButtonGroup';
|
||||
export * from './components/VisuallyHidden';
|
||||
|
||||
Reference in New Issue
Block a user