diff --git a/documentation/features/home-sections.md b/documentation/features/home-sections.md index ef75295..a924e6d 100644 --- a/documentation/features/home-sections.md +++ b/documentation/features/home-sections.md @@ -42,7 +42,7 @@ Users customize their home page by adding/removing/reordering sections. Each sec - **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce - **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header - **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize -- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — updated to support 1-12 dynamic sections +- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — controlled by `HomePage` for `activeIndex`; observer reports dominant section but parent gates updates via `lockedTo` state. Lock set on Prev/Next/jump; released on user scroll input (`wheel` / `touchstart` / Arrow / Page / Home / End keys) or any dot click. Fit-aware scroll via `src/lib/utils/paginationScroll.ts` — no scroll when section fits viewport, otherwise snaps top under sticky header with clamps that structurally prevent scrolling the section out of view. Pill is shown anywhere on main content; only the footer hides it. ## Key Decisions - 10 section limit per user (total) diff --git a/documentation/frontend/components.md b/documentation/frontend/components.md index 2299eab..a0fd12d 100644 --- a/documentation/frontend/components.md +++ b/documentation/frontend/components.md @@ -71,8 +71,12 @@ src/components/ - Floating pagination pill at bottom center of viewport - Minimal design: section label | ← | Page X of Y | → - Quick jump input (type page number + Enter) -- Auto-shows when scrolling through a section (IntersectionObserver) -- Auto-scrolls to section top on page change +- Free-scroll tracking via IntersectionObserver (reports dominant section to parent) +- Controlled `activeIndex` lives on the home page; pill is observer-aware but parent-decided +- **Lock-to-section on Prev/Next/jump:** pill stays anchored to the paged section until the user generates a scroll input (`wheel`, `touchstart`, `ArrowUp/Down`, `PageUp/Down`, `Home`, `End`) or clicks another section's dot. 30s safety auto-release. +- **Fit-aware scroll:** if the section already fits below the sticky header, paging swaps cards in place (no scroll). Otherwise snaps the section top under the header with breathing room (8px top, 24px bottom). Target Y is clamped to `[0, maxScrollY]` so paging can never scroll the section out of the viewport. +- Dot click on a different section always scrolls (intentional navigation) and releases any active lock. +- Visibility: pill is shown anywhere on homepage main content; hidden only when the footer enters view. Stays visible over the CTA card gap between the last section and the footer. - Rounded-full design with backdrop blur and subtle shadow - Responsive grid layouts (1/2/3/4 cols) - Enhanced CTA section with gradient background (blue-to-indigo) @@ -168,6 +172,13 @@ interface StickyPaginationProps { sectionRef: React.RefObject; label: string; } + +interface UnifiedPaginationProps { + sections: PaginationSection[]; + footerRef?: React.RefObject; + activeIndex: number; // controlled by parent + onDominantSectionChange: (idx: number) => void; // observer guess; parent decides +} ``` ## Custom Hooks diff --git a/src/app/page.tsx b/src/app/page.tsx index 54d9aa0..565161d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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('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>({}); const [configOpen, setConfigOpen] = useState(false); + // Controlled paginator-pill state + const [activeIndex, setActiveIndex] = useState(0); + const [lockedTo, setLockedTo] = useState(null); + const lockedToRef = useRef(null); + lockedToRef.current = lockedTo; + const footerRef = useRef(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) => { + 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) => { + 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() { )} diff --git a/src/components/home/HomeSection.tsx b/src/components/home/HomeSection.tsx index c1981fa..7be1bc2 100644 --- a/src/components/home/HomeSection.tsx +++ b/src/components/home/HomeSection.tsx @@ -89,7 +89,6 @@ interface HomeSectionProps { categoryName: string | null; colorIndex: number; page: number; - onPageChange: (page: number) => void; sectionRef: React.RefObject; cardSize: number; squareCovers: boolean; @@ -226,7 +225,6 @@ export function HomeSection({ categoryName, colorIndex, page, - onPageChange, sectionRef, cardSize, squareCovers, diff --git a/src/components/ui/UnifiedPagination.tsx b/src/components/ui/UnifiedPagination.tsx index 6fd0b07..08c57cc 100644 --- a/src/components/ui/UnifiedPagination.tsx +++ b/src/components/ui/UnifiedPagination.tsx @@ -29,6 +29,11 @@ export interface PaginationSection { interface UnifiedPaginationProps { sections: PaginationSection[]; footerRef?: React.RefObject; + /** 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(sections.map(() => 0)); - const [anySectionVisible, setAnySectionVisible] = useState(false); const transitionTimerRef = useRef | 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 diff --git a/src/lib/utils/paginationScroll.ts b/src/lib/utils/paginationScroll.ts new file mode 100644 index 0000000..23bafe2 --- /dev/null +++ b/src/lib/utils/paginationScroll.ts @@ -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 }; +} diff --git a/tests/app/home.page.test.tsx b/tests/app/home.page.test.tsx index 2bfbc72..c03bde3 100644 --- a/tests/app/home.page.test.tsx +++ b/tests/app/home.page.test.tsx @@ -66,6 +66,8 @@ vi.mock('@/components/ui/UnifiedPagination', () => ({ label: string; onPageChange: (page: number) => void; }>; + activeIndex: number; + onDominantSectionChange: (idx: number) => void; }) => (
{sections.map((s) => ( diff --git a/tests/components/ui/UnifiedPagination.test.tsx b/tests/components/ui/UnifiedPagination.test.tsx index 6d38ba8..dd06f8f 100644 --- a/tests/components/ui/UnifiedPagination.test.tsx +++ b/tests/components/ui/UnifiedPagination.test.tsx @@ -5,7 +5,7 @@ // @vitest-environment jsdom -import React from 'react'; +import React, { useState } from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination'; @@ -50,7 +50,11 @@ function makeSections( } describe('UnifiedPagination', () => { - const observers: { callback: IntersectionObserverCallback; observe: ReturnType; disconnect: ReturnType }[] = []; + const observers: { + callback: IntersectionObserverCallback; + observe: ReturnType; + disconnect: ReturnType; + }[] = []; beforeEach(() => { observers.length = 0; @@ -73,33 +77,31 @@ describe('UnifiedPagination', () => { it('renders nothing when both sections have only one page', () => { const sections = makeSections([{ totalPages: 1 }, { totalPages: 1 }]); - const { container } = render(); + const { container } = render( + + ); // The pill should be hidden (pointer-events-none, opacity-0) const root = container.querySelector('div.fixed') as HTMLElement; expect(root).toHaveClass('pointer-events-none'); }); - it('shows pagination when the dominant section is visible and has pages', () => { + it('is visible by default on the homepage main content (no footer in view)', () => { const sections = makeSections(); - const { container } = render(); + const { container } = render( + + ); const root = container.querySelector('div.fixed') as HTMLElement; - expect(root).toHaveClass('opacity-0'); - - // Simulate first section becoming visible with high ratio - act(() => { - observers[0].callback( - [ - { - isIntersecting: true, - intersectionRatio: 0.5, - target: sections[0].sectionRef.current as Element, - } as ObserverEntry, - ], - observers[0] as unknown as IntersectionObserver - ); - }); - + // Pill shows immediately — no longer gated on a section being intersected. + // This is what keeps it visible in the CTA-card gap between last section and footer. expect(root).toHaveClass('opacity-100'); }); @@ -107,7 +109,12 @@ describe('UnifiedPagination', () => { const sections = makeSections(); const footerRef = { current: document.createElement('footer') }; const { container } = render( - + ); const root = container.querySelector('div.fixed') as HTMLElement; @@ -147,7 +154,13 @@ describe('UnifiedPagination', () => { it('calls onPageChange for prev/next buttons', () => { const sections = makeSections([{ currentPage: 2, totalPages: 4 }]); - const { container } = render(); + render( + + ); // Make section visible so controls render interactably act(() => { @@ -172,7 +185,13 @@ describe('UnifiedPagination', () => { it('handles page jump input', () => { const sections = makeSections([{ currentPage: 2, totalPages: 5 }]); - render(); + render( + + ); // Make section visible act(() => { @@ -196,8 +215,216 @@ describe('UnifiedPagination', () => { it('uses pointer-events-none when hidden', () => { const sections = makeSections(); - const { container } = render(); + const footerRef = { current: document.createElement('footer') }; + const { container } = render( + + ); + const root = container.querySelector('div.fixed') as HTMLElement; + + // Hide the pill by bringing the footer into view (sections + footer = 3 observers; footer is index 2). + act(() => { + observers[2].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.1, + target: footerRef.current as Element, + } as ObserverEntry, + ], + observers[2] as unknown as IntersectionObserver + ); + }); + expect(root).toHaveClass('pointer-events-none'); }); + + // --- Controlled-component / lock-aware behavior ------------------------ + + it('reports the observer-chosen dominant section to the parent', () => { + const sections = makeSections(); + const onDominant = vi.fn(); + render( + + ); + + // Section 0 mildly visible + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.2, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + // Section 1 dominates + act(() => { + observers[1].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.9, + target: sections[1].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[1] as unknown as IntersectionObserver + ); + }); + + expect(onDominant).toHaveBeenCalledWith(1); + }); + + it('does NOT swap rendered controls when observer reports a different dominant (parent decides)', () => { + const sections = makeSections([ + { currentPage: 2, totalPages: 4, label: 'Popular' }, + { currentPage: 1, totalPages: 5, label: 'New Releases' }, + ]); + // Parent keeps activeIndex pinned to 0 regardless of what the observer reports. + render( + + ); + + // Make at least one section visible so controls render + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(screen.getByText('Popular')).toBeInTheDocument(); + + // Observer reports section 1 dominates + act(() => { + observers[1].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.95, + target: sections[1].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[1] as unknown as IntersectionObserver + ); + }); + + // Controls still belong to section 0 — the pill is controlled. + expect(screen.getByText('Popular')).toBeInTheDocument(); + expect(screen.queryByText('New Releases')).not.toBeInTheDocument(); + // And Next still targets section 0's onPageChange + fireEvent.click(screen.getByLabelText('Next page')); + expect(sections[0].onPageChange).toHaveBeenCalledWith(3); + expect(sections[1].onPageChange).not.toHaveBeenCalled(); + }); + + it('swaps rendered controls when the parent updates activeIndex', () => { + const sections = makeSections([ + { currentPage: 1, totalPages: 4, label: 'Popular' }, + { currentPage: 1, totalPages: 5, label: 'New Releases' }, + ]); + + // Wrapper that lets us flip activeIndex from outside. + function Harness() { + const [idx, setIdx] = useState(0); + return ( + <> + + + + ); + } + + render(); + + // Make at least one section visible + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(screen.getByText('Popular')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('flip')); + + expect(screen.getByText('New Releases')).toBeInTheDocument(); + expect(screen.queryByText('Popular')).not.toBeInTheDocument(); + }); + + it('does not re-emit dominant when the same section continues to dominate', () => { + const sections = makeSections(); + const onDominant = vi.fn(); + render( + + ); + + // Two callbacks both with section 0 as dominant + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.6, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.7, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + // Section 0 emits `0` exactly once — de-dupe on unchanged dominant + const zeros = onDominant.mock.calls.filter((c) => c[0] === 0); + expect(zeros.length).toBe(1); + }); }); diff --git a/tests/lib/utils/paginationScroll.test.ts b/tests/lib/utils/paginationScroll.test.ts new file mode 100644 index 0000000..d80bc87 --- /dev/null +++ b/tests/lib/utils/paginationScroll.test.ts @@ -0,0 +1,117 @@ +/** + * Component: Pagination Scroll Decision Helper — Tests + * Documentation: documentation/frontend/components.md + */ + +import { describe, it, expect } from 'vitest'; +import { decideScrollForPageChange } from '@/lib/utils/paginationScroll'; + +const base = { + viewportHeight: 1000, + headerHeight: 64, + scrollY: 0, + maxScrollY: 10000, +}; + +describe('decideScrollForPageChange', () => { + it('returns "none" when the section fits comfortably below the header', () => { + // available = 1000 - 64 = 936, required = 400 + 8 + 24 = 432 → fits + expect( + decideScrollForPageChange({ ...base, sectionTop: 200, sectionHeight: 400 }) + ).toEqual({ action: 'none' }); + }); + + it('returns "none" at exact fit (boundary inclusive)', () => { + // required = 904 + 8 + 24 = 936 === available + expect( + decideScrollForPageChange({ ...base, sectionTop: 0, sectionHeight: 904 }) + ).toEqual({ action: 'none' }); + }); + + it('returns "scroll" when the section is just barely too tall', () => { + // required = 905 + 8 + 24 = 937 > 936 + const result = decideScrollForPageChange({ + ...base, + sectionTop: 200, + sectionHeight: 905, + }); + expect(result.action).toBe('scroll'); + }); + + it('snaps section top to under the header with breathing room', () => { + // sectionTop 300 viewport-relative + scrollY 500 = 800 absolute; header 64; breathing 8 + // targetY = 800 - 64 - 8 = 728 + const result = decideScrollForPageChange({ + ...base, + scrollY: 500, + sectionTop: 300, + sectionHeight: 2000, + }); + expect(result).toEqual({ action: 'scroll', targetY: 728 }); + }); + + it('clamps targetY to 0 when math goes negative (user already at top, tall header)', () => { + // section is currently above viewport top → sectionTop negative + const result = decideScrollForPageChange({ + ...base, + scrollY: 30, + sectionTop: -10, + sectionHeight: 2000, + }); + // desired = -10 + 30 - 64 - 8 = -52 → clamp to 0 + expect(result).toEqual({ action: 'scroll', targetY: 0 }); + }); + + it('clamps targetY to maxScrollY when the section is at the very bottom of the page', () => { + // Big scrollY pushes desired past maxScrollY + const result = decideScrollForPageChange({ + ...base, + scrollY: 9800, + sectionTop: 500, + sectionHeight: 2000, + maxScrollY: 10000, + }); + // desired = 500 + 9800 - 64 - 8 = 10228 → clamp to 10000 + expect(result).toEqual({ action: 'scroll', targetY: 10000 }); + }); + + it('handles maxScrollY === 0 (page doesn\'t scroll) by clamping to 0', () => { + const result = decideScrollForPageChange({ + ...base, + scrollY: 0, + sectionTop: 200, + sectionHeight: 2000, + maxScrollY: 0, + }); + expect(result).toEqual({ action: 'scroll', targetY: 0 }); + }); + + it('honors custom breathing-room overrides', () => { + // bigger bottom requirement → no-longer fits + // required = 800 + 8 + 200 = 1008 > 936 + const result = decideScrollForPageChange({ + ...base, + sectionTop: 0, + sectionHeight: 800, + breathingRoomBottom: 200, + }); + expect(result.action).toBe('scroll'); + }); + + it('produces a target consistent with snapping section top under the header', () => { + // Sanity: targetY + headerHeight + breathing should equal (sectionTop + scrollY). + const sectionTop = 450; + const scrollY = 250; + const headerHeight = 64; + const breathingRoomTop = 8; + const result = decideScrollForPageChange({ + ...base, + scrollY, + headerHeight, + sectionTop, + sectionHeight: 2000, + }); + if (result.action !== 'scroll') throw new Error('expected scroll'); + expect(result.targetY + headerHeight + breathingRoomTop).toBe(sectionTop + scrollY); + }); +});