diff --git a/src/components/ScrollTrapDemo/index.tsx b/src/components/ScrollTrapDemo/index.tsx index 79071e81..1295fad0 100644 --- a/src/components/ScrollTrapDemo/index.tsx +++ b/src/components/ScrollTrapDemo/index.tsx @@ -4,26 +4,57 @@ import styles from "./styles.module.css"; type Example = "internalScroll" | "gestureLock" | "fixed"; +const desktopInstructions: Record = { + fixed: + "Hover over the inline app and scroll. The page keeps moving normally.", + gestureLock: + "Hover over the fixed app surface and scroll. The page stops even though there is no scrollbar.", + internalScroll: + "Hover over the nested list and scroll. The app captures the scroll inside the post.", +}; + +const mobileExamples: Record< + Example, + { + code: string; + details: string; + outcome: string; + title: string; + } +> = { + fixed: { + code: "touch-action: pan-y;", + details: "Swipe the green surface. The page should keep moving.", + outcome: "Acceptable: vertical swipes stay with the feed.", + title: "Feed stays scrollable", + }, + gestureLock: { + code: "touch-action: none;", + details: "Swipe the orange surface. It captures the gesture.", + outcome: "Rejected: no scrollbar, but still trapped.", + title: "No scrollbar can still trap swipes", + }, + internalScroll: { + code: "overflow-y: auto;", + details: "Swipe the mini list. It scrolls inside the post.", + outcome: "Rejected: nested scrolling competes with the feed.", + title: "Internal scrolling competes with the feed", + }, +}; + const examples: Array<{ - description: string; id: Example; label: string; }> = [ { - description: - "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.", id: "internalScroll", label: "Internal scroll trap", }, { - description: - "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.", id: "gestureLock", label: "No scrollbar trap", }, { - description: - "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.", id: "fixed", label: "Feed stays scrollable", }, @@ -31,17 +62,9 @@ const examples: Array<{ export default function ScrollTrapDemo(): React.ReactElement { const [activeExample, setActiveExample] = useState("internalScroll"); + const isTouchDemo = useTouchDemo(); const internalScrollRef = useRef(null); const gestureTrapRef = useRef(null); - const selectedExample = examples.find( - (example) => example.id === activeExample, - ); - - useEffect(() => { - if (activeExample === "internalScroll") { - internalScrollRef.current?.focus({ preventScroll: true }); - } - }, [activeExample]); useEffect(() => { const addWheelTrap = (element: HTMLDivElement | null) => { @@ -57,12 +80,14 @@ export default function ScrollTrapDemo(): React.ReactElement { return () => element.removeEventListener("wheel", onWheel); }; - const removeGestureTrap = addWheelTrap(gestureTrapRef.current); + const removeGestureTrap = isTouchDemo + ? undefined + : addWheelTrap(gestureTrapRef.current); return () => { removeGestureTrap?.(); }; - }, [activeExample]); + }, [activeExample, isTouchDemo]); return (
@@ -91,69 +116,126 @@ export default function ScrollTrapDemo(): React.ReactElement { role="tabpanel" aria-labelledby={`scroll-trap-tab-${activeExample}`} > -

{selectedExample?.description}

- -
-
- - - {activeExample === "internalScroll" ? ( - - ) : null} - - {activeExample === "gestureLock" ? ( - - ) : null} - - {activeExample === "fixed" ? : null} - - + {!isTouchDemo ? ( +

+ {desktopInstructions[activeExample]} +

+ ) : null} + + {isTouchDemo ? ( + + ) : ( +
+
+ + {activeExample === "internalScroll" ? ( + + ) : null} + + {activeExample === "gestureLock" ? ( + + ) : null} + + {activeExample === "fixed" ? : null} + +
-
+ )}
); } -function PlainMockPost({ - comments, - label, - title, - votes, -}: { - comments: string; - label: string; - title: string; - votes: string; -}) { +function useTouchDemo(): boolean { + const [isTouchDemo, setIsTouchDemo] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia("(hover: none), (pointer: coarse)"); + + const syncInputMode = () => setIsTouchDemo(mediaQuery.matches); + syncInputMode(); + + mediaQuery.addEventListener("change", syncInputMode); + return () => mediaQuery.removeEventListener("change", syncInputMode); + }, []); + + return isTouchDemo; +} + +function MobileExample({ activeExample }: { activeExample: Example }) { + const example = mobileExamples[activeExample]; + const isAccepted = activeExample === "fixed"; + const gestureTrapRef = useRef(null); + + useEffect(() => { + if (activeExample !== "gestureLock") { + return undefined; + } + + const element = gestureTrapRef.current; + if (!element) { + return undefined; + } + + const stopScroll = (event: TouchEvent | WheelEvent) => { + event.preventDefault(); + }; + + element.addEventListener("touchmove", stopScroll, { passive: false }); + element.addEventListener("wheel", stopScroll, { passive: false }); + + return () => { + element.removeEventListener("touchmove", stopScroll); + element.removeEventListener("wheel", stopScroll); + }; + }, [activeExample]); + return ( -
- -

{title}

- Community App -
-
- {label} - Preview +
+ +

Daily Game #116

+ Daily Game + +
+ +
+

{example.title}

-
- - - - +

{example.details}

+ {example.outcome} +
+ {activeExample === "internalScroll" ? ( +
+ Swipe this nested list + + + + +
+ ) : null} + + {activeExample === "gestureLock" ? ( +
+ Swipe this fixed surface +
+ ) : null} + + {activeExample === "fixed" ? ( +
+ Swipe here; the page should keep moving +
+ ) : null}
+ {example.code}
- + +
); } @@ -280,7 +362,9 @@ const GestureLockApp = React.forwardRef( The surface is fixed, but it locks gestures across the whole inline app. The feed cannot use the wheel or touch input.

-
+
Fixed canvas or game area
diff --git a/src/components/ScrollTrapDemo/styles.module.css b/src/components/ScrollTrapDemo/styles.module.css index 13c8f8c9..8bc7d7ad 100644 --- a/src/components/ScrollTrapDemo/styles.module.css +++ b/src/components/ScrollTrapDemo/styles.module.css @@ -84,19 +84,14 @@ background: var(--demo-surface-bg); border: 1px solid var(--demo-border); border-radius: 8px; - height: 760px; margin: 0 auto; max-width: 740px; - overflow-x: hidden; - overflow-y: auto; padding: 0.75rem; width: 100%; } .feedCanvas { - transform: scale(0.76); - transform-origin: top left; - width: 131.6%; + width: 100%; } .post { @@ -171,68 +166,6 @@ padding: 1rem; } -.plainPost { - opacity: 0.92; -} - -.plainPost .postTitle { - font-size: 1.15rem; -} - -.plainPost .flair { - margin-bottom: 0.65rem; -} - -.plainPost .postFooter { - padding-top: 0.65rem; -} - -.plainAppSurface { - background: #f6f7f8; - border: 1px solid #d7dde1; - border-radius: 16px; - color: #1a1a1b; - display: flex; - flex-direction: column; - min-height: 657px; - overflow: hidden; - padding: 0.8rem; -} - -.plainAppHeader { - align-items: center; - border-bottom: 1px solid #d7dde1; - display: flex; - justify-content: space-between; - margin: -0.8rem -0.8rem 0.8rem; - padding: 0.8rem; -} - -.plainAppHeader strong { - font-size: 1rem; -} - -.plainAppHeader span { - color: #667178; - font-size: 0.85rem; - font-weight: 700; - text-transform: uppercase; -} - -.plainAppGrid { - display: grid; - flex: 1; - gap: 0.6rem; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.plainAppGrid span { - background: #ffffff; - border: 1px solid #d7dde1; - border-radius: 8px; - min-height: 10rem; -} - .rejectedSurface { outline: 3px solid #ff4500; } @@ -372,6 +305,10 @@ display: flex; justify-content: center; min-height: 19rem; + touch-action: pan-y; +} + +.gestureTrapBoard { touch-action: none; } @@ -471,26 +408,209 @@ width: 1rem; } +.mobilePost { + display: none; + margin: 0 auto; + max-width: 740px; +} + +.mobileAppSurface { + background: #ffffff; + border-radius: 16px; + color: #111111; + overflow: hidden; + padding: 0.85rem; +} + +.mobileRejectedSurface { + outline: 3px solid #ff4500; +} + +.mobileAcceptedSurface { + outline: 3px solid #168a5b; +} + +.mobileExampleHeader { + margin: 1rem 0 0.7rem; +} + +.mobileExampleHeader h4 { + font-size: 1.1rem; + line-height: 1.25; + margin: 0; +} + +.mobileAppSurface p { + color: #343a3d; + line-height: 1.5; + margin: 0 0 0.85rem; +} + +.mobileAppSurface strong { + display: block; + line-height: 1.35; + margin-bottom: 0.85rem; +} + +.mobileSwipeDemo { + margin: 0 0 0.85rem; +} + +.mobileNestedScroller, +.mobileGestureSurface, +.mobilePassThroughSurface { + border: 2px solid #111111; + border-radius: 8px; +} + +.mobileNestedScroller { + display: flex; + flex-direction: column; + gap: 0.65rem; + max-height: 9rem; + overscroll-behavior: contain; + overflow-y: auto; + padding: 0.75rem; +} + +.mobileNestedScroller span { + color: #343a3d; + font-weight: 800; +} + +.mobileNestedScroller button { + background: #ff4500; + border: 2px solid #111111; + border-radius: 8px; + color: #111111; + font: inherit; + font-weight: 800; + min-height: 3.25rem; + padding: 0.7rem; +} + +.mobileGestureSurface, +.mobilePassThroughSurface { + align-items: center; + display: flex; + font-weight: 800; + justify-content: center; + min-height: 7rem; + padding: 1rem; + text-align: center; +} + +.mobileGestureSurface { + background: #ff4500; + color: #111111; + touch-action: none; +} + +.mobilePassThroughSurface { + background: #caf4d0; + color: #111111; + touch-action: pan-y; +} + +.mobileAppSurface code { + display: block; + font-size: 0.85rem; + overflow-wrap: anywhere; + padding: 0.55rem 0.65rem; +} + @media (max-width: 640px) { - .feedViewport { - height: 38rem; - max-height: 76vh; + .tabs { + gap: 0.5rem; + padding: 0.75rem; } - .feedCanvas { - transform: scale(0.8); - width: 125%; + .tab, + .activeTab { + font-size: 0.88rem; + min-height: 2.35rem; + padding: 0.35rem 0.7rem; } - .appSurface { - min-height: 36rem; + .panel { + padding: 0.85rem; + } + + .mobilePost .postMeta { + align-items: center; + column-gap: 0.55rem; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + row-gap: 0.2rem; + } + + .mobilePost .avatar { + grid-column: 1; + grid-row: 1 / span 2; + } + + .mobilePost .postMeta strong { + grid-column: 2; + grid-row: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mobilePost .appBadge { + grid-column: 2; + grid-row: 2; + justify-self: start; + } + + .mobilePost .postMeta > span:nth-of-type(3) { + grid-column: 2; + grid-row: 2; + margin-left: 3.2rem; } - .plainAppSurface { - min-height: 36rem; + .mobilePost .moreButton { + grid-column: 3; + grid-row: 1 / span 2; + margin-left: 0; + } + + .appSurface { + min-height: auto; + padding: 0.75rem; } .centerPanel { margin-top: 0; + padding: 0.85rem; + } + + .centerPanel h4 { + font-size: 1.1rem; + } + + .centerPanel p { + font-size: 0.9rem; + } + + .appBody { + padding: 1rem 0 0; + } + + .previewBoard { + min-height: 8.5rem; + } + + .previewCard { + min-height: 4.5rem; + } + + .gestureTrapBoard { + touch-action: pan-y; + } + + .mobilePost { + display: block; } }