All files / src/hooks useMenuFeedbackOpen.ts

100% Statements 114/114
94.28% Branches 33/35
100% Functions 12/12
100% Lines 114/114

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 1831x           1x           1x           1x   1x 268x 268x 268x 268x 268x   1x 188x 188x 188x 188x 188x           1x 164x 164x 164x 76x     34x 34x 15x 19x             76x 76x 34x       2x 2x 2x 2x 2x 2x 2x 34x 76x 34x 2x 2x 34x       76x 76x 76x 76x 34x 76x 34x         76x 76x 34x 76x 34x       76x 8x       8x 8x 8x 8x 8x 8x 8x 8x                             8x 8x 5x 5x 8x 5x 5x   5x 4x 4x 3x 3x 3x 3x 3x       3x 2x 2x 3x 3x 4x   2x 5x 8x 8x 8x 8x 8x 8x   8x 8x 8x 8x 8x 8x 8x 8x 8x 8x   26x 26x 26x 26x   26x 26x 26x 26x 26x 26x 164x 164x  
import { useEffect } from 'react';
 
interface UseMenuFeedbackOpenOptions {
  isOpen: boolean;
}
 
const HEADER_ID = 'header-container';
 
// The lunch page scrolls inside inner containers (the main area, the page
// content wrapper, and the horizontal menu carousel) rather than the window,
// so locking <html>/<body> alone doesn't stop them. Any such container is
// tagged with `data-scroll-lock` and frozen while the overlay is open.
const SCROLL_LOCK_SELECTOR = '[data-scroll-lock]';
 
// The only element inside the overlay that should still scroll on touch-drag
// is the comment box. Everything else is blocked so the full-screen overlay
// can't chain its scroll to the list underneath. Taps (buttons, emoji, close)
// are unaffected — only touchmove/scroll is prevented.
const TOUCH_SCROLL_ALLOW_SELECTOR = 'textarea';
 
const applyStyles = (
  el: HTMLElement,
  styles: Partial<CSSStyleDeclaration>,
): void => {
  Object.assign(el.style, styles);
};
 
const applyStylesToAll = (
  elements: HTMLElement[],
  styles: Partial<CSSStyleDeclaration>,
): void => {
  elements.forEach((el) => applyStyles(el, styles));
};
 
/**
 * Manage scroll lock when menu feedback overlay opens
 * Focus is handled via MenuFeedbackOverlay.onAnimationEnd callback for accurate timing
 */
