mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
5d9a764151
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.
118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|