Controlled pagination pill with lock & fit-scroll

Make the floating pagination pill a controlled component and add lock/fit-aware scroll behavior. UnifiedPagination now accepts activeIndex and onDominantSectionChange, reports observer-determined dominant section (parent may ignore when locked) and only shows/hides based on footer visibility. HomePage implements controlled state (activeIndex, lockedTo) with Prev/Next/jump locking, release on wheel/touch/key or 30s safety timeout, and dot clicks that always navigate and release locks. Extracted scroll math to src/lib/utils/paginationScroll.ts (decideScrollForPageChange) so paging avoids scrolling when a section fits below the sticky header and clamps targets; added unit tests and updated component tests and docs to reflect the new behavior. Removed now-unused onPageChange prop from HomeSection.
This commit is contained in:
kikootwo
2026-05-18 13:21:06 -04:00
parent b1492fc32e
commit 5d9a764151
9 changed files with 614 additions and 56 deletions
-2
View File
@@ -89,7 +89,6 @@ interface HomeSectionProps {
categoryName: string | null;
colorIndex: number;
page: number;
onPageChange: (page: number) => void;
sectionRef: React.RefObject<HTMLElement | null>;
cardSize: number;
squareCovers: boolean;
@@ -226,7 +225,6 @@ export function HomeSection({
categoryName,
colorIndex,
page,
onPageChange,
sectionRef,
cardSize,
squareCovers,
+39 -16
View File
@@ -29,6 +29,11 @@ export interface PaginationSection {
interface UnifiedPaginationProps {
sections: PaginationSection[];
footerRef?: React.RefObject<HTMLElement | null>;
/** Controlled: which section's controls the pill displays. */
activeIndex: number;
/** Reports the observer's "dominant section" guess to the parent.
* The parent decides whether to honor it (e.g., ignores it while locked). */
onDominantSectionChange: (index: number) => void;
}
// ---------------------------------------------------------------------------
@@ -217,14 +222,21 @@ function SectionDots({ sections, activeIndex }: SectionDotsProps) {
// Main component
// ---------------------------------------------------------------------------
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
const [activeIndex, setActiveIndex] = useState(0);
export function UnifiedPagination({
sections,
footerRef,
activeIndex,
onDominantSectionChange,
}: UnifiedPaginationProps) {
const [isTransitioning, setIsTransitioning] = useState(false);
const [footerVisible, setFooterVisible] = useState(false);
const ratiosRef = useRef<number[]>(sections.map(() => 0));
const [anySectionVisible, setAnySectionVisible] = useState(false);
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onDominantRef = useRef(onDominantSectionChange);
useEffect(() => {
onDominantRef.current = onDominantSectionChange;
}, [onDominantSectionChange]);
// Keep ratios array length in sync with sections
useEffect(() => {
@@ -232,13 +244,31 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
}, [sections.length]);
const activeSectionHasPages = sections[activeIndex]?.totalPages > 1;
const shouldShow = anySectionVisible && !footerVisible && activeSectionHasPages && sections.length > 0;
// Pill is visible anywhere on the homepage main content. Only the footer
// explicitly retreats it. Don't gate on a section being intersected — that
// hides the pill in the CTA-card gap between last section and footer.
const shouldShow = !footerVisible && activeSectionHasPages && sections.length > 0;
// Cross-fade whenever the controlled activeIndex changes (observer-driven via the
// parent OR a lock-driven explicit set). Skip on initial mount.
const prevActiveIndexRef = useRef(activeIndex);
useEffect(() => {
if (prevActiveIndexRef.current === activeIndex) return;
prevActiveIndexRef.current = activeIndex;
setIsTransitioning(true);
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = setTimeout(() => setIsTransitioning(false), 320);
return () => {
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
};
}, [activeIndex]);
// ------------------------------------------------------------------
// Intersection observers for all sections
// ------------------------------------------------------------------
useEffect(() => {
const observers: IntersectionObserver[] = [];
let lastReportedDominant = -1;
sections.forEach((section, idx) => {
if (!section.sectionRef.current) return;
@@ -246,8 +276,6 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
const observer = new IntersectionObserver(
([entry]) => {
ratiosRef.current[idx] = entry.intersectionRatio;
const anyVisible = ratiosRef.current.some((r) => r > 0.05);
setAnySectionVisible(anyVisible);
// Find dominant section
let maxRatio = -1;
@@ -259,15 +287,11 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
}
}
setActiveIndex((current) => {
if (current !== dominant) {
setIsTransitioning(true);
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = setTimeout(() => setIsTransitioning(false), 320);
return dominant;
}
return current;
});
// Report to parent. Parent decides whether to honor it (lock-aware).
if (dominant !== lastReportedDominant) {
lastReportedDominant = dominant;
onDominantRef.current(dominant);
}
},
{
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
@@ -281,7 +305,6 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
return () => {
observers.forEach((o) => o.disconnect());
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
};
// Re-run when section refs change
// eslint-disable-next-line react-hooks/exhaustive-deps