Skip to content

Commit be10e49

Browse files
authored
feat: add more swipe directions (#544)
* Add more swipe directions * tweaks * Maintain correct y position * fix tests
1 parent baa7b47 commit be10e49

File tree

4 files changed

+147
-18
lines changed

4 files changed

+147
-18
lines changed

src/index.tsx

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { toast, ToastState } from './state';
99
import './styles.css';
1010
import {
1111
isAction,
12+
SwipeDirection,
1213
type ExternalToast,
1314
type HeightT,
1415
type ToasterProps,
@@ -45,6 +46,21 @@ function cn(...classes: (string | undefined)[]) {
4546
return classes.filter(Boolean).join(' ');
4647
}
4748

49+
function getDefaultSwipeDirections(position: string): Array<SwipeDirection> {
50+
const [y, x] = position.split('-');
51+
const directions: Array<SwipeDirection> = [];
52+
53+
if (y) {
54+
directions.push(y as SwipeDirection);
55+
}
56+
57+
if (x) {
58+
directions.push(x as SwipeDirection);
59+
}
60+
61+
return directions;
62+
}
63+
4864
const Toast = (props: ToastProps) => {
4965
const {
5066
invert: ToasterInvert,
@@ -75,6 +91,8 @@ const Toast = (props: ToastProps) => {
7591
closeButtonAriaLabel = 'Close toast',
7692
pauseWhenPageIsHidden,
7793
} = props;
94+
const [swipeDirection, setSwipeDirection] = React.useState<'x' | 'y' | null>(null);
95+
const [swipeOutDirection, setSwipeOutDirection] = React.useState<'left' | 'right' | 'up' | 'down' | null>(null);
7896
const [mounted, setMounted] = React.useState(false);
7997
const [removed, setRemoved] = React.useState(false);
8098
const [swiping, setSwiping] = React.useState(false);
@@ -278,6 +296,7 @@ const Toast = (props: ToastProps) => {
278296
data-type={toastType}
279297
data-invert={invert}
280298
data-swipe-out={swipeOut}
299+
data-swipe-direction={swipeOutDirection}
281300
data-expanded={Boolean(expanded || (expandByDefault && mounted))}
282301
style={
283302
{
@@ -304,37 +323,82 @@ const Toast = (props: ToastProps) => {
304323
if (swipeOut || !dismissible) return;
305324

306325
pointerStartRef.current = null;
307-
const swipeAmount = Number(toastRef.current?.style.getPropertyValue('--swipe-amount').replace('px', '') || 0);
326+
const swipeAmountX = Number(
327+
toastRef.current?.style.getPropertyValue('--swipe-amount-x').replace('px', '') || 0,
328+
);
329+
const swipeAmountY = Number(
330+
toastRef.current?.style.getPropertyValue('--swipe-amount-y').replace('px', '') || 0,
331+
);
308332
const timeTaken = new Date().getTime() - dragStartTime.current?.getTime();
333+
334+
const swipeAmount = swipeDirection === 'x' ? swipeAmountX : swipeAmountY;
309335
const velocity = Math.abs(swipeAmount) / timeTaken;
310336

311-
// Remove only if threshold is met
312337
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
313338
setOffsetBeforeRemove(offset.current);
314339
toast.onDismiss?.(toast);
340+
341+
if (swipeDirection === 'x') {
342+
setSwipeOutDirection(swipeAmountX > 0 ? 'right' : 'left');
343+
} else {
344+
setSwipeOutDirection(swipeAmountY > 0 ? 'down' : 'up');
345+
}
346+
315347
deleteToast();
316348
setSwipeOut(true);
317349
setIsSwiped(false);
318350
return;
319351
}
320352

321-
toastRef.current?.style.setProperty('--swipe-amount', '0px');
322353
setSwiping(false);
354+
setSwipeDirection(null);
323355
}}
324356
onPointerMove={(event) => {
325357
if (!pointerStartRef.current || !dismissible) return;
326358

327-
const yPosition = event.clientY - pointerStartRef.current.y;
328359
const isHighlighted = window.getSelection()?.toString().length > 0;
329-
const swipeAmount = y === 'top' ? Math.min(0, yPosition) : Math.max(0, yPosition);
360+
if (isHighlighted) return;
330361

331-
if (Math.abs(swipeAmount) > 0) {
332-
setIsSwiped(true);
362+
const yDelta = event.clientY - pointerStartRef.current.y;
363+
const xDelta = event.clientX - pointerStartRef.current.x;
364+
365+
const swipeDirections = props.swipeDirections ?? getDefaultSwipeDirections(position);
366+
367+
// Determine swipe direction if not already locked
368+
if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) {
369+
setSwipeDirection(Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y');
333370
}
334371

335-
if (isHighlighted) return;
372+
let swipeAmount = { x: 0, y: 0 };
373+
374+
// Only apply swipe in the locked direction
375+
if (swipeDirection === 'y') {
376+
// Handle vertical swipes
377+
if (swipeDirections.includes('top') || swipeDirections.includes('bottom')) {
378+
if (swipeDirections.includes('top') && yDelta < 0) {
379+
swipeAmount.y = yDelta;
380+
} else if (swipeDirections.includes('bottom') && yDelta > 0) {
381+
swipeAmount.y = yDelta;
382+
}
383+
}
384+
} else if (swipeDirection === 'x') {
385+
// Handle horizontal swipes
386+
if (swipeDirections.includes('left') || swipeDirections.includes('right')) {
387+
if (swipeDirections.includes('left') && xDelta < 0) {
388+
swipeAmount.x = xDelta;
389+
} else if (swipeDirections.includes('right') && xDelta > 0) {
390+
swipeAmount.x = xDelta;
391+
}
392+
}
393+
}
394+
395+
if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) {
396+
setIsSwiped(true);
397+
}
336398

337-
toastRef.current?.style.setProperty('--swipe-amount', `${swipeAmount}px`);
399+
// Apply transform using both x and y values
400+
toastRef.current?.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`);
401+
toastRef.current?.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`);
338402
}}
339403
>
340404
{closeButton && !toast.jsx ? (
@@ -783,6 +847,7 @@ const Toaster = forwardRef<HTMLElement, ToasterProps>(function Toaster(props, re
783847
loadingIcon={loadingIcon}
784848
expanded={expanded}
785849
pauseWhenPageIsHidden={pauseWhenPageIsHidden}
850+
swipeDirections={props.swipeDirections}
786851
/>
787852
))}
788853
</ol>

src/styles.css

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,8 @@
254254
:where([data-sonner-toast][data-swiping='true'])::before {
255255
content: '';
256256
position: absolute;
257-
left: 0;
258-
right: 0;
257+
left: -50%;
258+
right: -50%;
259259
height: 100%;
260260
z-index: -1;
261261
}
@@ -341,7 +341,7 @@
341341
}
342342

343343
[data-sonner-toast][data-swiping='true'] {
344-
transform: var(--y) translateY(var(--swipe-amount, 0px));
344+
transform: var(--y) translateY(var(--swipe-amount-y, 0px)) translateX(var(--swipe-amount-x, 0px));
345345
transition: none;
346346
}
347347

@@ -351,17 +351,71 @@
351351

352352
[data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'],
353353
[data-sonner-toast][data-swipe-out='true'][data-y-position='top'] {
354-
animation: swipe-out 200ms ease-out forwards;
354+
animation-duration: 200ms;
355+
animation-timing-function: ease-out;
356+
animation-fill-mode: forwards;
357+
}
358+
359+
[data-sonner-toast][data-swipe-out='true'][data-swipe-direction='left'] {
360+
animation-name: swipe-out-left;
361+
}
362+
363+
[data-sonner-toast][data-swipe-out='true'][data-swipe-direction='right'] {
364+
animation-name: swipe-out-right;
365+
}
366+
367+
[data-sonner-toast][data-swipe-out='true'][data-swipe-direction='up'] {
368+
animation-name: swipe-out-up;
369+
}
370+
371+
[data-sonner-toast][data-swipe-out='true'][data-swipe-direction='down'] {
372+
animation-name: swipe-out-down;
373+
}
374+
375+
@keyframes swipe-out-left {
376+
from {
377+
transform: var(--y) translateX(var(--swipe-amount-x));
378+
opacity: 1;
379+
}
380+
381+
to {
382+
transform: var(--y) translateX(calc(var(--swipe-amount-x) - 100%));
383+
opacity: 0;
384+
}
385+
}
386+
387+
@keyframes swipe-out-right {
388+
from {
389+
transform: var(--y) translateX(var(--swipe-amount-x));
390+
opacity: 1;
391+
}
392+
393+
to {
394+
transform: var(--y) translateX(calc(var(--swipe-amount-x) + 100%));
395+
opacity: 0;
396+
}
397+
}
398+
399+
@keyframes swipe-out-up {
400+
from {
401+
transform: var(--y) translateY(var(--swipe-amount-y));
402+
opacity: 1;
403+
}
404+
405+
to {
406+
transform: var(--y) translateY(calc(var(--swipe-amount-y) - 100%));
407+
opacity: 0;
408+
}
355409
}
356410

357-
@keyframes swipe-out {
411+
@keyframes swipe-out-down {
358412
from {
359-
transform: translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount)));
413+
transform: var(--y) translateY(var(--swipe-amount-y));
360414
opacity: 1;
361415
}
362416

363417
to {
364-
transform: translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount) + var(--lift) * -100%));
418+
transform: var(--y) translateY(calc(var(--swipe-amount-y) + 100%));
365419
opacity: 0;
366420
}
367421
}

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export interface ToasterProps {
130130
offset?: Offset;
131131
mobileOffset?: Offset;
132132
dir?: 'rtl' | 'ltr' | 'auto';
133+
swipeDirections?: SwipeDirection[];
133134
/**
134135
* @deprecated Please use the `icons` prop instead:
135136
* ```jsx
@@ -144,10 +145,13 @@ export interface ToasterProps {
144145
pauseWhenPageIsHidden?: boolean;
145146
}
146147

148+
export type SwipeDirection = 'top' | 'right' | 'bottom' | 'left';
149+
147150
export interface ToastProps {
148151
toast: ToastT;
149152
toasts: ToastT[];
150153
index: number;
154+
swipeDirections?: SwipeDirection[];
151155
expanded: boolean;
152156
invert: boolean;
153157
heights: HeightT[];

test/tests/basic.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,18 @@ test.describe('Basic functionality', () => {
112112
const dragBoundingBox = await toast.boundingBox();
113113

114114
if (!dragBoundingBox) return;
115-
await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y);
116115

116+
// Initial touch point
117+
await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y);
117118
await page.mouse.down();
118-
await page.mouse.move(0, dragBoundingBox.y + 300);
119119

120+
// Move mouse slightly to determine swipe direction
121+
await page.mouse.move(dragBoundingBox.x + dragBoundingBox.width / 2, dragBoundingBox.y + 10);
122+
123+
// Complete the swipe
124+
await page.mouse.move(0, dragBoundingBox.y + 300);
120125
await page.mouse.up();
126+
121127
await expect(page.getByTestId('dismiss-el')).toHaveCount(1);
122128
});
123129

0 commit comments

Comments
 (0)