char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somkeep commit pagination height while loading7bca99b

main
3.5 KiB111 linesraw
1export const PAGE_SIZE = 10;
2
3/**
4 * prev/next pagination over a deduplicated async item stream. fetched pages
5 * are cached; a failed fetch rolls its items back and reopens the stream so
6 * "retry" starts clean. exhaustion is detected lazily: "next" at the very
7 * end no-ops once (instantly, the stream is already drained) then disables.
8 */
9export function paginate<T>(opts: {
10  /** items are appended here as they arrive */
11  list: HTMLElement;
12  /** receives the prev/next buttons */
13  control: HTMLElement;
14  /** fresh iterator; re-invoked to restart after an error */
15  open: () => AsyncIterator<T>;
16  /** stable id for cross-page dedup */
17  key: (item: T) => string;
18  render: (item: T) => HTMLElement;
19  /** pre-rendered page 0 (e.g. the static log) */
20  seed?: { items: HTMLElement[]; keys: string[] };
21  onShow?: (count: number) => void;
22  onError?: (err: unknown) => void;
23}): void {
24  const { list, control, open, key, render } = opts;
25  list.classList.add("paginated");
26  const shown = new Set(opts.seed?.keys);
27  const pages: HTMLElement[][] = opts.seed ? [opts.seed.items] : [];
28  let current = pages.length - 1;
29  let exhausted = false;
30  let failed = false;
31  let source = open();
32
33  const nextUnseen = async (): Promise<T | null> => {
34    while (true) {
35      const next = await source.next();
36      if (next.done) return null;
37      if (!shown.has(key(next.value))) return next.value;
38    }
39  };
40
41  const showPage = () => {
42    if (current >= 0) list.replaceChildren(...pages[current]);
43    previous.disabled = current <= 0;
44    const atEnd = exhausted && current === pages.length - 1;
45    next.disabled = atEnd;
46    list.classList.toggle("exhausted", atEnd);
47    next.textContent = "next →";
48    control.removeAttribute("title");
49    if (current >= 0) opts.onShow?.(pages[current].length);
50  };
51
52  async function goNext() {
53    failed = false;
54    if (current + 1 < pages.length) {
55      current++;
56      showPage();
57      return;
58    }
59    previous.disabled = true;
60    next.disabled = true;
61    next.textContent = `(0 / ${PAGE_SIZE})`;
62    list.replaceChildren();
63    const page: HTMLElement[] = [];
64    const pageKeys: string[] = [];
65    try {
66      while (page.length < PAGE_SIZE) {
67        const item = await nextUnseen();
68        if (!item) {
69          exhausted = true;
70          break;
71        }
72        shown.add(key(item));
73        pageKeys.push(key(item));
74        const element = render(item);
75        page.push(element);
76        list.append(element);
77        next.textContent = `(${page.length} / ${PAGE_SIZE})`;
78      }
79      if (page.length > 0) {
80        pages.push(page);
81        current++;
82      }
83      showPage();
84    } catch (err) {
85      for (const k of pageKeys) shown.delete(k);
86      source = open();
87      failed = true;
88      previous.disabled = current <= 0;
89      next.disabled = false;
90      next.textContent = "retry →";
91      if (opts.onError) opts.onError(err);
92      else control.title = String(err);
93    }
94  }
95
96  const previous = (
97    <button type="button" class="log-page-button prev" _onclick={() => {
98      // from the failed/retry state, prev re-shows the current page: the
99      // failed page was never pushed, so current already points at it
100      if (!failed) current--;
101      failed = false;
102      showPage();
103    }}> prev</button>
104  ) as HTMLButtonElement;
105  const next = (
106    <button type="button" class="log-page-button next" _onclick={goNext}>next </button>
107  ) as HTMLButtonElement;
108  control.replaceChildren(previous, next);
109  if (opts.seed) showPage();
110  else void goNext();
111}