60 lines
2.6 KiB
TypeScript
60 lines
2.6 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
|
|
/**
|
|
* A row of page controls held under the nav bar while the page scrolls.
|
|
*
|
|
* Whatever sits above a page — its view tabs, its search, its action buttons —
|
|
* used to leave with the scroll. Reaching the bottom of a long list put every
|
|
* control over that list off the top of the window, and the only way back to
|
|
* them was to scroll back through everything just read. The further in somebody
|
|
* was, the further from the controls.
|
|
*
|
|
* The page still scrolls normally. Nothing here gets a scrollbar of its own —
|
|
* the row simply stays where it is. Positioning and the canvas it paints live
|
|
* in `.page-sticky`; this component exists for the one part CSS cannot answer,
|
|
* which is whether the row is currently pinned.
|
|
*
|
|
* `PageHeader` wraps itself in this, so every page built from it gets the
|
|
* behaviour without asking. Dispatch builds its own switcher rather than going
|
|
* through `PageHeader`, so it wraps itself — which is the reason this is a
|
|
* component and not four more lines inside the header.
|
|
*/
|
|
export function StickyRow({ children }: { children: ReactNode }) {
|
|
/**
|
|
* Pinned or not, so the hairline can be drawn only when it is.
|
|
*
|
|
* At rest the nav bar's own hairline sits just above this one, and two
|
|
* parallel lines across the page with nothing between them read as a mistake.
|
|
* The line is what separates the row from content passing UNDER it, so it
|
|
* belongs to the pinned state rather than to the row.
|
|
*
|
|
* Watched with an observer rather than a scroll handler: a scroll listener
|
|
* runs on every frame of every scroll to answer a question that changes
|
|
* twice. The negative top margin puts the observer's boundary a pixel above
|
|
* where the row comes to rest, so "no longer fully inside that box" means
|
|
* exactly "stuck".
|
|
*/
|
|
const rowRef = useRef<HTMLDivElement>(null);
|
|
const [isStuck, setIsStuck] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const element = rowRef.current;
|
|
/* Guarded for any renderer without it — the row still sticks, it just
|
|
never grows its line, which is a missing hairline rather than a crash. */
|
|
if (!element || typeof IntersectionObserver === 'undefined') return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => setIsStuck(Boolean(entry && !entry.isIntersecting)),
|
|
{ rootMargin: '-57px 0px 0px 0px', threshold: 1 },
|
|
);
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={rowRef} className="page-sticky" data-stuck={isStuck ? 'true' : 'false'}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|