Skip to content

Commit d312910

Browse files
authored
Fix mobile members swipe dismissal (#1502)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> Fixes #1325 #### Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents 29c075f + 341ff1a commit d312910

8 files changed

Lines changed: 322 additions & 56 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { animate } from 'framer-motion';
4+
import { SwipeableOverlayWrapper } from './SwipeableOverlayWrapper';
5+
6+
vi.mock('$utils/platform', () => ({
7+
isMobileOrTablet: () => true,
8+
}));
9+
10+
vi.mock('framer-motion', () => {
11+
const animateMock = vi.fn<(...args: unknown[]) => Promise<void>>(() => Promise.resolve());
12+
return {
13+
animate: animateMock,
14+
motion: { div: 'div' },
15+
useMotionValue: (initial: number) => {
16+
let value = initial;
17+
return {
18+
get: () => value,
19+
set: (next: number) => {
20+
value = next;
21+
},
22+
stop: vi.fn<() => void>(),
23+
};
24+
},
25+
};
26+
});
27+
28+
const touchList = (target: HTMLElement, clientX: number, clientY: number) => {
29+
const point = { identifier: 0, target, clientX, clientY, pageX: clientX, pageY: clientY };
30+
return { touches: [point], targetTouches: [point], changedTouches: [point] };
31+
};
32+
33+
function renderWrapper(direction: 'left' | 'right' | 'both', onClose: () => void) {
34+
render(
35+
<SwipeableOverlayWrapper direction={direction} onClose={onClose}>
36+
<div data-testid="content" />
37+
</SwipeableOverlayWrapper>
38+
);
39+
return screen.getByTestId('content');
40+
}
41+
42+
describe('SwipeableOverlayWrapper', () => {
43+
beforeEach(() => {
44+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 320 });
45+
vi.mocked(animate).mockClear();
46+
});
47+
48+
afterEach(() => {
49+
vi.restoreAllMocks();
50+
});
51+
52+
it('closes after a single horizontal move past the distance threshold', async () => {
53+
const onClose = vi.fn<() => void>();
54+
const content = renderWrapper('both', onClose);
55+
56+
fireEvent.touchStart(content, touchList(content, 260, 100));
57+
fireEvent.touchMove(content, touchList(content, 100, 100));
58+
fireEvent.touchEnd(content, {
59+
...touchList(content, 100, 100),
60+
touches: [],
61+
targetTouches: [],
62+
});
63+
64+
await Promise.resolve();
65+
66+
expect(animate).toHaveBeenCalledWith(expect.anything(), -320, {
67+
duration: 0.22,
68+
ease: 'easeOut',
69+
});
70+
expect(onClose).toHaveBeenCalledOnce();
71+
});
72+
73+
it('leaves a vertical member-list scroll alone', () => {
74+
const onClose = vi.fn<() => void>();
75+
const content = renderWrapper('both', onClose);
76+
77+
fireEvent.touchStart(content, touchList(content, 160, 100));
78+
fireEvent.touchMove(content, touchList(content, 165, 260));
79+
fireEvent.touchEnd(content, {
80+
...touchList(content, 165, 260),
81+
touches: [],
82+
targetTouches: [],
83+
});
84+
85+
expect(onClose).not.toHaveBeenCalled();
86+
expect(animate).not.toHaveBeenCalled();
87+
});
88+
89+
it('does not close a cancelled horizontal gesture', () => {
90+
const onClose = vi.fn<() => void>();
91+
const content = renderWrapper('both', onClose);
92+
93+
fireEvent.touchStart(content, touchList(content, 260, 100));
94+
fireEvent.touchMove(content, touchList(content, 100, 100));
95+
fireEvent.touchCancel(content, { touches: [], targetTouches: [] });
96+
97+
expect(onClose).not.toHaveBeenCalled();
98+
expect(animate).toHaveBeenCalledWith(expect.anything(), 0, {
99+
duration: 0.22,
100+
ease: 'easeOut',
101+
});
102+
});
103+
104+
it('does not close on a disallowed swipe direction', () => {
105+
const onClose = vi.fn<() => void>();
106+
const content = renderWrapper('right', onClose);
107+
108+
fireEvent.touchStart(content, touchList(content, 260, 100));
109+
fireEvent.touchMove(content, touchList(content, 100, 100));
110+
fireEvent.touchEnd(content, {
111+
...touchList(content, 100, 100),
112+
touches: [],
113+
targetTouches: [],
114+
});
115+
116+
expect(onClose).not.toHaveBeenCalled();
117+
});
118+
});

