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
+1 -1
View File
@@ -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)
+13 -2
View File
@@ -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<HTMLElement | null>;
label: string;
}
interface UnifiedPaginationProps {
sections: PaginationSection[];
footerRef?: React.RefObject<HTMLElement | null>;
activeIndex: number; // controlled by parent
onDominantSectionChange: (idx: number) => void; // observer guess; parent decides
}
```
## Custom Hooks
+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 };
}
+2
View File
@@ -66,6 +66,8 @@ vi.mock('@/components/ui/UnifiedPagination', () => ({
label: string;
onPageChange: (page: number) => void;
}>;
activeIndex: number;
onDominantSectionChange: (idx: number) => void;
}) => (
<div>
{sections.map((s) => (
+252 -25
View File
@@ -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<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
const observers: {
callback: IntersectionObserverCallback;
observe: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
}[] = [];
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(<UnifiedPagination sections={sections} />);
const { container } = render(
<UnifiedPagination
sections={sections}
activeIndex={0}
onDominantSectionChange={vi.fn()}
/>
);
// 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(<UnifiedPagination sections={sections} />);
const { container } = render(
<UnifiedPagination
sections={sections}
activeIndex={0}
onDominantSectionChange={vi.fn()}
/>
);
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(
<UnifiedPagination sections={sections} footerRef={footerRef} />
<UnifiedPagination
sections={sections}
footerRef={footerRef}
activeIndex={0}
onDominantSectionChange={vi.fn()}
/>
);
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(<UnifiedPagination sections={sections} />);
render(
<UnifiedPagination
sections={sections}
activeIndex={0}
onDominantSectionChange={vi.fn()}
/>
);
// 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(<UnifiedPagination sections={sections} />);
render(
<UnifiedPagination
sections={sections}
activeIndex={0}
onDominantSectionChange={vi.fn()}
/>
);
// Make section visible
act(() => {
@@ -196,8 +215,216 @@ describe('UnifiedPagination', () => {
it('uses pointer-events-none when hidden', () => {
const sections = makeSections();
const { container } = render(<UnifiedPagination sections={sections} />);
const footerRef = { current: document.createElement('footer') };
const { container } = render(
<UnifiedPagination
sections={sections}
footerRef={footerRef}
activeIndex={0}
onDominantSectionChange={vi.fn()}
/>
);
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(
<UnifiedPagination
sections={sections}
activeIndex={0}
onDominantSectionChange={onDominant}
/>
);
// 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(
<UnifiedPagination
sections={sections}
activeIndex={0}
onDominantSectionChange={vi.fn()}
/>
);
// 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 (
<>
<button onClick={() => setIdx(1)}>flip</button>
<UnifiedPagination
sections={sections}
activeIndex={idx}
onDominantSectionChange={vi.fn()}
/>
</>
);
}
render(<Harness />);
// 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(
<UnifiedPagination
sections={sections}
activeIndex={0}
onDominantSectionChange={onDominant}
/>
);
// 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);
});
});
+117
View File
@@ -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);
});
});