Improve style animation
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
6c18cb30b2
commit
7bef71f283
@@ -25,7 +25,6 @@
|
||||
transform: translateX(-50%);
|
||||
z-index: 100050;
|
||||
width: 300px;
|
||||
pointer-events: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -61,7 +60,8 @@
|
||||
outline: none;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
/* All toasts need pointer-events to trigger hover expansion */
|
||||
pointer-events: auto;
|
||||
|
||||
/* Stacking */
|
||||
z-index: calc(1000 - var(--toast-index));
|
||||
@@ -77,27 +77,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Expanded state on hover - show all toasts stacked with gap */
|
||||
.bui-ToastRegion:hover .bui-Toast,
|
||||
.bui-ToastRegion:focus-within .bui-Toast,
|
||||
.bui-ToastRegion[data-hover-locked] .bui-Toast {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Add padding above each toast when expanded to fill the gap */
|
||||
.bui-ToastRegion:hover .bui-Toast::before,
|
||||
.bui-ToastRegion:focus-within .bui-Toast::before,
|
||||
.bui-ToastRegion[data-hover-locked] .bui-Toast::before {
|
||||
/* 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: var(--bui-space-2);
|
||||
}
|
||||
|
||||
/* Only first toast (index 0) is interactive by default */
|
||||
.bui-Toast[style*='--toast-index: 0'] {
|
||||
/* Height matches the expanded gap (80px) to ensure continuous hover */
|
||||
height: 12px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,20 +27,95 @@ const meta = preview.meta({
|
||||
},
|
||||
});
|
||||
|
||||
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: () => (
|
||||
<>
|
||||
<ToastRegion queue={toastQueue} />
|
||||
<Button
|
||||
onPress={() =>
|
||||
toastQueue.add(
|
||||
{
|
||||
title: 'Files uploaded',
|
||||
description: '3 files uploaded successfully.',
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
const toast =
|
||||
randomToasts[Math.floor(Math.random() * randomToasts.length)];
|
||||
toastQueue.add(toast);
|
||||
}}
|
||||
>
|
||||
Show Toast
|
||||
</Button>
|
||||
|
||||
@@ -75,6 +75,7 @@ export const Toast = forwardRef(
|
||||
toast,
|
||||
state,
|
||||
index = 0,
|
||||
isExpanded = false,
|
||||
onClose,
|
||||
status,
|
||||
icon,
|
||||
@@ -157,9 +158,17 @@ export const Toast = forwardRef(
|
||||
const statusIcon = getStatusIcon();
|
||||
|
||||
// Calculate stacking values based on index
|
||||
// Each toast behind scales down 5% and moves up 12px
|
||||
const stackScale = Math.max(0, 1 - index * 0.05);
|
||||
const stackY = -index * 12;
|
||||
// Collapsed: each toast behind scales down 5% and peeks up 12px
|
||||
const collapsedScale = Math.max(0, 1 - index * 0.05);
|
||||
const collapsedY = -index * 12;
|
||||
|
||||
// Expanded: full scale, stacked vertically with gaps
|
||||
// Each toast moves up by (toast height ~72px + gap 8px) * index
|
||||
const expandedY = -index * 72;
|
||||
|
||||
// Use expanded or collapsed values based on hover state
|
||||
const animateY = isExpanded ? expandedY : collapsedY;
|
||||
const animateScale = isExpanded ? 1 : collapsedScale;
|
||||
const stackZIndex = 1000 - index;
|
||||
|
||||
// Check if this toast is being manually closed
|
||||
@@ -170,7 +179,12 @@ export const Toast = forwardRef(
|
||||
// Auto-timeout: fade out in place, stay in stack position
|
||||
const exitAnimation = isManualClose
|
||||
? { opacity: 0, y: 100, scale: 1, zIndex: 2000 }
|
||||
: { opacity: 0, y: stackY + 50, scale: stackScale, zIndex: stackZIndex };
|
||||
: {
|
||||
opacity: 0,
|
||||
y: animateY + 50,
|
||||
scale: animateScale,
|
||||
zIndex: stackZIndex,
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -186,8 +200,8 @@ export const Toast = forwardRef(
|
||||
initial={{ opacity: 0, y: 100, scale: 1 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: stackY,
|
||||
scale: stackScale,
|
||||
y: animateY,
|
||||
scale: animateScale,
|
||||
zIndex: stackZIndex,
|
||||
}}
|
||||
exit={exitAnimation}
|
||||
@@ -197,7 +211,7 @@ export const Toast = forwardRef(
|
||||
manuallyClosingToasts.delete(toast.key);
|
||||
}
|
||||
}}
|
||||
transition={{ type: 'tween', duration: 2, ease: 'easeOut' }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
{...dataAttributes}
|
||||
data-status={finalStatus}
|
||||
{...restProps}
|
||||
|
||||
@@ -69,10 +69,16 @@ export const ToastRegion = forwardRef(
|
||||
// Get ARIA props for the toast region
|
||||
const { regionProps } = useToastRegion({}, state, regionRef);
|
||||
|
||||
// Lock hover state after close to prevent stack collapse during DOM updates
|
||||
// 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;
|
||||
|
||||
// Get inverted theme mode for toasts (light when app is dark, dark when app is light)
|
||||
const invertedThemeMode = useInvertedThemeMode();
|
||||
|
||||
@@ -85,10 +91,10 @@ export const ToastRegion = forwardRef(
|
||||
clearTimeout(unlockTimerRef.current);
|
||||
}
|
||||
|
||||
// Unlock after a short delay to allow DOM to settle
|
||||
// Unlock after a short delay to allow exit animation to complete
|
||||
unlockTimerRef.current = setTimeout(() => {
|
||||
setIsHoverLocked(false);
|
||||
}, 300);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -98,6 +104,10 @@ export const ToastRegion = forwardRef(
|
||||
className={className || classes.region}
|
||||
data-theme-mode={invertedThemeMode}
|
||||
data-hover-locked={isHoverLocked ? '' : undefined}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onFocus={() => setIsHovered(true)}
|
||||
onBlur={() => setIsHovered(false)}
|
||||
{...dataAttributes}
|
||||
{...restProps}
|
||||
>
|
||||
@@ -108,6 +118,7 @@ export const ToastRegion = forwardRef(
|
||||
toast={toast}
|
||||
state={state}
|
||||
index={index}
|
||||
isExpanded={isExpanded}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const ToastDefinition = defineComponent<ToastOwnProps>()({
|
||||
toast: {},
|
||||
state: {},
|
||||
index: {},
|
||||
isExpanded: {},
|
||||
onClose: {},
|
||||
status: { dataAttribute: true },
|
||||
icon: {},
|
||||
|
||||
@@ -44,6 +44,8 @@ export type ToastOwnProps = ContainerSurfaceProps & {
|
||||
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 */
|
||||
|
||||
Reference in New Issue
Block a user