src/app/components/SwipeableOverlayWrapper.tsx

Lines changed: 113 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
11
import type { ReactNode } from 'react';
2+
import { useRef } from 'react';
23
import { animate, motion, useMotionValue } from 'framer-motion';
3-
import { useDrag } from '@use-gesture/react';
44
import { isMobileOrTablet } from '$utils/platform';
55

6+
const SETTLE_MS = 220;
7+
const LOCK_THRESHOLD_PX = 8;
8+
const COMMIT_FRACTION = 0.22;
9+
const VELOCITY_THRESHOLD = 0.45; // px per ms
10+
11+
const getViewportWidth = () => document.documentElement.clientWidth || window.innerWidth;
12+
13+
type GestureMode = 'pending' | 'vertical' | 'horizontal' | 'blocked';
14+
15+
type ActiveGesture = {
16+
startX: number;
17+
startY: number;
18+
lastX: number;
19+
lastTime: number;
20+
velocityX: number;
21+
mode: GestureMode;
22+
lockOffset: number;
23+
};
24+
625
interface SwipeableOverlayWrapperProps {
726
children: ReactNode;
827
onClose: () => void;
9-
direction: 'left' | 'right';
28+
direction: 'left' | 'right' | 'both';
1029
}
1130

1231
export function SwipeableOverlayWrapper({
@@ -15,55 +34,51 @@ export function SwipeableOverlayWrapper({
1534
direction,
1635
}: SwipeableOverlayWrapperProps) {
1736
const x = useMotionValue(0);
37+
const gestureRef = useRef<ActiveGesture>();
38+
const closeCommittedRef = useRef(false);
1839

19-
const bind = useDrag(
20-
({ first, active, offset: [ox], velocity: [vx], direction: [dx], event, cancel }) => {
21-
if (first && event && 'target' in event && event.target instanceof HTMLElement) {
22-
if (event.target.closest('[data-gestures="ignore"]')) {
23-
cancel();
24-
return;
25-
}
26-
}
40+
const acceptsLeft = direction !== 'right';
41+
const acceptsRight = direction !== 'left';
2742

28-
if (!isMobileOrTablet()) return;
43+
const clampOffset = (val: number, viewportWidth: number) => {
44+
let v = val;
45+
if (!acceptsLeft) v = Math.max(0, v);
46+
if (!acceptsRight) v = Math.min(0, v);
47+
return Math.max(-viewportWidth, Math.min(viewportWidth, v));
48+
};
2949

30-
event.stopPropagation();
50+
const finish = (commitEligible: boolean) => {
51+
const gesture = gestureRef.current;
52+
gestureRef.current = undefined;
53+
if (!gesture || gesture.mode !== 'horizontal') return;
3154

32-
let val = ox;
55+
if (commitEligible) {
56+
const viewportWidth = getViewportWidth();
57+
const val = x.get();
58+
const swipedLeft =
59+
acceptsLeft &&
60+
val < 0 &&
61+
(val <= -viewportWidth * COMMIT_FRACTION || gesture.velocityX <= -VELOCITY_THRESHOLD);
62+
const swipedRight =
63+
acceptsRight &&
64+
val > 0 &&
65+
(val >= viewportWidth * COMMIT_FRACTION || gesture.velocityX >= VELOCITY_THRESHOLD);
3366

34-
if (direction === 'left' && val > 0) val = 0;
35-
if (direction === 'right' && val < 0) val = 0;
36-
37-
if (active) {
38-
// Take over any settling spring; offset is seeded from the live position.
39-
if (first) x.stop();
40-
x.set(val);
41-
} else {
42-
const swipeThreshold = 100;
43-
const velocityThreshold = 0.5;
44-
45-
const swipedLeft =
46-
direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0));
47-
const swipedRight =
48-
direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0));
49-
50-
if (swipedLeft || swipedRight) {
67+
if (swipedLeft || swipedRight) {
68+
closeCommittedRef.current = true;
69+
const target = swipedLeft ? -viewportWidth : viewportWidth;
70+
void animate(x, target, { duration: SETTLE_MS / 1000, ease: 'easeOut' }).then(() => {
71+
if (!closeCommittedRef.current) return;
5172
onClose();
52-
}
53-
54-
animate(x, 0, { type: 'spring', stiffness: 400, damping: 40 });
73+
closeCommittedRef.current = false;
74+
animate(x, 0, { duration: SETTLE_MS / 1000, ease: 'easeOut' });
75+
});
76+
return;
5577
}
56-
},
57-
{
58-
axis: 'x',
59-
bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 },
60-
rubberband: true,
61-
filterTaps: true,
62-
pointer: { capture: true },
63-
eventOptions: { passive: true },
64-
from: () => [x.get(), 0],
6578
}
66-
);
79+
80+
animate(x, 0, { duration: SETTLE_MS / 1000, ease: 'easeOut' });
81+
};
6782

