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
+124 -10
View File
@@ -14,6 +14,18 @@ import { HomeSectionConfigModal } from '@/components/home/HomeSectionConfigModal
import { useHomeSections } from '@/lib/hooks/useHomeSections';
import { usePreferences } from '@/contexts/PreferencesContext';
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
import { decideScrollForPageChange } from '@/lib/utils/paginationScroll';
const FALLBACK_HEADER_HEIGHT = 64;
const LOCK_SAFETY_RELEASE_MS = 30_000;
const RELEASE_SCROLL_KEYS = new Set([
'ArrowUp',
'ArrowDown',
'PageUp',
'PageDown',
'Home',
'End',
]);
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
if (sectionType === 'popular') return 'Popular Audiobooks';
@@ -21,6 +33,14 @@ function getSectionTitle(sectionType: string, categoryName?: string | null): str
return categoryName || 'Category';
}
function measureHeaderHeight(): number {
if (typeof document === 'undefined') return FALLBACK_HEADER_HEIGHT;
const header = document.querySelector<HTMLElement>('header.sticky');
if (!header) return FALLBACK_HEADER_HEIGHT;
const h = header.getBoundingClientRect().height;
return h > 0 ? h : FALLBACK_HEADER_HEIGHT;
}
export default function HomePage() {
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
@@ -30,6 +50,12 @@ export default function HomePage() {
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
const [configOpen, setConfigOpen] = useState(false);
// Controlled paginator-pill state
const [activeIndex, setActiveIndex] = useState(0);
const [lockedTo, setLockedTo] = useState<number | null>(null);
const lockedToRef = useRef<number | null>(null);
lockedToRef.current = lockedTo;
const footerRef = useRef<HTMLElement>(null);
// Create stable refs for each section
@@ -52,6 +78,38 @@ export default function HomePage() {
setTotalPagesMap({});
}, [hideAvailable]);
// Clamp activeIndex if the section list shrinks
useEffect(() => {
if (sections.length === 0) return;
if (activeIndex >= sections.length) {
setActiveIndex(0);
setLockedTo(null);
}
}, [sections.length, activeIndex]);
// Release the lock on the user's next intentional scroll input.
// wheel / touchstart always release; keydown releases only for known page-scroll keys.
useEffect(() => {
if (lockedTo === null) return;
const release = () => setLockedTo(null);
const onKeyDown = (e: KeyboardEvent) => {
if (RELEASE_SCROLL_KEYS.has(e.key)) release();
};
window.addEventListener('wheel', release, { passive: true });
window.addEventListener('touchstart', release, { passive: true });
window.addEventListener('keydown', onKeyDown);
const safetyTimer = window.setTimeout(release, LOCK_SAFETY_RELEASE_MS);
return () => {
window.removeEventListener('wheel', release);
window.removeEventListener('touchstart', release);
window.removeEventListener('keydown', onKeyDown);
window.clearTimeout(safetyTimer);
};
}, [lockedTo]);
const getPage = (key: string) => pages[key] || 1;
const setPage = useCallback((key: string, page: number) => {
setPages((prev) => ({ ...prev, [key]: page }));
@@ -63,6 +121,68 @@ export default function HomePage() {
});
}, []);
// Pill-driven Prev/Next/jump. Fit-aware scroll, lock pill to this section.
const handlePageChange = useCallback(
(index: number, key: string, page: number, ref: React.RefObject<HTMLElement | null>) => {
setPage(key, page);
setActiveIndex(index);
setLockedTo(index);
const section = ref.current;
if (!section || typeof window === 'undefined') return;
const rect = section.getBoundingClientRect();
const headerHeight = measureHeaderHeight();
const maxScrollY = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight
);
const decision = decideScrollForPageChange({
sectionTop: rect.top,
sectionHeight: rect.height,
viewportHeight: window.innerHeight,
headerHeight,
scrollY: window.scrollY,
maxScrollY,
});
if (decision.action === 'scroll') {
window.scrollTo({ top: decision.targetY, behavior: 'smooth' });
}
},
[setPage]
);
// Dot click on a non-active section. Always scrolls (intentional navigation).
// Releases any active lock and immediately switches the pill to that section.
const handleScrollToSection = useCallback(
(index: number, ref: React.RefObject<HTMLElement | null>) => {
setLockedTo(null);
setActiveIndex(index);
const section = ref.current;
if (!section || typeof window === 'undefined') return;
const rect = section.getBoundingClientRect();
const headerHeight = measureHeaderHeight();
const maxScrollY = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight
);
const desired = rect.top + window.scrollY - headerHeight - 8;
const targetY = Math.min(Math.max(0, desired), maxScrollY);
window.scrollTo({ top: targetY, behavior: 'smooth' });
},
[]
);
// Observer-driven "dominant section" guess from the pill. Honored only when unlocked.
const handleDominantSectionChange = useCallback((index: number) => {
if (lockedToRef.current !== null) return;
setActiveIndex(index);
}, []);
// Build pagination sections for the floating pill
const paginationSections: PaginationSection[] = sections.map((s, i) => {
const key = getSectionKey(s);
@@ -72,13 +192,9 @@ export default function HomePage() {
accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length],
currentPage: getPage(key),
totalPages: totalPagesMap[key] || 1,
onPageChange: (page: number) => {
setPage(key, page);
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
onPageChange: (page: number) => handlePageChange(i, key, page, ref),
sectionRef: ref,
onScrollToSection: () =>
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
onScrollToSection: () => handleScrollToSection(i, ref),
};
});
@@ -125,10 +241,6 @@ export default function HomePage() {
categoryName={section.categoryName}
colorIndex={index}
page={getPage(key)}
onPageChange={(page) => {
setPage(key, page);
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
sectionRef={ref}
cardSize={cardSize}
squareCovers={squareCovers}
@@ -174,6 +286,8 @@ export default function HomePage() {
<UnifiedPagination
footerRef={footerRef}
sections={paginationSections}
activeIndex={Math.min(activeIndex, paginationSections.length - 1)}
onDominantSectionChange={handleDominantSectionChange}
/>
)}
-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
+66
View File
@@ -0,0 +1,66 @@
/**
* Component: Pagination Scroll Decision Helper
* Documentation: documentation/frontend/components.md
*
* Pure helper that decides whether a section page-change should scroll the
* window and, if so, to what absolute Y. Extracted so `page.tsx` can stay
* lean and the fit-math can be unit-tested without a real browser layout.
*/
export interface ScrollDecisionInput {
/** Section's `getBoundingClientRect().top` (viewport-relative). */
sectionTop: number;
/** Section's `getBoundingClientRect().height`. */
sectionHeight: number;
/** `window.innerHeight`. */
viewportHeight: number;
/** Measured sticky app-header height. */
headerHeight: number;
/** `window.scrollY`. */
scrollY: number;
/** `document.documentElement.scrollHeight - window.innerHeight`. Used as the upper clamp. */
maxScrollY: number;
/** Padding between the header and the section top after a scroll. Default 8. */
breathingRoomTop?: number;
/** Required slack below the section to count as "fits". Default 24. */
breathingRoomBottom?: number;
}
export type ScrollDecision =
| { action: 'none' }
| { action: 'scroll'; targetY: number };
/**
* Decide whether a section page-change should scroll the window.
*
* Rule (locked by product brief):
* - If the section comfortably fits below the sticky header right now → no scroll.
* - Otherwise → snap the section's top to just below the header, with breathing room.
* - Always clamp the target into `[0, maxScrollY]` so paging structurally cannot
* scroll the section out of the viewport.
*/
export function decideScrollForPageChange(input: ScrollDecisionInput): ScrollDecision {
const {
sectionTop,
sectionHeight,
viewportHeight,
headerHeight,
scrollY,
maxScrollY,
breathingRoomTop = 8,
breathingRoomBottom = 24,
} = input;
const availableHeight = viewportHeight - headerHeight;
const requiredHeight = sectionHeight + breathingRoomTop + breathingRoomBottom;
if (requiredHeight <= availableHeight) {
return { action: 'none' };
}
const desired = sectionTop + scrollY - headerHeight - breathingRoomTop;
const upper = Math.max(0, maxScrollY);
const targetY = Math.min(Math.max(0, desired), upper);
return { action: 'scroll', targetY };
}