diff --git a/packages/ui/src/components/Toast/Toast.module.css b/packages/ui/src/components/Toast/Toast.module.css index c4024a8fc4..d0907f5a7f 100644 --- a/packages/ui/src/components/Toast/Toast.module.css +++ b/packages/ui/src/components/Toast/Toast.module.css @@ -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; } diff --git a/packages/ui/src/components/Toast/Toast.stories.tsx b/packages/ui/src/components/Toast/Toast.stories.tsx index 13220f1f3f..383b1d402a 100644 --- a/packages/ui/src/components/Toast/Toast.stories.tsx +++ b/packages/ui/src/components/Toast/Toast.stories.tsx @@ -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: () => ( <> diff --git a/packages/ui/src/components/Toast/Toast.tsx b/packages/ui/src/components/Toast/Toast.tsx index d80292b7a9..45c7e28f2e 100644 --- a/packages/ui/src/components/Toast/Toast.tsx +++ b/packages/ui/src/components/Toast/Toast.tsx @@ -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 ( | 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} /> ))} diff --git a/packages/ui/src/components/Toast/definition.ts b/packages/ui/src/components/Toast/definition.ts index fd3158f7a4..ccef6b8559 100644 --- a/packages/ui/src/components/Toast/definition.ts +++ b/packages/ui/src/components/Toast/definition.ts @@ -37,6 +37,7 @@ export const ToastDefinition = defineComponent()({ toast: {}, state: {}, index: {}, + isExpanded: {}, onClose: {}, status: { dataAttribute: true }, icon: {}, diff --git a/packages/ui/src/components/Toast/types.ts b/packages/ui/src/components/Toast/types.ts index d6b4975810..32227224f7 100644 --- a/packages/ui/src/components/Toast/types.ts +++ b/packages/ui/src/components/Toast/types.ts @@ -44,6 +44,8 @@ export type ToastOwnProps = ContainerSurfaceProps & { state: ToastState; /** 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 */