Toast improvements

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-01-27 12:11:55 +00:00
committed by Patrik Oldsberg
parent 8923502d02
commit cae33447ed
9 changed files with 55 additions and 364 deletions
@@ -26,7 +26,7 @@ export function StatusVariants() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
@@ -99,7 +99,7 @@ export function WithoutDescription() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
@@ -125,71 +125,11 @@ export function WithoutDescription() {
);
}
export function Positions() {
const [currentPosition, setCurrentPosition] = useState<
'top' | 'bottom' | null
>(null);
const [currentPlacement, setCurrentPlacement] = useState<
'start' | 'center' | 'end' | null
>(null);
const showToast = (
position: 'top' | 'bottom',
placement: 'start' | 'center' | 'end',
) => {
setCurrentPosition(position);
setCurrentPlacement(placement);
toastQueue.add({
title: `${position} - ${placement}`,
description: `Toast positioned at ${position} ${placement}`,
status: 'info',
});
};
return (
<>
{currentPosition && currentPlacement && (
<ToastRegion
queue={toastQueue}
position={currentPosition}
placement={currentPlacement}
/>
)}
<Flex direction="column" gap="4">
<div>
<strong>Top Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('top', 'start')}>Top Start</Button>
<Button onPress={() => showToast('top', 'center')}>
Top Center
</Button>
<Button onPress={() => showToast('top', 'end')}>Top End</Button>
</Flex>
</div>
<div>
<strong>Bottom Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('bottom', 'start')}>
Bottom Start
</Button>
<Button onPress={() => showToast('bottom', 'center')}>
Bottom Center
</Button>
<Button onPress={() => showToast('bottom', 'end')}>
Bottom End
</Button>
</Flex>
</div>
</Flex>
</>
);
}
export function AutoDismiss() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add(
@@ -258,7 +198,7 @@ export function QueueManagement() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Flex gap="3">
<Button
onPress={() => {
toastQueue.add({
+1 -15
View File
@@ -8,7 +8,6 @@ import {
statusVariantsSnippet,
withDescriptionSnippet,
withoutDescriptionSnippet,
positionsSnippet,
autoDismissSnippet,
programmaticControlSnippet,
queueManagementSnippet,
@@ -18,7 +17,6 @@ import {
StatusVariants,
WithDescription,
WithoutDescription,
Positions,
AutoDismiss,
ProgrammaticControl,
QueueManagement,
@@ -30,7 +28,7 @@ import { ToastDefinition, ToastRegionDefinition } from '../../../utils/definitio
<PageTitle
title="Toast"
description="A Toast displays a brief, temporary notification of actions, errors, or other events in an application."
description="A Toast displays a brief, temporary notification of actions, errors, or other events in the bottom-right corner of the application."
/>
<Snippet align="center" py={4} preview={<Default />} code={defaultSnippet} />
@@ -95,18 +93,6 @@ Toasts can display a title only for simpler notifications.
code={withoutDescriptionSnippet}
/>
### Positions and Placements
Control where toasts appear on the screen with `position` (top/bottom) and `placement` (start/center/end).
<Snippet
align="center"
py={4}
open
preview={<Positions />}
code={positionsSnippet}
/>
### Auto Dismiss
Toasts can automatically dismiss after a timeout. For accessibility, a minimum of 5 seconds is recommended. Timers automatically pause when users hover or focus on a toast.
@@ -35,17 +35,5 @@ export const toastRegionPropDefs: Record<string, PropDef> = {
values: ['ToastQueue<ToastContent>'],
responsive: false,
},
position: {
type: 'enum',
values: ['top', 'bottom'],
responsive: true,
default: 'bottom',
},
placement: {
type: 'enum',
values: ['start', 'center', 'end'],
responsive: true,
default: 'end',
},
...classNamePropDefs,
};
+4 -55
View File
@@ -39,7 +39,7 @@ export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
@@ -116,7 +116,7 @@ export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
@@ -142,64 +142,13 @@ export function Example() {
);
}`;
export const positionsSnippet = `import { useState } from 'react';
import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui';
export function Example() {
const [currentPosition, setCurrentPosition] = useState<'top' | 'bottom' | null>(null);
const [currentPlacement, setCurrentPlacement] = useState<'start' | 'center' | 'end' | null>(null);
const showToast = (
position: 'top' | 'bottom',
placement: 'start' | 'center' | 'end'
) => {
setCurrentPosition(position);
setCurrentPlacement(placement);
toastQueue.add({
title: \`\${position} - \${placement}\`,
description: \`Toast positioned at \${position} \${placement}\`,
status: 'info',
});
};
return (
<>
{currentPosition && currentPlacement && (
<ToastRegion
queue={toastQueue}
position={currentPosition}
placement={currentPlacement}
/>
)}
<Flex direction="column" gap="4">
<div>
<strong>Top Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('top', 'start')}>Top Start</Button>
<Button onPress={() => showToast('top', 'center')}>Top Center</Button>
<Button onPress={() => showToast('top', 'end')}>Top End</Button>
</Flex>
</div>
<div>
<strong>Bottom Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('bottom', 'start')}>Bottom Start</Button>
<Button onPress={() => showToast('bottom', 'center')}>Bottom Center</Button>
<Button onPress={() => showToast('bottom', 'end')}>Bottom End</Button>
</Flex>
</div>
</Flex>
</>
);
}`;
export const autoDismissSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui';
export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add(
@@ -273,7 +222,7 @@ export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Flex gap="3">
<Button
onPress={() => {
toastQueue.add({
+41 -105
View File
@@ -17,22 +17,23 @@
@layer tokens, base, components, utilities;
@layer components {
/* Toast Region - Container for all toasts */
/* Toast Region - Container for all toasts (bottom-right only) */
.bui-ToastRegion {
position: fixed;
bottom: 2rem;
right: 2rem;
bottom: 1rem;
right: 1rem;
z-index: 100050;
width: 300px;
/* inset-inline-start: 0;
inset-inline-end: 0;
z-index: 100050;
display: flex;
pointer-events: none;
outline: none; */
outline: none;
}
/* Position: Bottom */
/* bottom: 0;
flex-direction: column-reverse; */
@media (min-width: 500px) {
.bui-ToastRegion {
width: 350px;
bottom: 2rem;
right: 2rem;
}
}
/* Individual Toast */
@@ -40,17 +41,18 @@
--toast-bg: var(--bui-bg-surface-1);
--toast-fg: var(--bui-fg-primary);
--toast-gap: 0.75rem;
--toast-peek: 1.25rem;
--toast-peek: 0.75rem;
--toast-scale: calc(max(0, 1 - (var(--toast-index) * 0.05)));
--toast-shrink: calc(1 - var(--toast-scale));
position: relative;
position: absolute;
right: 0;
margin: 0 auto;
box-sizing: border-box;
display: flex;
align-items: flex-start;
gap: var(--bui-space-3);
min-width: 320px;
max-width: 480px;
margin: var(--bui-space-2);
width: 100%;
padding: var(--bui-space-3);
border-radius: var(--bui-radius-3);
background-color: var(--toast-bg);
@@ -60,10 +62,12 @@
line-height: 1.5;
box-shadow: var(--bui-shadow);
outline: none;
cursor: default;
user-select: none;
z-index: calc(1000 - var(--toast-index));
pointer-events: auto;
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s;
opacity: calc(1 - (var(--toast-index) * 0.15));
transition:
transform 0.5s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.5s;
/* Focus ring */
&:focus-visible {
@@ -72,50 +76,24 @@
}
}
/* Bottom position stacking */
.bui-ToastRegion .bui-Toast:not([data-entering]) {
transform: translateY(
calc(
(var(--toast-index) * var(--toast-peek) * -1) -
(var(--toast-shrink) * 100%)
)
)
scale(var(--toast-scale));
/* Stacking from bottom */
.bui-Toast {
bottom: 0;
transform-origin: bottom center;
transform: translateY(calc(var(--toast-index) * var(--toast-peek) * -1))
scale(var(--toast-scale));
opacity: calc(1 - (var(--toast-index) * 0.15));
}
/* Only first toast is interactive */
.bui-ToastRegion li:not(:first-child) .bui-Toast {
.bui-Toast[style*='--toast-index: 0'] {
pointer-events: auto;
}
.bui-Toast:not([style*='--toast-index: 0']) {
pointer-events: none;
}
/* List styling for toast container */
/* .bui-ToastRegion ol {
display: inherit;
flex-direction: inherit;
align-items: inherit;
list-style-type: none;
margin: 0;
padding: 0;
position: relative;
} */
.bui-ToastRegion li {
display: flex !important;
position: absolute;
width: 100%;
justify-content: flex-end;
}
/* Position list items based on toast region position */
.bui-ToastRegion[data-position='bottom'] li {
bottom: 0;
}
.bui-ToastRegion[data-position='top'] li {
top: 0;
}
/* Status variants */
.bui-Toast[data-status='info'] {
--toast-bg: var(--bui-bg-info);
@@ -215,52 +193,15 @@
}
}
/* Animations */
@keyframes bui-toast-slide-in-bottom {
from {
transform: translateY(calc(100% + 2rem)) scale(1);
opacity: 0;
}
/* Entering and exiting states */
.bui-Toast[data-entering] {
transform: translateY(calc(100% + 2rem)) scale(1);
opacity: 0;
}
@keyframes bui-toast-slide-in-top {
from {
transform: translateY(calc(-100% - 2rem)) scale(1);
opacity: 0;
}
}
@keyframes bui-toast-slide-out-bottom {
to {
transform: translateY(calc(100% + 2rem));
opacity: 0;
}
}
@keyframes bui-toast-slide-out-top {
to {
transform: translateY(calc(-100% - 2rem));
opacity: 0;
}
}
/* Apply animations based on position */
.bui-ToastRegion[data-position='bottom'] .bui-Toast[data-entering] {
animation: bui-toast-slide-in-bottom 0.4s cubic-bezier(0.22, 1, 0.36, 1)
forwards;
}
.bui-ToastRegion[data-position='bottom'] .bui-Toast[data-exiting] {
animation: bui-toast-slide-out-bottom 0.3s ease-in forwards;
}
.bui-ToastRegion[data-position='top'] .bui-Toast[data-entering] {
animation: bui-toast-slide-in-top 0.4s cubic-bezier(0.22, 1, 0.36, 1)
forwards;
}
.bui-ToastRegion[data-position='top'] .bui-Toast[data-exiting] {
animation: bui-toast-slide-out-top 0.3s ease-in forwards;
.bui-Toast[data-exiting] {
transform: translateY(calc(100% + 2rem)) scale(var(--toast-scale));
opacity: 0;
}
/* Reduced motion */
@@ -268,10 +209,5 @@
.bui-Toast {
transition: none;
}
.bui-Toast[data-entering],
.bui-Toast[data-exiting] {
animation: none;
}
}
}
@@ -22,16 +22,6 @@ import { Button } from '../Button';
const meta = preview.meta({
title: 'Backstage UI/Toast',
component: ToastRegion,
argTypes: {
position: {
control: 'select',
options: ['top', 'bottom'],
},
placement: {
control: 'select',
options: ['start', 'center', 'end'],
},
},
});
export const Default = meta.story({
@@ -168,89 +158,6 @@ export const WithoutIcons = meta.story({
),
});
export const Positions = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} position="top" placement="center" />
<Flex gap="3">
<Button
onPress={() =>
toastQueue.add({
title: 'Toast from top center',
description: 'This appears at the top center.',
status: 'info',
})
}
>
Top Center
</Button>
</Flex>
</>
),
});
export const AllPositions = meta.story({
render: () => {
const [currentPosition, setCurrentPosition] = useState<'top' | 'bottom'>(
'bottom',
);
const [currentPlacement, setCurrentPlacement] = useState<
'start' | 'center' | 'end'
>('end');
const showToast = (
position: 'top' | 'bottom',
placement: 'start' | 'center' | 'end',
) => {
setCurrentPosition(position);
setCurrentPlacement(placement);
toastQueue.add({
title: `${position} - ${placement}`,
description: `Toast positioned at ${position} ${placement}`,
status: 'info',
});
};
return (
<>
<ToastRegion
queue={toastQueue}
position={currentPosition}
placement={currentPlacement}
/>
<Flex direction="column" gap="4">
<div>
<strong>Top Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('top', 'start')}>
Top Start
</Button>
<Button onPress={() => showToast('top', 'center')}>
Top Center
</Button>
<Button onPress={() => showToast('top', 'end')}>Top End</Button>
</Flex>
</div>
<div>
<strong>Bottom Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('bottom', 'start')}>
Bottom Start
</Button>
<Button onPress={() => showToast('bottom', 'center')}>
Bottom Center
</Button>
<Button onPress={() => showToast('bottom', 'end')}>
Bottom End
</Button>
</Flex>
</div>
</Flex>
</>
);
},
});
export const AutoDismiss = meta.story({
render: () => (
<>
@@ -22,11 +22,12 @@ import { ToastRegionDefinition } from './definition';
import { Toast } from './Toast';
/**
* A ToastRegion displays one or more toast notifications.
* A ToastRegion displays one or more toast notifications in the bottom-right corner.
*
* @remarks
* The ToastRegion component should typically be placed once at the root of your application.
* It manages the display and positioning of all toast notifications added to its queue.
* 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 regions are ARIA landmark regions that can be navigated using F6 (forward) and
* Shift+F6 (backward) for keyboard accessibility.
*
@@ -35,28 +36,18 @@ import { Toast } from './Toast';
* @example
* Basic setup in app root:
* ```tsx
* import { ToastRegion, queue } from '@backstage/ui';
* import { ToastRegion, toastQueue } from '@backstage/ui';
*
* function App() {
* return (
* <>
* <ToastRegion queue={queue} />
* <ToastRegion queue={toastQueue} />
* <YourAppContent />
* </>
* );
* }
* ```
*
* @example
* Custom positioning:
* ```tsx
* <ToastRegion
* queue={queue}
* position="top"
* placement="center"
* />
* ```
*
* @public
*/
export const ToastRegion = forwardRef(
@@ -52,8 +52,6 @@ export const ToastRegionDefinition = defineComponent<ToastRegionOwnProps>()({
},
propDefs: {
queue: {},
position: { dataAttribute: true, default: 'bottom' },
placement: { dataAttribute: true, default: 'end' },
className: {},
},
});
@@ -64,10 +64,6 @@ export interface ToastProps extends ToastOwnProps {}
export type ToastRegionOwnProps = {
/** Toast queue instance */
queue: RAToastQueue<ToastContent>;
/** Position of the toast region (top or bottom) */
position?: Responsive<'top' | 'bottom'>;
/** Horizontal placement of toasts */
placement?: Responsive<'start' | 'center' | 'end'>;
/** Custom class name */
className?: string;
};