/** * The "keep scrolling" cue for the Company ID page. On a short screen the * first paint ends right after the ring and the completeness card, with no * partially visible block below to say the page continues - merchants stopped * there and never reached the rings or the setup rail. */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { LuChevronDown } from 'react-icons/lu'; /** Hide the cue once the merchant has clearly started scrolling. */ const SCROLLED_AWAY_PX = 40; /** Below this much hidden content the page is effectively all visible. */ const WORTH_CUEING_PX = 160; interface ScrollCueProps { /** Short line telling the merchant what waits below. */ label: string; } /** * Find the ancestor that actually scrolls. The plugin shell scrolls its * nested `main`, not the window, so the cue cannot read window scroll. * A candidate must both declare a scrolling overflow AND overflow its box: * the Company ID page root sets `overflow-x: hidden`, which makes the * computed `overflow-y` resolve to `auto` even though the element never * scrolls, and stopping there would leave the cue permanently hidden. When * nothing overflows yet, the OUTERMOST in-app candidate wins (the shell's * `main`, never the document itself), so a page that only grows tall after * mount still measures the container it will actually scroll. * * @param {HTMLElement | null} from - Node to start walking up from. * @returns {HTMLElement | null} The scrolling ancestor, or null. */ function findScroller(from: HTMLElement | null): HTMLElement | null { let node = from?.parentElement ?? null; let fallback: HTMLElement | null = null; while (node) { const overflowY = window.getComputedStyle(node).overflowY; const scrollable = overflowY === 'auto' || overflowY === 'scroll'; if (scrollable && node.scrollHeight > node.clientHeight) return node; if ( scrollable && node !== document.body && node !== document.documentElement ) { fallback = node; } node = node.parentElement; } // Nothing above us scrolls: the document itself does, which is the // case inside an embedded admin. Without this the cue resolved to // null and never appeared at all. return ( fallback ?? (document.scrollingElement as HTMLElement | null) ?? document.documentElement ); } /** * The scroller's own child that holds the page, so a ResizeObserver watches * the block that actually grows as the record lands. * * @param {HTMLElement} scroller - The scrolling ancestor. * @param {HTMLElement} anchor - The cue's in-flow anchor node. * @returns {Element | null} The direct child wrapping the anchor. */ function scrollerContent( scroller: HTMLElement, anchor: HTMLElement ): Element | null { let node: HTMLElement | null = anchor; while (node && node.parentElement !== scroller) { node = node.parentElement; } return node ?? scroller.firstElementChild; } /** * The cue: a centered pill over the bottom of the scrolling area that bobs * until the merchant scrolls, and scrolls the page down when clicked. * * @param {ScrollCueProps} props - The label shown inside the pill. * @returns {JSX.Element} The cue element (empty anchor while hidden). */ export function ScrollCue({ label }: ScrollCueProps): JSX.Element { const anchorRef = useRef(null); const scrollerRef = useRef(null); const [visible, setVisible] = useState(false); // Fixed to the viewport, but centered on the scrolling area rather than the // window: the sidebar and the copilot panel both shift that area sideways. const [box, setBox] = useState<{ left: number; width: number } | null>(null); useEffect(() => { const anchor = anchorRef.current; const scroller = findScroller(anchor); scrollerRef.current = scroller; if (!scroller) return; const update = () => { const rect = scroller.getBoundingClientRect(); setBox({ left: rect.left, width: rect.width }); const hidden = scroller.scrollHeight - scroller.clientHeight; setVisible( hidden > WORTH_CUEING_PX && scroller.scrollTop < SCROLLED_AWAY_PX ); }; update(); scroller.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update); // The page grows as the record lands, so re-measure on content resize // instead of trusting the mount-time height. const content = anchor ? scrollerContent(scroller, anchor) : null; const observer = content ? new ResizeObserver(update) : null; if (content && observer) observer.observe(content); return () => { scroller.removeEventListener('scroll', update); window.removeEventListener('resize', update); observer?.disconnect(); }; }, []); const scrollDown = useCallback(() => { const scroller = scrollerRef.current; if (!scroller) return; const reduced = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; scroller.scrollBy({ top: Math.round(scroller.clientHeight * 0.82), behavior: reduced ? 'auto' : 'smooth', }); }, []); return (
{visible && box && (
)}
); }