export const useMenuFeedbackOpen = ({
  isOpen,
}: UseMenuFeedbackOpenOptions): void => {
  useEffect(() => {
    if (!isOpen) return;
 
    // Check if matchMedia is available (not in jsdom test environment)
    const isMobile =
      typeof window !== 'undefined' && window.matchMedia
        ? window.matchMedia('(max-width: 767px)').matches
        : false;
 
    // Both lock strategies break `position: sticky` when the page is scrolled:
    // `overflow: hidden` on <html> removes the sticky scroll container, and
    // `position: fixed` on <body> drops sticky descendants in WebKit. Pin the
    // header explicitly (relative to the viewport) so it stays visible, then
    // restore it on cleanup.
    const header = document.getElementById(HEADER_ID);
    const pinHeader = (): void => {
      if (!header) return;
      // Reserve the header's height first so taking it out of the flow doesn't
      // shift the page content up (and jump back down on close, which reads as
      // lag). The fixed header covers this reserved space.
      applyStyles(document.body, { paddingTop: `${header.offsetHeight}px` });
      applyStyles(header, {
        position: 'fixed',
        top: '0',
        left: '0',
        right: '0',
      });
    };
    const unpinHeader = (): void => {
      if (!header) return;
      applyStyles(header, { position: '', top: '', left: '', right: '' });
      applyStyles(document.body, { paddingTop: '' });
    };
 
    // Freeze the page's inner scroll containers so content behind the overlay
    // can't be scrolled.
    const scrollContainers = Array.from(
      document.querySelectorAll<HTMLElement>(SCROLL_LOCK_SELECTOR),
    );
    const lockScrollContainers = (): void =>
      applyStylesToAll(scrollContainers, { overflow: 'hidden' });
    const unlockScrollContainers = (): void =>
      applyStylesToAll(scrollContainers, { overflow: '' });
 
    // Stop any scroll gesture that escapes a locked container (e.g. a drag that
    // starts inside the overlay's comment box) from chaining/rubber-banding the
    // page behind the overlay.
    const htmlAndBody = [document.documentElement, document.body];
    const lockOverscroll = (): void =>
      applyStylesToAll(htmlAndBody, { overscrollBehavior: 'none' });
    const unlockOverscroll = (): void =>
      applyStylesToAll(htmlAndBody, { overscrollBehavior: '' });
 
    // Lock body scroll when overlay opens.
    // On mobile, use position-based locking to avoid iOS viewport zoom.
    if (isMobile) {
      const scrollY = window.scrollY;
      // Pin the body at the top (top: 0) rather than shifting it by the scroll
      // offset (which would move the fixed header off-screen too). Restore the
      // scroll position on cleanup instead.
      applyStyles(document.body, {
        position: 'fixed',
        top: '0',
        width: '100%',
      });
      pinHeader();
      lockScrollContainers();
      lockOverscroll();
 
      // iOS keeps touch-scrolling inner containers even when their overflow is
      // hidden, and — unlike Android — lets a swipe pan the whole visual
      // viewport, exposing blank space past the overlay. The overlay is
      // full-screen fixed, so every touch lands "inside" it. Block touchmove so
      // nothing pans, with one exception: the comment box, which may need to
      // scroll a long comment.
      //
      // That exception can't be blanket, though: the textarea is the largest
      // target on screen, so most swipes start on it. Letting them through
      // whenever the box can't move (short comment) or once it hits its top or
      // bottom edge is exactly what chains the gesture out into a page/visual-
      // viewport pan. So allow the native scroll only while the box has room to
      // move in the swipe direction; at its edges, block it.
      let touchStartY = 0;
      const recordTouchStart = (event: TouchEvent): void => {
        touchStartY = event.touches[0]?.clientY ?? 0;
      };
      const preventTouchScroll = (event: TouchEvent): void => {
        const target = event.target as Element | null;
        const box = target?.closest(TOUCH_SCROLL_ALLOW_SELECTOR) ?? null;
 
        if (box) {
          const canScroll = box.scrollHeight > box.clientHeight;
          if (canScroll) {
            const currentY = event.touches[0]?.clientY ?? touchStartY;
            const movingDown = currentY > touchStartY;
            const atTop = box.scrollTop <= 0;
            const atBottom =
              box.scrollTop + box.clientHeight >= box.scrollHeight - 1;
 
            // Only the overscroll past an edge chains out to a page pan; block
            // that, otherwise let the box scroll normally.
            if ((atTop && movingDown) || (atBottom && !movingDown)) {
              event.preventDefault();
            }
            return;
          }
        }
 
        event.preventDefault();
      };
      document.addEventListener('touchstart', recordTouchStart, {
        passive: true,
      });
      document.addEventListener('touchmove', preventTouchScroll, {
        passive: false,
      });
 
      return () => {
        applyStyles(document.body, { position: '', top: '', width: '' });
        unpinHeader();
        unlockScrollContainers();
        unlockOverscroll();
        document.removeEventListener('touchstart', recordTouchStart);
        document.removeEventListener('touchmove', preventTouchScroll);
        window.scrollTo(0, scrollY);
      };
    }
 
    applyStylesToAll(htmlAndBody, { overflow: 'hidden' });
    pinHeader();
    lockScrollContainers();
    lockOverscroll();
 
    return () => {
      applyStylesToAll(htmlAndBody, { overflow: '' });
      unpinHeader();
      unlockScrollContainers();
      unlockOverscroll();
    };
  }, [isOpen]);
};