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
+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);
});
});