Skip to content

Commit 4ddbbdd

Browse files
fix for mobile scroll trap demo (#207)
## 💸 TL;DR <!-- What's the three sentence summary of purpose of the PR --> Behavior of scroll trap demo was off on mobile. This attempts to fix it.
1 parent 3c6e616 commit 4ddbbdd

2 files changed

Lines changed: 354 additions & 150 deletions

File tree

‎src/components/ScrollTrapDemo/index.tsx‎

Lines changed: 156 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,67 @@ import styles from "./styles.module.css";
44

55
type Example = "internalScroll" | "gestureLock" | "fixed";
66

7+
const desktopInstructions: Record<Example, string> = {
8+
fixed:
9+
"Hover over the inline app and scroll. The page keeps moving normally.",
10+
gestureLock:
11+
"Hover over the fixed app surface and scroll. The page stops even though there is no scrollbar.",
12+
internalScroll:
13+
"Hover over the nested list and scroll. The app captures the scroll inside the post.",
14+
};
15+
16+
const mobileExamples: Record<
17+
Example,
18+
{
19+
code: string;
20+
details: string;
21+
outcome: string;
22+
title: string;
23+
}
24+
> = {
25+
fixed: {
26+
code: "touch-action: pan-y;",
27+
details: "Swipe the green surface. The page should keep moving.",
28+
outcome: "Acceptable: vertical swipes stay with the feed.",
29+
title: "Feed stays scrollable",
30+
},
31+
gestureLock: {
32+
code: "touch-action: none;",
33+
details: "Swipe the orange surface. It captures the gesture.",
34+
outcome: "Rejected: no scrollbar, but still trapped.",
35+
title: "No scrollbar can still trap swipes",
36+
},
37+
internalScroll: {
38+
code: "overflow-y: auto;",
39+
details: "Swipe the mini list. It scrolls inside the post.",
40+
outcome: "Rejected: nested scrolling competes with the feed.",
41+
title: "Internal scrolling competes with the feed",
42+
},
43+
};
44+
745
const examples: Array<{
8-
description: string;
946
id: Example;
1047
label: string;
1148
}> = [
1249
{
13-
description:
14-
"Start scrolling until you hit the trap, then hover over the app and scroll. The app captures the scroll, and the Reddit feed stops moving.",
1550
id: "internalScroll",
1651
label: "Internal scroll trap",
1752
},
1853
{
19-
description:
20-
"Start scrolling until you hit the trap, then hover over the app and scroll. The app captures the scroll even though no scrollbar is visible, and the Reddit feed stops moving.",
2154
id: "gestureLock",
2255
label: "No scrollbar trap",
2356
},
2457
{
25-
description:
26-
"Start scrolling until you hit the app, then hover over it and scroll. The app does not capture the scroll, and the Reddit feed continues moving normally.",
2758
id: "fixed",
2859
label: "Feed stays scrollable",
2960
},
3061
];
3162

3263
export default function ScrollTrapDemo(): React.ReactElement {
3364
const [activeExample, setActiveExample] = useState<Example>("internalScroll");
65+
const isTouchDemo = useTouchDemo();
3466
const internalScrollRef = useRef<HTMLDivElement>(null);
3567
const gestureTrapRef = useRef<HTMLDivElement>(null);
36-
const selectedExample = examples.find(
37-
(example) => example.id === activeExample,
38-
);
39-
40-
useEffect(() => {
41-
if (activeExample === "internalScroll") {
42-
internalScrollRef.current?.focus({ preventScroll: true });
43-
}
44-
}, [activeExample]);
4568

4669
useEffect(() => {
4770
const addWheelTrap = (element: HTMLDivElement | null) => {
@@ -57,12 +80,14 @@ export default function ScrollTrapDemo(): React.ReactElement {
5780
return () => element.removeEventListener("wheel", onWheel);
5881
};
5982

60-
const removeGestureTrap = addWheelTrap(gestureTrapRef.current);
83+
const removeGestureTrap = isTouchDemo
84+
? undefined
85+
: addWheelTrap(gestureTrapRef.current);
6186

6287
return () => {
6388
removeGestureTrap?.();
6489
};
65-
}, [activeExample]);
90+
}, [activeExample, isTouchDemo]);
6691

6792
return (
6893
<section className={styles.wrapper} aria-label="Scroll trap examples">
@@ -91,69 +116,126 @@ export default function ScrollTrapDemo(): React.ReactElement {
91116
role="tabpanel"
92117
aria-labelledby={`scroll-trap-tab-${activeExample}`}
93118
>
94-
<p className={styles.instructions}>{selectedExample?.description}</p>
95-
96-
<div className={styles.feedViewport}>
97-
<div className={styles.feedCanvas}>
98-
<PlainMockPost
99-
label="Mock app"
100-
title="Community Check-in"
101-
votes="18"
102-
comments="4"
103-
/>
104-
<MockPost>
105-
{activeExample === "internalScroll" ? (
106-
<InternalScrollApp scrollerRef={internalScrollRef} />
107-
) : null}
108-
109-
{activeExample === "gestureLock" ? (
110-
<GestureLockApp ref={gestureTrapRef} />
111-
) : null}
112-
113-
{activeExample === "fixed" ? <FixedApp /> : null}
114-
</MockPost>
115-
<PlainMockPost
116-
label="Mock app"
117-
title="Weekly Scoreboard"
118-
votes="31"
119-
comments="9"
120-
/>
119+
{!isTouchDemo ? (
120+
<p className={styles.instructions}>
121+
{desktopInstructions[activeExample]}
122+
</p>
123+
) : null}
124+
125+
{isTouchDemo ? (
126+
<MobileExample activeExample={activeExample} />
127+
) : (
128+
<div className={styles.feedViewport}>
129+
<div className={styles.feedCanvas}>
130+
<MockPost>
131+
{activeExample === "internalScroll" ? (
132+
<InternalScrollApp scrollerRef={internalScrollRef} />
133+
) : null}
134+
135+
{activeExample === "gestureLock" ? (
136+
<GestureLockApp ref={gestureTrapRef} />
137+
) : null}
138+
139+
{activeExample === "fixed" ? <FixedApp /> : null}
140+
</MockPost>
141+
</div>
121142
</div>
122-
</div>
143+
)}
123144
</div>
124145
</section>
125146
);
126147
}
127148

128-
function PlainMockPost({
129-
comments,
130-
label,
131-
title,
132-
votes,
133-
}: {
134-
comments: string;
135-
label: string;
136-
title: string;
137-
votes: string;
138-
}) {
149+
function useTouchDemo(): boolean {
150+
const [isTouchDemo, setIsTouchDemo] = useState(false);
151+
152+
useEffect(() => {
153+
const mediaQuery = window.matchMedia("(hover: none), (pointer: coarse)");
154+
155+
const syncInputMode = () => setIsTouchDemo(mediaQuery.matches);
156+
syncInputMode();
157+
158+
mediaQuery.addEventListener("change", syncInputMode);
159+
return () => mediaQuery.removeEventListener("change", syncInputMode);
160+
}, []);
161+
162+
return isTouchDemo;
163+
}
164+
165+
function MobileExample({ activeExample }: { activeExample: Example }) {
166+
const example = mobileExamples[activeExample];
167+
const isAccepted = activeExample === "fixed";
168+
const gestureTrapRef = useRef<HTMLDivElement>(null);
169+
170+
useEffect(() => {
171+
if (activeExample !== "gestureLock") {
172+
return undefined;
173+
}
174+
175+
const element = gestureTrapRef.current;
176+
if (!element) {
177+
return undefined;
178+
}
179+
180+
const stopScroll = (event: TouchEvent | WheelEvent) => {
181+
event.preventDefault();
182+
};
183+
184+
element.addEventListener("touchmove", stopScroll, { passive: false });
185+
element.addEventListener("wheel", stopScroll, { passive: false });
186+
187+
return () => {
188+
element.removeEventListener("touchmove", stopScroll);
189+
element.removeEventListener("wheel", stopScroll);
190+
};
191+
}, [activeExample]);
192+
139193
return (
140-
<article className={`${styles.post} ${styles.plainPost}`}>
141-
<PostMeta name="sample-app" time="8 hr. ago" avatar="W" />
142-
<h3 className={styles.postTitle}>{title}</h3>
143-
<span className={styles.flair}>Community App</span>
144-
<div className={styles.plainAppSurface}>
145-
<div className={styles.plainAppHeader}>
146-
<strong>{label}</strong>
147-
<span>Preview</span>
194+
<article className={`${styles.post} ${styles.mobilePost}`}>
195+
<PostMeta name="sample-scroll-trap-app" time="11 hr. ago" avatar="S" />
196+
<h3 className={styles.postTitle}>Daily Game #116</h3>
197+
<span className={styles.flair}>Daily Game</span>
198+
199+
<div
200+
className={`${styles.mobileAppSurface} ${
201+
isAccepted
202+
? styles.mobileAcceptedSurface
203+
: styles.mobileRejectedSurface
204+
}`}
205+
>
206+
<AppToolbar status={isAccepted ? "Acceptable" : "Rejected"} />
207+
<div className={styles.mobileExampleHeader}>
208+
<h4>{example.title}</h4>
148209
</div>
149-
<div className={styles.plainAppGrid}>
150-
<span />
151-
<span />
152-
<span />
153-
<span />
210+
<p>{example.details}</p>
211+
<strong>{example.outcome}</strong>
212+
<div className={styles.mobileSwipeDemo}>
213+
{activeExample === "internalScroll" ? (
214+
<div className={styles.mobileNestedScroller}>
215+
<span>Swipe this nested list</span>
216+
<button type="button">Share score</button>
217+
<button type="button">View leaderboard</button>
218+
<button type="button">Claim streak bonus</button>
219+
<button type="button">Play again tomorrow</button>
220+
</div>
221+
) : null}
222+
223+
{activeExample === "gestureLock" ? (
224+
<div className={styles.mobileGestureSurface} ref={gestureTrapRef}>
225+
Swipe this fixed surface
226+
</div>
227+
) : null}
228+
229+
{activeExample === "fixed" ? (
230+
<div className={styles.mobilePassThroughSurface}>
231+
Swipe here; the page should keep moving
232+
</div>
233+
) : null}
154234
</div>
235+
<code>{example.code}</code>
155236
</div>
156-
<PostFooter votes={votes} comments={comments} />
237+
238+
<PostFooter votes="42" comments="18" />
157239
</article>
158240
);
159241
}
@@ -280,7 +362,9 @@ const GestureLockApp = React.forwardRef<HTMLDivElement>(
280362
The surface is fixed, but it locks gestures across the whole
281363
inline app. The feed cannot use the wheel or touch input.
282364
</p>
283-
<div className={styles.previewBoard}>
365+
<div
366+
className={`${styles.previewBoard} ${styles.gestureTrapBoard}`}
367+
>
284368
<div className={styles.previewCard}>
285369
Fixed canvas or game area
286370
</div>

0 commit comments

Comments
 (0)