~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
110 lines · 3.7 KB Raw History
// 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;
		}
	}
}