All files / src/components/layout scroll-hide-header.tsx

100% Statements 25/25
100% Branches 16/16
100% Functions 4/4
100% Lines 23/23

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                                                          4x           4x                                       51x 51x   51x 36x 25x 25x   11x 11x   10x 10x 9x 9x 9x 6x 6x 3x 1x 1x       10x 10x     51x                            
import {
	type ReactNode,
	type RefObject,
	useEffect,
	useRef,
	useState,
} from "react";
import { cn } from "../../lib/utils";
 
/**
 * `ScrollHideHeader` — specified in the approved mobile spec
 * (`docs/wip/specs/2026-03-31-portal-mobile-ux-redesign.md` §2: "Hides on
 * scroll down, reappears on scroll up") and still unbuilt until now. The
 * numbers below come from the signed-off mockup `.context/mockups/mobile.html`,
 * which animates the behaviour rather than annotating it.
 *
 * Two rules the mockup is explicit about:
 *
 * - **Margin, not transform.** The bar collapses by pulling its own top margin
 *   negative, so the content genuinely reclaims the space instead of sliding
 *   under a hole. The old mobile header used `-translate-y-full` and left a
 *   48px gap behind.
 * - **The bottom nav does NOT hide.** It is the only way out of a screen. Only
 *   the header is hideable, and only because nothing in it is load-bearing —
 *   a `MobileFullPage` header carries the ✕ and the step count, so it stays
 *   pinned (pass `enabled={false}`).
 */
 
/** Don't hide while the user is still near the top of the page. */
export const SCROLL_HIDE_FLOOR = 56;
 
/**
 * Collapsing the header reflows the content, which fires another scroll event.
 * Without a dead zone the bar flickers.
 */
export const SCROLL_HIDE_HYSTERESIS = 8;
 
export type ScrollHideHeaderProps = {
	/** The element that actually scrolls — usually `<main>`. */
	scrollRef: RefObject<HTMLElement | null>;
	/** Off on desktop and on full pages: the bar then never moves. */
	enabled?: boolean;
	className?: string;
	/** How far up it tucks. Must match the bar's own height. */
	collapsedClassName?: string;
	children: ReactNode;
};
 
export function ScrollHideHeader({
	scrollRef,
	enabled = true,
	className,
	collapsedClassName = "-mt-12",
	children,
}: ScrollHideHeaderProps) {
	const [hidden, setHidden] = useState(false);
	const lastY = useRef(0);
 
	useEffect(() => {
		if (!enabled) {
			setHidden(false);
			return;
		}
		const element = scrollRef.current;
		if (!element) return;
 
		lastY.current = element.scrollTop;
		const onScroll = () => {
			const y = element.scrollTop;
			const delta = y - lastY.current;
			if (delta > SCROLL_HIDE_HYSTERESIS && y > SCROLL_HIDE_FLOOR) {
				setHidden(true);
				lastY.current = y;
			} else if (delta < -SCROLL_HIDE_HYSTERESIS) {
				setHidden(false);
				lastY.current = y;
			}
		};
 
		element.addEventListener("scroll", onScroll, { passive: true });
		return () => element.removeEventListener("scroll", onScroll);
	}, [enabled, scrollRef]);
 
	return (
		<header
			data-hidden={hidden ? "true" : undefined}
			className={cn(
				// Nothing moves under prefers-reduced-motion: it snaps instead.
				"transition-[margin] duration-200 ease-out motion-reduce:transition-none",
				hidden && collapsedClassName,
				className,
			)}
		>
			{children}
		</header>
	);
}