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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user