6883
if (!isMobileOrTablet()) {
6984
return (
@@ -83,14 +98,68 @@ export function SwipeableOverlayWrapper({
8398

8499
return (
85100
<div
86-
{...bind()}
101+
onTouchStart={(event) => {
102+
if (closeCommittedRef.current) return;
103+
if (event.touches.length !== 1) {
104+
finish(false);
105+
return;
106+
}
107+
const touch = event.touches[0];
108+
if (!touch) return;
109+
const blocked =
110+
event.target instanceof HTMLElement &&
111+
event.target.closest('[data-gestures="ignore"]') !== null;
112+
gestureRef.current = {
113+
startX: touch.clientX,
114+
startY: touch.clientY,
115+
lastX: touch.clientX,
116+
lastTime: event.timeStamp,
117+
velocityX: 0,
118+
mode: blocked ? 'blocked' : 'pending',
119+
lockOffset: 0,
120+
};
121+
}}
122+
onTouchMove={(event) => {
123+
const gesture = gestureRef.current;
124+
const touch = event.touches[0];
125+
if (!gesture || !touch || gesture.mode === 'blocked' || gesture.mode === 'vertical') {
126+
return;
127+
}
128+
129+
const distanceX = touch.clientX - gesture.startX;
130+
const distanceY = touch.clientY - gesture.startY;
131+
const elapsed = event.timeStamp - gesture.lastTime;
132+
if (elapsed > 0) {
133+
gesture.velocityX = (touch.clientX - gesture.lastX) / elapsed;
134+
gesture.lastX = touch.clientX;
135+
gesture.lastTime = event.timeStamp;
136+
}
137+
138+
if (gesture.mode === 'pending') {
139+
if (Math.max(Math.abs(distanceX), Math.abs(distanceY)) < LOCK_THRESHOLD_PX) return;
140+
if (Math.abs(distanceY) >= Math.abs(distanceX)) {
141+
gesture.mode = 'vertical';
142+
return;
143+
}
144+
gesture.mode = 'horizontal';
145+
// Take over any settling spring; offset is seeded from the live position.
146+
x.stop();
147+
gesture.lockOffset = x.get();
148+
}
149+
150+
x.set(clampOffset(gesture.lockOffset + distanceX, getViewportWidth()));
151+
}}
152+
onTouchEnd={() => finish(true)}
153+
onTouchCancel={() => finish(false)}
87154
style={{
88155
overflow: 'hidden',
89156
display: 'flex',
90157
flexDirection: 'column',
91158
flexGrow: 1,
92159
height: '100%',
93160
width: '100%',
161+
touchAction: 'pan-y',
162+
overscrollBehaviorX: 'none',
94163
}}
95164
>
96165
<motion.div

0 commit comments

Comments
 (0)