mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +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:
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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