johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// A list a page shows a page at a time, each page after the first added
// below those before it rather than swapped in for them.
import { goto, preloadData } from '$app/navigation';
import { page } from '$app/state';
import { tick } from 'svelte';
/** Part of a list, and the cursor to the part after it, if any. */
export type Page<T> = { items: T[]; cursor?: string };
/** The pages added below a list's first, and where the page was scrolled to. */
export type Kept<T> = Page<T> & { y: number };
/** The pages of a list shown so far, and the way to the next. */
export type Paged<T> = {
readonly items: T[];
readonly href?: string;
readonly busy: boolean;
next(): void;
};
/**
* The list a route's data `D` holds, as `pick` finds it there: the page
* the route loaded, with each page asked for since added below it. The
* next page is the route's own, at its address with the last cursor, so a
* link to it opens that page on its own too. Another load of the route,
* a filter changed say, starts over. Some cursors count from the top, and
* an item made since moves the rest down one, so an item already shown,
* by its `key` where it has one, is not shown again.
*/
export class Pages<D, T> {
#data: () => D;
#pick: (data: D) => Page<T>;
#key?: (item: T) => string;
/** The pages added, below the loaded one at `after`. */
#more = $state.raw<{ after: T[]; items: T[]; cursor?: string } | null>(null);
busy = $state(false);
constructor(data: () => D, pick: (data: D) => Page<T>, key?: (item: T) => string) {
this.#data = data;
this.#pick = pick;
this.#key = key;
}
get #first(): Page<T> {
return this.#pick(this.#data());
}
/** The pages added below the one loaded now, none once another is. */
get #added() {
return this.#more?.after === this.#first.items ? this.#more : null;
}
get items(): T[] {
const added = this.#added;
return added ? [...this.#first.items, ...added.items] : this.#first.items;
}
/** Where the next page is, if there is one. */
get href(): string | undefined {
const cursor = this.#added ? this.#added.cursor : this.#first.cursor;
if (!cursor) return undefined;
const q = new URLSearchParams(page.url.search);
q.set('cursor', cursor);
return `${page.url.pathname}?${q}`;
}
/** The pages added below the loaded one, to keep with the history entry. */
capture(): Kept<T> | null {
const added = this.#added;
return added ? { items: added.items, cursor: added.cursor, y: scrollY } : null;
}
/**
* The pages `capture` kept, added again below the page loaded: going
* back to a list shows all it showed before, at the place it was left.
* SvelteKit scrolls back before it restores, while the page is only as
* long as its first page, so it is scrolled again once they are drawn.
*/
async restore(kept: Kept<T> | null) {
if (!kept) return;
this.#more = { after: this.#first.items, items: kept.items, cursor: kept.cursor };
await tick();
scrollTo(scrollX, kept.y);
}
/** Adds the next page below the rest, or opens it, when it loads as something else. */
async next() {
const href = this.href;
if (!href || this.busy) return;
const after = this.#first.items;
this.busy = true;
try {
const loaded = await preloadData(href);
if (loaded.type !== 'loaded' || loaded.status !== 200) return goto(href);
// Left for another load of the route while it loaded.
if (this.#first.items !== after) return;
const page = this.#pick(loaded.data as D);
const key = this.#key;
const shown = new Set(key ? this.items.map(key) : []);
const items = [
...(this.#added?.items ?? []),
...(key ? page.items.filter((i) => !shown.has(key(i))) : page.items)
];
this.#more = { after, items, cursor: page.cursor };
} finally {
this.busy = false;
}
}
}