Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/app/components/SwipeableOverlayWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { animate } from 'framer-motion';
import { SwipeableOverlayWrapper } from './SwipeableOverlayWrapper';

vi.mock('$utils/platform', () => ({
isMobileOrTablet: () => true,
}));

vi.mock('framer-motion', () => {
const animateMock = vi.fn<(...args: unknown[]) => Promise<void>>(() => Promise.resolve());
return {
animate: animateMock,
motion: { div: 'div' },
useMotionValue: (initial: number) => {
let value = initial;
return {
get: () => value,
set: (next: number) => {
value = next;
},
stop: vi.fn<() => void>(),
};
},
};
});

const touchList = (target: HTMLElement, clientX: number, clientY: number) => {
const point = { identifier: 0, target, clientX, clientY, pageX: clientX, pageY: clientY };
return { touches: [point], targetTouches: [point], changedTouches: [point] };
};

function renderWrapper(direction: 'left' | 'right' | 'both', onClose: () => void) {
render(
<SwipeableOverlayWrapper direction={direction} onClose={onClose}>
<div data-testid="content" />
</SwipeableOverlayWrapper>
);
return screen.getByTestId('content');
}

describe('SwipeableOverlayWrapper', () => {
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 320 });
vi.mocked(animate).mockClear();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('closes after a single horizontal move past the distance threshold', async () => {
const onClose = vi.fn<() => void>();
const content = renderWrapper('both', onClose);

fireEvent.touchStart(content, touchList(content, 260, 100));
fireEvent.touchMove(content, touchList(content, 100, 100));
fireEvent.touchEnd(content, {
...touchList(content, 100, 100),
touches: [],
targetTouches: [],
});

await Promise.resolve();

expect(animate).toHaveBeenCalledWith(expect.anything(), -320, {
duration: 0.22,
ease: 'easeOut',
});
expect(onClose).toHaveBeenCalledOnce();
});

it('leaves a vertical member-list scroll alone', () => {
const onClose = vi.fn<() => void>();
const content = renderWrapper('both', onClose);

fireEvent.touchStart(content, touchList(content, 160, 100));
fireEvent.touchMove(content, touchList(content, 165, 260));
fireEvent.touchEnd(content, {
...touchList(content, 165, 260),
touches: [],
targetTouches: [],
});

expect(onClose).not.toHaveBeenCalled();
expect(animate).not.toHaveBeenCalled();
});

it('does not close a cancelled horizontal gesture', () => {
const onClose = vi.fn<() => void>();
const content = renderWrapper('both', onClose);

fireEvent.touchStart(content, touchList(content, 260, 100));
fireEvent.touchMove(content, touchList(content, 100, 100));
fireEvent.touchCancel(content, { touches: [], targetTouches: [] });

expect(onClose).not.toHaveBeenCalled();
expect(animate).toHaveBeenCalledWith(expect.anything(), 0, {
duration: 0.22,
ease: 'easeOut',
});
});

it('does not close on a disallowed swipe direction', () => {
const onClose = vi.fn<() => void>();
const content = renderWrapper('right', onClose);

fireEvent.touchStart(content, touchList(content, 260, 100));
fireEvent.touchMove(content, touchList(content, 100, 100));
fireEvent.touchEnd(content, {
...touchList(content, 100, 100),
touches: [],
targetTouches: [],
});

expect(onClose).not.toHaveBeenCalled();
});
});
157 changes: 113 additions & 44 deletions src/app/components/SwipeableOverlayWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import type { ReactNode } from 'react';
import { useRef } from 'react';
import { animate, motion, useMotionValue } from 'framer-motion';
import { useDrag } from '@use-gesture/react';
import { isMobileOrTablet } from '$utils/platform';

const SETTLE_MS = 220;
const LOCK_THRESHOLD_PX = 8;
const COMMIT_FRACTION = 0.22;
const VELOCITY_THRESHOLD = 0.45; // px per ms

const getViewportWidth = () => document.documentElement.clientWidth || window.innerWidth;

type GestureMode = 'pending' | 'vertical' | 'horizontal' | 'blocked';

type ActiveGesture = {
startX: number;
startY: number;
lastX: number;
lastTime: number;
velocityX: number;
mode: GestureMode;
lockOffset: number;
};

interface SwipeableOverlayWrapperProps {
children: ReactNode;
onClose: () => void;
direction: 'left' | 'right';
direction: 'left' | 'right' | 'both';
}

