src/test/intersection.ts
Rama main · réplica de lectura del repositorio original.
// An `IntersectionObserver` for jsdom, which implements none.
//
// It has to exist at all because `new IntersectionObserver` throws outright
// without it: `LoadMoreTrigger` observes its button so a list pulls the next
// page in as the diver scrolls, and every page rendering a list would fail on
// mount rather than at an assertion.
//
// It reports nothing by default, which is the honest answer - whether an element
// is near the viewport is a layout question, and "jsdom answers no layout
// question" (DECISIONS.md). A test that wants the answer says so with `reveal`,
// which is a statement about the scenario rather than about the environment: the
// reader has scrolled to the end of the list, or far enough down the gear page
// to reach the sets card. Anything genuinely about *when* the browser decides an
// element is near belongs in the browser lane.
//
// Installed globally from `vitest.setup.ts`, unlike `memoryStorage`, because the
// stub holds no per-test state a suite could leak through - `reveal` is a
// one-shot call over whatever is mounted right now, and the registry is cleared
// after every test.
interface Registration {
element: Element;
callback: IntersectionObserverCallback;
observer: IntersectionObserver;
}
const registrations: Registration[] = [];
class StubIntersectionObserver implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly scrollMargin = "";
readonly thresholds: readonly number[] = [];
constructor(private readonly callback: IntersectionObserverCallback) {}
observe(element: Element) {
registrations.push({ element, callback: this.callback, observer: this });
}
unobserve(element: Element) {
for (let i = registrations.length - 1; i >= 0; i -= 1) {
if (
registrations[i].observer === this &&
registrations[i].element === element
) {
registrations.splice(i, 1);
}
}
}
disconnect() {
for (let i = registrations.length - 1; i >= 0; i -= 1) {
if (registrations[i].observer === this) registrations.splice(i, 1);
}
}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
export function installIntersectionObserverStub() {
window.IntersectionObserver =
StubIntersectionObserver as unknown as typeof IntersectionObserver;
}
export function clearIntersectionObservers() {
registrations.length = 0;
}
/**
* Report every element currently under observation as on screen - "the reader
* has scrolled to it".
*
* Wrap the call in `act()`: it runs React state updates synchronously. The list
* is copied first because a callback can mount or unmount observed nodes, which
* would otherwise mutate the array mid-iteration.
*/
export function reveal() {
for (const { element, callback, observer } of [...registrations]) {
callback(
[
{
isIntersecting: true,
intersectionRatio: 1,
target: element,
boundingClientRect: { top: 100, bottom: 140 } as DOMRectReadOnly,
rootBounds: { top: 0, bottom: 799 } as DOMRectReadOnly,
} as IntersectionObserverEntry,
],
observer,
);
}
}
/**
* Report every element currently under observation as scrolled up past the top
* of the viewport - off screen, and above it rather than below. Wrap it in
* `act()`, as `reveal`.
*
* `within` narrows it to the elements inside one node - the reader has scrolled
* past this card and not the next.
*/
export function scrollPast(within?: Element) {
for (const { element, callback, observer } of [...registrations]) {
if (within && !within.contains(element)) continue;
callback(
[
{
isIntersecting: false,
intersectionRatio: 0,
target: element,
boundingClientRect: { top: -2, bottom: -1 } as DOMRectReadOnly,
rootBounds: { top: 0, bottom: 799 } as DOMRectReadOnly,
} as IntersectionObserverEntry,
],
observer,
);
}
}
/**
* Report every element currently under observation as held at the foot of the
* viewport by `position: sticky` - its last pixel past a root shrunk by one at
* the bottom, so not wholly in view, and below rather than above. Wrap it in
* `act()`, as `reveal`.
*/
export function pinToBottom() {
for (const { element, callback, observer } of [...registrations]) {
callback(
[
{
isIntersecting: true,
intersectionRatio: 0.98,
target: element,
boundingClientRect: { bottom: 800 } as DOMRectReadOnly,
rootBounds: { bottom: 799 } as DOMRectReadOnly,
} as IntersectionObserverEntry,
],
observer,
);
}
}
/**
* Report every element currently under observation as held at the top of its
* scroll box by `position: sticky` - its first pixel past a root grown by one at
* the top, so not wholly in view, and above rather than below. Wrap it in
* `act()`, as `reveal`.
*/
export function pinToTop() {
for (const { element, callback, observer } of [...registrations]) {
callback(
[
{
isIntersecting: true,
intersectionRatio: 0.98,
target: element,
boundingClientRect: { top: -2, bottom: 40 } as DOMRectReadOnly,
rootBounds: { top: -1, bottom: 800 } as DOMRectReadOnly,
} as IntersectionObserverEntry,
],
observer,
);
}
}