mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
+124
-10
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user