export function SwipeableOverlayWrapper({
Expand All @@ -15,55 +34,51 @@ export function SwipeableOverlayWrapper({
direction,
}: SwipeableOverlayWrapperProps) {
const x = useMotionValue(0);
const gestureRef = useRef<ActiveGesture>();
const closeCommittedRef = useRef(false);

const bind = useDrag(
({ first, active, offset: [ox], velocity: [vx], direction: [dx], event, cancel }) => {
if (first && event && 'target' in event && event.target instanceof HTMLElement) {
if (event.target.closest('[data-gestures="ignore"]')) {
cancel();
return;
}
}
const acceptsLeft = direction !== 'right';
const acceptsRight = direction !== 'left';

if (!isMobileOrTablet()) return;
const clampOffset = (val: number, viewportWidth: number) => {
let v = val;
if (!acceptsLeft) v = Math.max(0, v);
if (!acceptsRight) v = Math.min(0, v);
return Math.max(-viewportWidth, Math.min(viewportWidth, v));
};

event.stopPropagation();
const finish = (commitEligible: boolean) => {
const gesture = gestureRef.current;
gestureRef.current = undefined;
if (!gesture || gesture.mode !== 'horizontal') return;

let val = ox;
if (commitEligible) {
const viewportWidth = getViewportWidth();
const val = x.get();
const swipedLeft =
acceptsLeft &&
val < 0 &&
(val <= -viewportWidth * COMMIT_FRACTION || gesture.velocityX <= -VELOCITY_THRESHOLD);
const swipedRight =
acceptsRight &&
val > 0 &&
(val >= viewportWidth * COMMIT_FRACTION || gesture.velocityX >= VELOCITY_THRESHOLD);

if (direction === 'left' && val > 0) val = 0;
if (direction === 'right' && val < 0) val = 0;

if (active) {
// Take over any settling spring; offset is seeded from the live position.
if (first) x.stop();
x.set(val);
} else {
const swipeThreshold = 100;
const velocityThreshold = 0.5;

const swipedLeft =
direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0));
const swipedRight =
direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0));

if (swipedLeft || swipedRight) {
if (swipedLeft || swipedRight) {
closeCommittedRef.current = true;
const target = swipedLeft ? -viewportWidth : viewportWidth;
void animate(x, target, { duration: SETTLE_MS / 1000, ease: 'easeOut' }).then(() => {
if (!closeCommittedRef.current) return;
onClose();
}

animate(x, 0, { type: 'spring', stiffness: 400, damping: 40 });
closeCommittedRef.current = false;
animate(x, 0, { duration: SETTLE_MS / 1000, ease: 'easeOut' });
});
return;
}
},
{
axis: 'x',
bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 },
rubberband: true,
filterTaps: true,
pointer: { capture: true },
eventOptions: { passive: true },
from: () => [x.get(), 0],
}
);

animate(x, 0, { duration: SETTLE_MS / 1000, ease: 'easeOut' });
};

if (!isMobileOrTablet()) {
return (
Expand All @@ -83,14 +98,68 @@ export function SwipeableOverlayWrapper({

return (
<div
{...bind()}
onTouchStart={(event) => {
if (closeCommittedRef.current) return;
if (event.touches.length !== 1) {
finish(false);
return;
}
const touch = event.touches[0];
if (!touch) return;
const blocked =
event.target instanceof HTMLElement &&
event.target.closest('[data-gestures="ignore"]') !== null;
gestureRef.current = {
startX: touch.clientX,
startY: touch.clientY,
lastX: touch.clientX,
lastTime: event.timeStamp,
velocityX: 0,
mode: blocked ? 'blocked' : 'pending',
lockOffset: 0,
};
}}
onTouchMove={(event) => {
const gesture = gestureRef.current;
const touch = event.touches[0];
if (!gesture || !touch || gesture.mode === 'blocked' || gesture.mode === 'vertical') {
return;
}

const distanceX = touch.clientX - gesture.startX;
const distanceY = touch.clientY - gesture.startY;
const elapsed = event.timeStamp - gesture.lastTime;
if (elapsed > 0) {
gesture.velocityX = (touch.clientX - gesture.lastX) / elapsed;
gesture.lastX = touch.clientX;
gesture.lastTime = event.timeStamp;
}

if (gesture.mode === 'pending') {
if (Math.max(Math.abs(distanceX), Math.abs(distanceY)) < LOCK_THRESHOLD_PX) return;
if (Math.abs(distanceY) >= Math.abs(distanceX)) {
gesture.mode = 'vertical';
return;
}
gesture.mode = 'horizontal';
// Take over any settling spring; offset is seeded from the live position.
x.stop();
gesture.lockOffset = x.get();
}

x.set(clampOffset(gesture.lockOffset + distanceX, getViewportWidth()));
}}
onTouchEnd={() => finish(true)}
onTouchCancel={() => finish(false)}
style={{
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
width: '100%',
touchAction: 'pan-y',
overscrollBehaviorX: 'none',
}}
>
<motion.div
Expand Down
Loading
Loading