The button floats over the bottom-right corner, and at the end of a long table it covered the closing pager's '100' and 'All'. While it is up, PageScroller ends the page a button's height later (only then, so a full-height layout gets no scrollbar). Its shown state is a shared store read through a use… wrapper: a store's .use() called straight from a component is memoized by the React Compiler and never re-renders (contract: store.contract.test.ts).
125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The scroll-restoration contract PageScroller implements: offsets are remembered per
|
|
// (history entry id + content key), so Back to an entry restores where you left it while a forward
|
|
// navigation (a new entry id) or a new content key opens at the top. jsdom lays nothing out, so we
|
|
// feed the <main> fake metrics, drive restoreScroll's rAF loop synchronously, and steer the entry id
|
|
// through window.history.state (what ensureNavId/peekNavId read) — firing the nav bus (notifyNav)
|
|
// stands in for a Back/Forward that doesn't re-render the scroller.
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
|
});
|
|
|
|
import PageScroller from "./PageScroller";
|
|
import BackToTop from "./BackToTop";
|
|
import { notifyNav } from "../../lib/history";
|
|
|
|
beforeAll(() => {
|
|
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// restoreScroll re-applies the offset over a few rAF frames (the feed needs time to reach height);
|
|
// run rAF synchronously so a restore lands within the test tick.
|
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function setEntry(id: number) {
|
|
window.history.replaceState({ sfScroll: id }, "");
|
|
}
|
|
|
|
function sizedMain(container: HTMLElement): HTMLElement {
|
|
const main = container.querySelector("main")!;
|
|
// Enough scrollable room for a 300px offset (restoreScroll only sets when scrollHeight-clientHeight
|
|
// >= the target), and a writable scrollTop jsdom otherwise no-ops.
|
|
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 });
|
|
Object.defineProperty(main, "clientHeight", { configurable: true, value: 500 });
|
|
Object.defineProperty(main, "scrollTop", { configurable: true, writable: true, value: 0 });
|
|
return main;
|
|
}
|
|
|
|
describe("PageScroller scroll memory", () => {
|
|
it("restores per history entry: a forward nav opens at the top, Back restores the offset", () => {
|
|
setEntry(1);
|
|
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
|
|
const main = sizedMain(container);
|
|
|
|
// The user scrolls entry 1 to 200 — recorded live on scroll.
|
|
main.scrollTop = 200;
|
|
fireEvent.scroll(main);
|
|
|
|
// Forward navigation → a fresh entry id, same content key → the view opens at the top.
|
|
setEntry(2);
|
|
notifyNav();
|
|
expect(main.scrollTop).toBe(0);
|
|
|
|
// Back to entry 1 → its saved offset is restored.
|
|
setEntry(1);
|
|
notifyNav();
|
|
expect(main.scrollTop).toBe(200);
|
|
});
|
|
|
|
it("restores per content key within one entry: a new key resets to top, returning restores", () => {
|
|
setEntry(10);
|
|
const { container, rerender } = render(<PageScroller scrollKey="e10:a">x</PageScroller>);
|
|
const main = sizedMain(container);
|
|
|
|
main.scrollTop = 300;
|
|
fireEvent.scroll(main);
|
|
|
|
// A new content key (e.g. a fresh feed filter) in the SAME entry starts at the top.
|
|
rerender(<PageScroller scrollKey="e10:b">x</PageScroller>);
|
|
expect(main.scrollTop).toBe(0);
|
|
|
|
// Returning to the first key restores its offset.
|
|
rerender(<PageScroller scrollKey="e10:a">x</PageScroller>);
|
|
expect(main.scrollTop).toBe(300);
|
|
});
|
|
});
|
|
|
|
// E28 UAT: Back to top floats over the bottom-right corner, and at the end of a long table it covered
|
|
// the closing pager's "100" and "All". While it is up, the page ends a button's height later.
|
|
describe("PageScroller · room for Back to top", () => {
|
|
it("ends the page later while the button is up, and not otherwise", () => {
|
|
const { container } = render(
|
|
<>
|
|
<PageScroller scrollKey="room">
|
|
<p>rows</p>
|
|
</PageScroller>
|
|
<BackToTop />
|
|
</>,
|
|
);
|
|
const main = container.querySelector("main")!;
|
|
const spacer = () => main.querySelector(':scope > [aria-hidden="true"]');
|
|
expect(spacer()).toBeNull();
|
|
main.scrollTop = 900;
|
|
act(() => void main.dispatchEvent(new Event("scroll")));
|
|
expect(spacer()?.className).toBe("h-18");
|
|
main.scrollTop = 0;
|
|
act(() => void main.dispatchEvent(new Event("scroll")));
|
|
expect(spacer()).toBeNull();
|
|
});
|
|
});
|