From 219256be558c705cf61017f331b977f85bc11538 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 19 Aug 2026 11:27:44 -0400 Subject: [PATCH] Fix guided tour tooltip going off-screen on mobile Body scroll is locked for the tour's duration, so scrollIntoView on the step-2 target had nothing to scroll and its position was measured way off-screen. Scroll the frozen body's offset directly instead, and clamp tooltip vertical position to the viewport as a safety net. Co-Authored-By: Claude Opus 5 --- estore/src/components/GuidedTour.tsx | 19 ++++++++++++------- estore/src/lib/useLockBodyScroll.ts | 6 ++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/estore/src/components/GuidedTour.tsx b/estore/src/components/GuidedTour.tsx index d7a91aa..549d5fe 100644 --- a/estore/src/components/GuidedTour.tsx +++ b/estore/src/components/GuidedTour.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect, useCallback } from 'react' -import { useLockBodyScroll } from '@/lib/useLockBodyScroll' +import { useLockBodyScroll, scrollLockedBodyTo } from '@/lib/useLockBodyScroll' interface TourStep { target: string | null // CSS selector, or null = centered modal @@ -54,8 +54,9 @@ const STEPS: TourStep[] = [ }, ] -const PAD = 10 // px padding around spotlight -const TIP_WIDTH = 300 // tooltip width in px +const PAD = 10 // px padding around spotlight +const TIP_WIDTH = 300 // tooltip width in px +const TIP_MAX_HEIGHT = 260 // conservative estimate, used to keep the tooltip on-screen interface Props { onDone: () => void @@ -103,7 +104,11 @@ export default function GuidedTour({ onDone, onStart }: Props) { const elRect = el.getBoundingClientRect() scrollParent.scrollTop = elRect.top - parentRect.top + scrollParent.scrollTop - 20 } else { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + // Page scroll is locked (useLockBodyScroll), so scrollIntoView has nothing + // to scroll — shift the frozen body's offset instead so the target actually moves into view. + const lockedY = Math.abs(parseFloat(document.body.style.top || '0')) || 0 + const elRect = el.getBoundingClientRect() + scrollLockedBodyTo(lockedY + elRect.top - 100) } // Measure after layout settles (double-rAF ensures paint is done) requestAnimationFrame(() => { @@ -179,7 +184,7 @@ export default function GuidedTour({ onDone, onStart }: Props) { ) tooltipStyle = { position: 'fixed', - top: spot.top + spot.height + 12, + top: Math.max(8, Math.min(spot.top + spot.height + 12, vh - TIP_MAX_HEIGHT - 8)), left, width: Math.min(TIP_WIDTH, vw - 16), zIndex: 10002, @@ -191,7 +196,7 @@ export default function GuidedTour({ onDone, onStart }: Props) { ) tooltipStyle = { position: 'fixed', - bottom: vh - spot.top + 12, + bottom: Math.max(8, Math.min(vh - spot.top + 12, vh - TIP_MAX_HEIGHT - 8)), left, width: Math.min(TIP_WIDTH, vw - 16), zIndex: 10002, @@ -200,7 +205,7 @@ export default function GuidedTour({ onDone, onStart }: Props) { // right tooltipStyle = { position: 'fixed', - top: Math.max(8, spot.top + spot.height / 2 - 80), + top: Math.max(8, Math.min(spot.top + spot.height / 2 - 80, vh - TIP_MAX_HEIGHT - 8)), left: Math.min(spot.left + spot.width + 12, vw - TIP_WIDTH - 8), width: Math.min(TIP_WIDTH, vw - 16), zIndex: 10002, diff --git a/estore/src/lib/useLockBodyScroll.ts b/estore/src/lib/useLockBodyScroll.ts index ab51294..a4e7f80 100644 --- a/estore/src/lib/useLockBodyScroll.ts +++ b/estore/src/lib/useLockBodyScroll.ts @@ -25,3 +25,9 @@ export function useLockBodyScroll() { } }, []) } + +/** While the page is frozen by useLockBodyScroll, there's no real scroll to move — + * "scrolling" means shifting the fixed body's negative `top` offset instead. */ +export function scrollLockedBodyTo(y: number) { + document.body.style.top = `-${Math.max(0, y)}px` +}