Improve hover animation on toast
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
69a323b9c4
commit
53def9cff7
@@ -51,7 +51,6 @@
|
||||
"@remixicon/react": "^4.6.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^12.29.2",
|
||||
"react-aria-components": "^1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -78,21 +78,53 @@
|
||||
|
||||
/* Stacking from bottom */
|
||||
.bui-Toast {
|
||||
--swipe-x: 0;
|
||||
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
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));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Disable transitions during swipe for immediate feedback */
|
||||
.bui-Toast[data-swiping] {
|
||||
transition: none !important;
|
||||
opacity: calc((1 - (var(--toast-index) * 0.15)) * (1 - (var(--swipe-x) / 400)));
|
||||
}
|
||||
|
||||
/* Only first toast is interactive */
|
||||
.bui-Toast[style*='--toast-index: 0'] {
|
||||
/* 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 {
|
||||
transform: translateY(calc((var(--toast-index) * -100%) - (var(--toast-index) * var(--bui-space-2)))) scale(1);
|
||||
opacity: 1;
|
||||
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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--bui-space-2);
|
||||
}
|
||||
|
||||
.bui-Toast:not([style*='--toast-index: 0']) {
|
||||
/* Only first toast (index 0) is interactive by default */
|
||||
.bui-Toast {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bui-Toast[style*='--toast-index: 0'] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Status variants */
|
||||
.bui-Toast[data-status='info'] {
|
||||
@@ -193,10 +225,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Starting state - toast entering */
|
||||
.bui-Toast[data-starting-style] {
|
||||
transform: translateY(calc(100% + 2rem)) scale(1) !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bui-Toast {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
html.bui-toast-add::view-transition-new(*),
|
||||
html.bui-toast-remove::view-transition-old(*) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef, Ref, isValidElement, ReactElement, useRef } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useToast } from '@react-aria/toast';
|
||||
import { useButton } from 'react-aria';
|
||||
import {
|
||||
forwardRef,
|
||||
Ref,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
UNSTABLE_Toast as RAToast,
|
||||
UNSTABLE_ToastStateContext,
|
||||
Button as RAButton,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
RiInformationLine,
|
||||
RiCheckLine,
|
||||
@@ -28,8 +39,6 @@ import {
|
||||
import type { ToastProps } from './types';
|
||||
import { useDefinition } from '../../hooks/useDefinition';
|
||||
import { ToastDefinition } from './definition';
|
||||
import type { ToastState } from 'react-stately';
|
||||
import type { ToastContent } from './types';
|
||||
|
||||
/**
|
||||
* A Toast displays a brief, temporary notification of actions, errors, or other events in an application.
|
||||
@@ -44,15 +53,15 @@ import type { ToastContent } from './types';
|
||||
* @example
|
||||
* Basic usage with queue:
|
||||
* ```tsx
|
||||
* import { queue } from '@backstage/ui';
|
||||
* import { toastQueue } from '@backstage/ui';
|
||||
*
|
||||
* queue.add({ title: 'File saved successfully', status: 'success' });
|
||||
* toastQueue.add({ title: 'File saved successfully', status: 'success' });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* With description and auto-dismiss:
|
||||
* ```tsx
|
||||
* queue.add(
|
||||
* toastQueue.add(
|
||||
* {
|
||||
* title: 'Update available',
|
||||
* description: 'A new version is ready to install.',
|
||||
@@ -65,17 +74,80 @@ import type { ToastContent } from './types';
|
||||
* @public
|
||||
*/
|
||||
export const Toast = forwardRef(
|
||||
(props: ToastProps, _forwardedRef: Ref<HTMLDivElement>) => {
|
||||
const { ownProps, dataAttributes } = useDefinition(ToastDefinition, props);
|
||||
const { classes, toast, state, index = 0, status, icon } = ownProps;
|
||||
(props: ToastProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { ownProps, restProps, dataAttributes } = useDefinition(
|
||||
ToastDefinition,
|
||||
props,
|
||||
);
|
||||
const { classes, toast, onSwipeEnd, status, icon } = ownProps;
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
// Get state from context
|
||||
const state = useContext(UNSTABLE_ToastStateContext);
|
||||
|
||||
const { toastProps, titleProps, descriptionProps, closeButtonProps } =
|
||||
useToast({ toast }, state, ref);
|
||||
// Calculate index from state
|
||||
const visibleToasts = state?.visibleToasts || [];
|
||||
const arrayIndex = visibleToasts.findIndex(t => t.key === toast.key);
|
||||
const index = arrayIndex >= 0 ? arrayIndex : 0;
|
||||
|
||||
const { buttonProps } = useButton(closeButtonProps, closeButtonRef);
|
||||
// Track starting state for enter animation
|
||||
const [isStarting, setIsStarting] = useState(true);
|
||||
|
||||
// Track swipe position
|
||||
const [swipeX, setSwipeX] = useState(0);
|
||||
const [isSwiping, setIsSwiping] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const startXRef = useRef(0);
|
||||
const toastRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Remove starting state after brief delay to trigger animation
|
||||
const timer = setTimeout(() => setIsStarting(false), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
// Check if region is hovered (expanded state)
|
||||
const region = toastRef.current?.closest(
|
||||
'[role="region"]',
|
||||
) as HTMLElement;
|
||||
setIsExpanded(region?.matches(':hover') || false);
|
||||
|
||||
setIsSwiping(true);
|
||||
startXRef.current = e.clientX;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isSwiping) return;
|
||||
|
||||
const deltaX = e.clientX - startXRef.current;
|
||||
// Only allow swipe right
|
||||
if (deltaX > 0) {
|
||||
setSwipeX(deltaX);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (!isSwiping) return;
|
||||
|
||||
setIsSwiping(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
|
||||
// Notify parent that swipe ended to lock hover
|
||||
onSwipeEnd?.();
|
||||
|
||||
// If swiped more than 150px, close the toast
|
||||
if (swipeX > 150) {
|
||||
// Animate off screen before closing
|
||||
setSwipeX(400);
|
||||
setTimeout(() => {
|
||||
state?.close(toast.key);
|
||||
}, 200);
|
||||
} else {
|
||||
// Spring back
|
||||
setSwipeX(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Get content from toast
|
||||
const content = toast.content;
|
||||
@@ -116,46 +188,71 @@ export const Toast = forwardRef(
|
||||
const statusIcon = getStatusIcon();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
<RAToast
|
||||
toast={toast}
|
||||
ref={node => {
|
||||
if (toastRef) {
|
||||
(
|
||||
toastRef as React.MutableRefObject<HTMLDivElement | null>
|
||||
).current = node;
|
||||
}
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
|
||||
node;
|
||||
}
|
||||
}}
|
||||
className={classes.root}
|
||||
style={
|
||||
{
|
||||
'--toast-index': index,
|
||||
'--swipe-x': swipeX,
|
||||
transform:
|
||||
swipeX > 0
|
||||
? isExpanded
|
||||
? `translateX(${swipeX}px) translateY(calc((var(--toast-index) * -100%) - (var(--toast-index) * var(--bui-space-2)))) scale(1)`
|
||||
: `translateX(${swipeX}px) translateY(calc(var(--toast-index) * var(--toast-peek) * -1)) scale(var(--toast-scale))`
|
||||
: undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...toastProps}
|
||||
data-swiping={isSwiping ? '' : undefined}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={() => {
|
||||
setIsSwiping(false);
|
||||
setSwipeX(0);
|
||||
}}
|
||||
{...dataAttributes}
|
||||
data-status={finalStatus}
|
||||
initial={{ y: '150%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '150%', opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
data-starting-style={isStarting ? '' : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
<div className={classes.content}>
|
||||
{statusIcon && <div className={classes.icon}>{statusIcon}</div>}
|
||||
<div>
|
||||
<div {...titleProps} className={classes.title}>
|
||||
{content.title}
|
||||
</div>
|
||||
<div className={classes.title}>{content.title}</div>
|
||||
{content.description && (
|
||||
<div {...descriptionProps} className={classes.description}>
|
||||
{content.description}
|
||||
</div>
|
||||
<div className={classes.description}>{content.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={closeButtonRef}
|
||||
<RAButton
|
||||
slot="close"
|
||||
className={classes.closeButton}
|
||||
onPress={() => {
|
||||
// Lock hover first to prevent collapse
|
||||
onSwipeEnd?.();
|
||||
// Small delay before actually closing to let lock take effect
|
||||
setTimeout(() => {
|
||||
state?.close(toast.key);
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<RiCloseLine aria-hidden="true" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</RAButton>
|
||||
</RAToast>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,11 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef, Ref, useEffect, useState, useRef } from 'react';
|
||||
import { useToastRegion } from '@react-aria/toast';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import type { QueuedToast, ToastState } from 'react-stately';
|
||||
import type { ToastRegionProps, ToastContent } from './types';
|
||||
import { forwardRef, Ref, useState, useRef } from 'react';
|
||||
import { UNSTABLE_ToastRegion as RAToastRegion } from 'react-aria-components';
|
||||
import type { ToastRegionProps } from './types';
|
||||
import { useDefinition } from '../../hooks/useDefinition';
|
||||
import { ToastRegionDefinition } from './definition';
|
||||
import { Toast } from './Toast';
|
||||
@@ -53,57 +51,58 @@ import { Toast } from './Toast';
|
||||
* @public
|
||||
*/
|
||||
export const ToastRegion = forwardRef(
|
||||
(props: ToastRegionProps, _ref: Ref<HTMLDivElement>) => {
|
||||
(props: ToastRegionProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { ownProps, restProps, dataAttributes } = useDefinition(
|
||||
ToastRegionDefinition,
|
||||
props,
|
||||
);
|
||||
const { classes, queue, className } = ownProps;
|
||||
|
||||
const [, forceUpdate] = useState({});
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// Lock hover state after swipe/close to prevent collapse
|
||||
const [isHoverLocked, setIsHoverLocked] = useState(false);
|
||||
const unlockTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to queue changes to trigger re-renders
|
||||
const unsubscribe = queue.subscribe(() => forceUpdate({}));
|
||||
return unsubscribe;
|
||||
}, [queue]);
|
||||
|
||||
const state: ToastState<ToastContent> = {
|
||||
visibleToasts: queue.visibleToasts,
|
||||
add: (content: ToastContent, options?: any) =>
|
||||
queue.add(content, options),
|
||||
close: (key: string) => queue.close(key),
|
||||
pauseAll: () => queue.pauseAll(),
|
||||
resumeAll: () => queue.resumeAll(),
|
||||
const lockHover = () => {
|
||||
setIsHoverLocked(true);
|
||||
// Clear any pending unlock
|
||||
if (unlockTimerRef.current) {
|
||||
clearTimeout(unlockTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const { regionProps } = useToastRegion({}, state, ref);
|
||||
const unlockHover = (e: React.MouseEvent) => {
|
||||
// Check if mouse is actually leaving the region bounds
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const isOutside =
|
||||
e.clientX < rect.left ||
|
||||
e.clientX > rect.right ||
|
||||
e.clientY < rect.top ||
|
||||
e.clientY > rect.bottom;
|
||||
|
||||
if (!isOutside) {
|
||||
// Mouse is still inside, don't unlock yet
|
||||
return;
|
||||
}
|
||||
|
||||
// Delay unlock to prevent collapse during DOM updates
|
||||
unlockTimerRef.current = setTimeout(() => {
|
||||
setIsHoverLocked(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...regionProps}
|
||||
<RAToastRegion
|
||||
ref={ref}
|
||||
queue={queue}
|
||||
className={className || classes.region}
|
||||
aria-label="Notifications"
|
||||
data-hover-locked={isHoverLocked ? '' : undefined}
|
||||
{...dataAttributes}
|
||||
{...restProps}
|
||||
onMouseLeave={unlockHover}
|
||||
>
|
||||
<ol style={{ margin: 0, padding: 0, listStyleType: 'none' }}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{queue.visibleToasts.map(
|
||||
(toast: QueuedToast<ToastContent>, arrayIndex: number) => {
|
||||
// Reverse index so newest toast (at end of array) gets index 0
|
||||
const index = queue.visibleToasts.length - 1 - arrayIndex;
|
||||
return (
|
||||
<li key={toast.key} style={{ display: 'contents' }}>
|
||||
<Toast toast={toast} index={index} state={state} />
|
||||
</li>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ol>
|
||||
</div>
|
||||
{({ toast }) => <Toast toast={toast} onSwipeEnd={lockHover} />}
|
||||
</RAToastRegion>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,8 +35,7 @@ export const ToastDefinition = defineComponent<ToastOwnProps>()({
|
||||
surface: 'container',
|
||||
propDefs: {
|
||||
toast: {},
|
||||
state: {},
|
||||
index: {},
|
||||
onSwipeEnd: {},
|
||||
status: { dataAttribute: true },
|
||||
icon: {},
|
||||
},
|
||||
|
||||
@@ -41,10 +41,8 @@ export interface ToastContent {
|
||||
export type ToastOwnProps = {
|
||||
/** Toast object from the queue */
|
||||
toast: QueuedToast<ToastContent>;
|
||||
/** Toast state for hooks */
|
||||
state: ToastState<ToastContent>;
|
||||
/** Index of the toast in the stack (0 = frontmost) */
|
||||
index: number;
|
||||
/** Callback when swipe ends */
|
||||
onSwipeEnd?: () => void;
|
||||
/** Override status from content */
|
||||
status?: Responsive<'info' | 'success' | 'warning' | 'danger'>;
|
||||
/** Override icon from content */
|
||||
|
||||
@@ -8123,7 +8123,6 @@ __metadata:
|
||||
eslint-plugin-storybook: "npm:^10.3.0-alpha.1"
|
||||
glob: "npm:^11.0.1"
|
||||
globals: "npm:^15.11.0"
|
||||
motion: "npm:^12.29.2"
|
||||
react: "npm:^18.0.2"
|
||||
react-aria-components: "npm:^1.14.0"
|
||||
react-dom: "npm:^18.0.2"
|
||||
@@ -32469,28 +32468,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"framer-motion@npm:^12.29.2":
|
||||
version: 12.29.2
|
||||
resolution: "framer-motion@npm:12.29.2"
|
||||
dependencies:
|
||||
motion-dom: "npm:^12.29.2"
|
||||
motion-utils: "npm:^12.29.2"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/13ea43efa814e4df1f3a0bf7ee6c1736f3f01b96cb677e560b62d58db8768f00a53c17156bb5bf6bf06b786370468495dbb6b0a83e3b5b5289e5235046ccb03b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"framer-motion@npm:^6.5.1":
|
||||
version: 6.5.1
|
||||
resolution: "framer-motion@npm:6.5.1"
|
||||
@@ -39904,43 +39881,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-dom@npm:^12.29.2":
|
||||
version: 12.29.2
|
||||
resolution: "motion-dom@npm:12.29.2"
|
||||
dependencies:
|
||||
motion-utils: "npm:^12.29.2"
|
||||
checksum: 10/d1eeb6840363cc2b4d1e1c2d9a91becc117bb64747482b1a99f7f2d4da709f049c9544c3a566c641cada368037019c0fafd3e7cf56f0477e88d65e9886bcdc40
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-utils@npm:^12.29.2":
|
||||
version: 12.29.2
|
||||
resolution: "motion-utils@npm:12.29.2"
|
||||
checksum: 10/ae5f9be58c07939af72334894ed1a18653d724946182a718dc3d11268ef26e63804c3f16dee5a6110596d4406b539c4513822b74f86adebef9488601c34b18b7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion@npm:^12.29.2":
|
||||
version: 12.29.2
|
||||
resolution: "motion@npm:12.29.2"
|
||||
dependencies:
|
||||
framer-motion: "npm:^12.29.2"
|
||||
tslib: "npm:^2.4.0"
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/0480a984192dd0f884709a007c1d28f31ff5fcdecb6674517edbe11ac0e318efc845373f35800e0606899613e6617ec87b1c09d5ecf3f5a5aeb3eb25fef22c7d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mri@npm:1.1.4":
|
||||
version: 1.1.4
|
||||
resolution: "mri@npm:1.1.4"
|
||||
|
||||
Reference in New Issue
Block a user