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:
@@ -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
|
- **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
|
- **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
|
- **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
|
## Key Decisions
|
||||||
- 10 section limit per user (total)
|
- 10 section limit per user (total)
|
||||||
|
|||||||
@@ -71,8 +71,12 @@ src/components/
|
|||||||
- Floating pagination pill at bottom center of viewport
|
- Floating pagination pill at bottom center of viewport
|
||||||
- Minimal design: section label | ← | Page X of Y | →
|
- Minimal design: section label | ← | Page X of Y | →
|
||||||
- Quick jump input (type page number + Enter)
|
- Quick jump input (type page number + Enter)
|
||||||
- Auto-shows when scrolling through a section (IntersectionObserver)
|
- Free-scroll tracking via IntersectionObserver (reports dominant section to parent)
|
||||||
- Auto-scrolls to section top on page change
|
- 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
|
- Rounded-full design with backdrop blur and subtle shadow
|
||||||
- Responsive grid layouts (1/2/3/4 cols)
|
- Responsive grid layouts (1/2/3/4 cols)
|
||||||
- Enhanced CTA section with gradient background (blue-to-indigo)
|
- Enhanced CTA section with gradient background (blue-to-indigo)
|
||||||
@@ -168,6 +172,13 @@ interface StickyPaginationProps {
|
|||||||
sectionRef: React.RefObject<HTMLElement | null>;
|
sectionRef: React.RefObject<HTMLElement | null>;
|
||||||
label: string;
|
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
|
## Custom Hooks
|
||||||
|
|||||||
+124
-10
@@ -14,6 +14,18 @@ import { HomeSectionConfigModal } from '@/components/home/HomeSectionConfigModal
|
|||||||
import { useHomeSections } from '@/lib/hooks/useHomeSections';
|
import { useHomeSections } from '@/lib/hooks/useHomeSections';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
|
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 {
|
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
|
||||||
if (sectionType === 'popular') return 'Popular Audiobooks';
|
if (sectionType === 'popular') return 'Popular Audiobooks';
|
||||||
@@ -21,6 +33,14 @@ function getSectionTitle(sectionType: string, categoryName?: string | null): str
|
|||||||
return categoryName || 'Category';
|
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() {
|
export default function HomePage() {
|
||||||
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
|
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
|
||||||
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
|
||||||
@@ -30,6 +50,12 @@ export default function HomePage() {
|
|||||||
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
|
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
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);
|
const footerRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
// Create stable refs for each section
|
// Create stable refs for each section
|
||||||
@@ -52,6 +78,38 @@ export default function HomePage() {
|
|||||||
setTotalPagesMap({});
|
setTotalPagesMap({});
|
||||||
}, [hideAvailable]);
|
}, [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 getPage = (key: string) => pages[key] || 1;
|
||||||
const setPage = useCallback((key: string, page: number) => {
|
const setPage = useCallback((key: string, page: number) => {
|
||||||
setPages((prev) => ({ ...prev, [key]: page }));
|
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
|
// Build pagination sections for the floating pill
|
||||||
const paginationSections: PaginationSection[] = sections.map((s, i) => {
|
const paginationSections: PaginationSection[] = sections.map((s, i) => {
|
||||||
const key = getSectionKey(s);
|
const key = getSectionKey(s);
|
||||||
@@ -72,13 +192,9 @@ export default function HomePage() {
|
|||||||
accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length],
|
accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length],
|
||||||
currentPage: getPage(key),
|
currentPage: getPage(key),
|
||||||
totalPages: totalPagesMap[key] || 1,
|
totalPages: totalPagesMap[key] || 1,
|
||||||
onPageChange: (page: number) => {
|
onPageChange: (page: number) => handlePageChange(i, key, page, ref),
|
||||||
setPage(key, page);
|
|
||||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
},
|
|
||||||
sectionRef: ref,
|
sectionRef: ref,
|
||||||
onScrollToSection: () =>
|
onScrollToSection: () => handleScrollToSection(i, ref),
|
||||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,10 +241,6 @@ export default function HomePage() {
|
|||||||
categoryName={section.categoryName}
|
categoryName={section.categoryName}
|
||||||
colorIndex={index}
|
colorIndex={index}
|
||||||
page={getPage(key)}
|
page={getPage(key)}
|
||||||
onPageChange={(page) => {
|
|
||||||
setPage(key, page);
|
|
||||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}}
|
|
||||||
sectionRef={ref}
|
sectionRef={ref}
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
squareCovers={squareCovers}
|
squareCovers={squareCovers}
|
||||||
@@ -174,6 +286,8 @@ export default function HomePage() {
|
|||||||
<UnifiedPagination
|
<UnifiedPagination
|
||||||
footerRef={footerRef}
|
footerRef={footerRef}
|
||||||
sections={paginationSections}
|
sections={paginationSections}
|
||||||
|
activeIndex={Math.min(activeIndex, paginationSections.length - 1)}
|
||||||
|
onDominantSectionChange={handleDominantSectionChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ interface HomeSectionProps {
|
|||||||
categoryName: string | null;
|
categoryName: string | null;
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
page: number;
|
page: number;
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
sectionRef: React.RefObject<HTMLElement | null>;
|
sectionRef: React.RefObject<HTMLElement | null>;
|
||||||
cardSize: number;
|
cardSize: number;
|
||||||
squareCovers: boolean;
|
squareCovers: boolean;
|
||||||
@@ -226,7 +225,6 @@ export function HomeSection({
|
|||||||
categoryName,
|
categoryName,
|
||||||
colorIndex,
|
colorIndex,
|
||||||
page,
|
page,
|
||||||
onPageChange,
|
|
||||||
sectionRef,
|
sectionRef,
|
||||||
cardSize,
|
cardSize,
|
||||||
squareCovers,
|
squareCovers,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export interface PaginationSection {
|
|||||||
interface UnifiedPaginationProps {
|
interface UnifiedPaginationProps {
|
||||||
sections: PaginationSection[];
|
sections: PaginationSection[];
|
||||||
footerRef?: React.RefObject<HTMLElement | null>;
|
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
|
// Main component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
|
export function UnifiedPagination({
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
sections,
|
||||||
|
footerRef,
|
||||||
|
activeIndex,
|
||||||
|
onDominantSectionChange,
|
||||||
|
}: UnifiedPaginationProps) {
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
const [footerVisible, setFooterVisible] = useState(false);
|
const [footerVisible, setFooterVisible] = useState(false);
|
||||||
const ratiosRef = useRef<number[]>(sections.map(() => 0));
|
const ratiosRef = useRef<number[]>(sections.map(() => 0));
|
||||||
const [anySectionVisible, setAnySectionVisible] = useState(false);
|
|
||||||
|
|
||||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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
|
// Keep ratios array length in sync with sections
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,13 +244,31 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
|||||||
}, [sections.length]);
|
}, [sections.length]);
|
||||||
|
|
||||||
const activeSectionHasPages = sections[activeIndex]?.totalPages > 1;
|
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
|
// Intersection observers for all sections
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observers: IntersectionObserver[] = [];
|
const observers: IntersectionObserver[] = [];
|
||||||
|
let lastReportedDominant = -1;
|
||||||
|
|
||||||
sections.forEach((section, idx) => {
|
sections.forEach((section, idx) => {
|
||||||
if (!section.sectionRef.current) return;
|
if (!section.sectionRef.current) return;
|
||||||
@@ -246,8 +276,6 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
|||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
ratiosRef.current[idx] = entry.intersectionRatio;
|
ratiosRef.current[idx] = entry.intersectionRatio;
|
||||||
const anyVisible = ratiosRef.current.some((r) => r > 0.05);
|
|
||||||
setAnySectionVisible(anyVisible);
|
|
||||||
|
|
||||||
// Find dominant section
|
// Find dominant section
|
||||||
let maxRatio = -1;
|
let maxRatio = -1;
|
||||||
@@ -259,15 +287,11 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveIndex((current) => {
|
// Report to parent. Parent decides whether to honor it (lock-aware).
|
||||||
if (current !== dominant) {
|
if (dominant !== lastReportedDominant) {
|
||||||
setIsTransitioning(true);
|
lastReportedDominant = dominant;
|
||||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
onDominantRef.current(dominant);
|
||||||
transitionTimerRef.current = setTimeout(() => setIsTransitioning(false), 320);
|
}
|
||||||
return dominant;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
|
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
|
||||||
@@ -281,7 +305,6 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observers.forEach((o) => o.disconnect());
|
observers.forEach((o) => o.disconnect());
|
||||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
|
||||||
};
|
};
|
||||||
// Re-run when section refs change
|
// Re-run when section refs change
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 };
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ vi.mock('@/components/ui/UnifiedPagination', () => ({
|
|||||||
label: string;
|
label: string;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
}>;
|
}>;
|
||||||
|
activeIndex: number;
|
||||||
|
onDominantSectionChange: (idx: number) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div>
|
<div>
|
||||||
{sections.map((s) => (
|
{sections.map((s) => (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
|
import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
|
||||||
@@ -50,7 +50,11 @@ function makeSections(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('UnifiedPagination', () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
observers.length = 0;
|
observers.length = 0;
|
||||||
@@ -73,33 +77,31 @@ describe('UnifiedPagination', () => {
|
|||||||
|
|
||||||
it('renders nothing when both sections have only one page', () => {
|
it('renders nothing when both sections have only one page', () => {
|
||||||
const sections = makeSections([{ totalPages: 1 }, { totalPages: 1 }]);
|
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)
|
// The pill should be hidden (pointer-events-none, opacity-0)
|
||||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
expect(root).toHaveClass('pointer-events-none');
|
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 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;
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
expect(root).toHaveClass('opacity-0');
|
// 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.
|
||||||
// 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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(root).toHaveClass('opacity-100');
|
expect(root).toHaveClass('opacity-100');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +109,12 @@ describe('UnifiedPagination', () => {
|
|||||||
const sections = makeSections();
|
const sections = makeSections();
|
||||||
const footerRef = { current: document.createElement('footer') };
|
const footerRef = { current: document.createElement('footer') };
|
||||||
const { container } = render(
|
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;
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
@@ -147,7 +154,13 @@ describe('UnifiedPagination', () => {
|
|||||||
|
|
||||||
it('calls onPageChange for prev/next buttons', () => {
|
it('calls onPageChange for prev/next buttons', () => {
|
||||||
const sections = makeSections([{ currentPage: 2, totalPages: 4 }]);
|
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
|
// Make section visible so controls render interactably
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -172,7 +185,13 @@ describe('UnifiedPagination', () => {
|
|||||||
|
|
||||||
it('handles page jump input', () => {
|
it('handles page jump input', () => {
|
||||||
const sections = makeSections([{ currentPage: 2, totalPages: 5 }]);
|
const sections = makeSections([{ currentPage: 2, totalPages: 5 }]);
|
||||||
render(<UnifiedPagination sections={sections} />);
|
render(
|
||||||
|
<UnifiedPagination
|
||||||
|
sections={sections}
|
||||||
|
activeIndex={0}
|
||||||
|
onDominantSectionChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// Make section visible
|
// Make section visible
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -196,8 +215,216 @@ describe('UnifiedPagination', () => {
|
|||||||
|
|
||||||
it('uses pointer-events-none when hidden', () => {
|
it('uses pointer-events-none when hidden', () => {
|
||||||
const sections = makeSections();
|
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;
|
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');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user