// 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 = { items: T[]; cursor?: string }; /** The pages added below a list's first, and where the page was scrolled to. */ export type Kept = Page & { y: number }; /** The pages of a list shown so far, and the way to the next. */ export type Paged = { 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 { #data: () => D; #pick: (data: D) => Page; #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, key?: (item: T) => string) { this.#data = data; this.#pick = pick; this.#key = key; } get #first(): Page { 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 | 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 | 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; } } }