diff --git a/src/index.tsx b/src/index.tsx index 6a7b8ec..15255f0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import { toast, ToastState } from './state'; import './styles.css'; import { isAction, + SwipeDirection, type ExternalToast, type HeightT, type ToasterProps, @@ -45,6 +46,21 @@ function cn(...classes: (string | undefined)[]) { return classes.filter(Boolean).join(' '); } +function getDefaultSwipeDirections(position: string): Array { + const [y, x] = position.split('-'); + const directions: Array = []; + + if (y) { + directions.push(y as SwipeDirection); + } + + if (x) { + directions.push(x as SwipeDirection); + } + + return directions; +} + const Toast = (props: ToastProps) => { const { invert: ToasterInvert, @@ -75,6 +91,8 @@ const Toast = (props: ToastProps) => { closeButtonAriaLabel = 'Close toast', pauseWhenPageIsHidden, } = props; + const [swipeDirection, setSwipeDirection] = React.useState<'x' | 'y' | null>(null); + const [swipeOutDirection, setSwipeOutDirection] = React.useState<'left' | 'right' | 'up' | 'down' | null>(null); const [mounted, setMounted] = React.useState(false); const [removed, setRemoved] = React.useState(false); const [swiping, setSwiping] = React.useState(false); @@ -278,6 +296,7 @@ const Toast = (props: ToastProps) => { data-type={toastType} data-invert={invert} data-swipe-out={swipeOut} + data-swipe-direction={swipeOutDirection} data-expanded={Boolean(expanded || (expandByDefault && mounted))} style={ { @@ -304,37 +323,82 @@ const Toast = (props: ToastProps) => { if (swipeOut || !dismissible) return; pointerStartRef.current = null; - const swipeAmount = Number(toastRef.current?.style.getPropertyValue('--swipe-amount').replace('px', '') || 0); + const swipeAmountX = Number( + toastRef.current?.style.getPropertyValue('--swipe-amount-x').replace('px', '') || 0, + ); + const swipeAmountY = Number( + toastRef.current?.style.getPropertyValue('--swipe-amount-y').replace('px', '') || 0, + ); const timeTaken = new Date().getTime() - dragStartTime.current?.getTime(); + + const swipeAmount = swipeDirection === 'x' ? swipeAmountX : swipeAmountY; const velocity = Math.abs(swipeAmount) / timeTaken; - // Remove only if threshold is met if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { setOffsetBeforeRemove(offset.current); toast.onDismiss?.(toast); + + if (swipeDirection === 'x') { + setSwipeOutDirection(swipeAmountX > 0 ? 'right' : 'left'); + } else { + setSwipeOutDirection(swipeAmountY > 0 ? 'down' : 'up'); + } + deleteToast(); setSwipeOut(true); setIsSwiped(false); return; } - toastRef.current?.style.setProperty('--swipe-amount', '0px'); setSwiping(false); + setSwipeDirection(null); }} onPointerMove={(event) => { if (!pointerStartRef.current || !dismissible) return; - const yPosition = event.clientY - pointerStartRef.current.y; const isHighlighted = window.getSelection()?.toString().length > 0; - const swipeAmount = y === 'top' ? Math.min(0, yPosition) : Math.max(0, yPosition); + if (isHighlighted) return; - if (Math.abs(swipeAmount) > 0) { - setIsSwiped(true); + const yDelta = event.clientY - pointerStartRef.current.y; + const xDelta = event.clientX - pointerStartRef.current.x; + + const swipeDirections = props.swipeDirections ?? getDefaultSwipeDirections(position); + + // Determine swipe direction if not already locked + if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) { + setSwipeDirection(Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y'); } - if (isHighlighted) return; + let swipeAmount = { x: 0, y: 0 }; + + // Only apply swipe in the locked direction + if (swipeDirection === 'y') { + // Handle vertical swipes + if (swipeDirections.includes('top') || swipeDirections.includes('bottom')) { + if (swipeDirections.includes('top') && yDelta < 0) { + swipeAmount.y = yDelta; + } else if (swipeDirections.includes('bottom') && yDelta > 0) { + swipeAmount.y = yDelta; + } + } + } else if (swipeDirection === 'x') { + // Handle horizontal swipes + if (swipeDirections.includes('left') || swipeDirections.includes('right')) { + if (swipeDirections.includes('left') && xDelta < 0) { + swipeAmount.x = xDelta; + } else if (swipeDirections.includes('right') && xDelta > 0) { + swipeAmount.x = xDelta; + } + } + } + + if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) { + setIsSwiped(true); + } - toastRef.current?.style.setProperty('--swipe-amount', `${swipeAmount}px`); + // Apply transform using both x and y values + toastRef.current?.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`); + toastRef.current?.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`); }} > {closeButton && !toast.jsx ? ( @@ -783,6 +847,7 @@ const Toaster = forwardRef(function Toaster(props, re loadingIcon={loadingIcon} expanded={expanded} pauseWhenPageIsHidden={pauseWhenPageIsHidden} + swipeDirections={props.swipeDirections} /> ))} diff --git a/src/styles.css b/src/styles.css index 913b7cb..ddca7db 100644 --- a/src/styles.css +++ b/src/styles.css @@ -254,8 +254,8 @@ :where([data-sonner-toast][data-swiping='true'])::before { content: ''; position: absolute; - left: 0; - right: 0; + left: -50%; + right: -50%; height: 100%; z-index: -1; } @@ -341,7 +341,7 @@ } [data-sonner-toast][data-swiping='true'] { - transform: var(--y) translateY(var(--swipe-amount, 0px)); + transform: var(--y) translateY(var(--swipe-amount-y, 0px)) translateX(var(--swipe-amount-x, 0px)); transition: none; } @@ -351,17 +351,71 @@ [data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'], [data-sonner-toast][data-swipe-out='true'][data-y-position='top'] { - animation: swipe-out 200ms ease-out forwards; + animation-duration: 200ms; + animation-timing-function: ease-out; + animation-fill-mode: forwards; +} + +[data-sonner-toast][data-swipe-out='true'][data-swipe-direction='left'] { + animation-name: swipe-out-left; +} + +[data-sonner-toast][data-swipe-out='true'][data-swipe-direction='right'] { + animation-name: swipe-out-right; +} + +[data-sonner-toast][data-swipe-out='true'][data-swipe-direction='up'] { + animation-name: swipe-out-up; +} + +[data-sonner-toast][data-swipe-out='true'][data-swipe-direction='down'] { + animation-name: swipe-out-down; +} + +@keyframes swipe-out-left { + from { + transform: var(--y) translateX(var(--swipe-amount-x)); + opacity: 1; + } + + to { + transform: var(--y) translateX(calc(var(--swipe-amount-x) - 100%)); + opacity: 0; + } +} + +@keyframes swipe-out-right { + from { + transform: var(--y) translateX(var(--swipe-amount-x)); + opacity: 1; + } + + to { + transform: var(--y) translateX(calc(var(--swipe-amount-x) + 100%)); + opacity: 0; + } +} + +@keyframes swipe-out-up { + from { + transform: var(--y) translateY(var(--swipe-amount-y)); + opacity: 1; + } + + to { + transform: var(--y) translateY(calc(var(--swipe-amount-y) - 100%)); + opacity: 0; + } } -@keyframes swipe-out { +@keyframes swipe-out-down { from { - transform: translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount))); + transform: var(--y) translateY(var(--swipe-amount-y)); opacity: 1; } to { - transform: translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount) + var(--lift) * -100%)); + transform: var(--y) translateY(calc(var(--swipe-amount-y) + 100%)); opacity: 0; } } diff --git a/src/types.ts b/src/types.ts index 4add2eb..b1b06fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,6 +130,7 @@ export interface ToasterProps { offset?: Offset; mobileOffset?: Offset; dir?: 'rtl' | 'ltr' | 'auto'; + swipeDirections?: SwipeDirection[]; /** * @deprecated Please use the `icons` prop instead: * ```jsx @@ -144,10 +145,13 @@ export interface ToasterProps { pauseWhenPageIsHidden?: boolean; } +export type SwipeDirection = 'top' | 'right' | 'bottom' | 'left'; + export interface ToastProps { toast: ToastT; toasts: ToastT[]; index: number; + swipeDirections?: SwipeDirection[]; expanded: boolean; invert: boolean; heights: HeightT[]; diff --git a/test/tests/basic.spec.ts b/test/tests/basic.spec.ts index bb2d695..019f400 100644 --- a/test/tests/basic.spec.ts +++ b/test/tests/basic.spec.ts @@ -112,12 +112,18 @@ test.describe('Basic functionality', () => { const dragBoundingBox = await toast.boundingBox(); if (!dragBoundingBox) return; - await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y); + // Initial touch point + await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y); await page.mouse.down(); - await page.mouse.move(0, dragBoundingBox.y + 300); + // Move mouse slightly to determine swipe direction + await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y + 10); + + // Complete the swipe + await page.mouse.move(0, dragBoundingBox.y + 300); await page.mouse.up(); + await expect(page.getByTestId('dismiss-el')).toHaveCount(1); });