aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/pager.js
blob: a5c165bb7c65c6e5585ba1ecff45507d819d95c7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import {
  html, useState, useRef, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';

// ── Pages of cards ───────────────────────────────────────────────────────────
//
// Videos and Music draw one card per film, show or album, and a library of a
// few thousand is a very long scroll. They show a slice at a time instead,
// with arrows in their pinned toolbar. The slice is cut from the list exactly
// as it would have been drawn, so paging never reorders anything.
//
// The size is the reader's own preference, kept on the hub with the others
// (Settings → Defaults). `PAGE_SIZE_PREF` must be in the hub's
// `ALLOWED_PREF_KEYS`, or saving it is a 400 nobody sees.

export const PAGE_SIZE_PREF = 'media_page_size';
export const PAGE_SIZE_DEFAULT = 50;
export const PAGE_SIZE_STEP = 10;
export const PAGE_SIZE_MAX = 200;

// Anything that is not a multiple of the step within range reads as the
// default: the value is a string the hub stores without looking at it.
export function pageSizeFrom(userPrefs) {
  const n = Number(userPrefs && userPrefs[PAGE_SIZE_PREF]);
  if (!Number.isInteger(n) || n < PAGE_SIZE_STEP || n > PAGE_SIZE_MAX || n % PAGE_SIZE_STEP) {
    return PAGE_SIZE_DEFAULT;
  }
  return n;
}

// Clamped rather than reset when the list shrinks under the reader — Search
// loses a group's cards while it reconnects — so the page comes back once
// they do.
export function pageBounds(total, size, page) {
  const last = Math.max(0, Math.ceil(total / size) - 1);
  const current = Math.min(Math.max(0, page), last);
  return { page: current, last, start: current * size, end: Math.min(total, (current + 1) * size) };
}

// `resetKey` names what the list is (group, mode, filter, query): a different
// list starts on its first page. Compared during render rather than reset by an
// effect, which would draw the old page of the new list for one frame.
export function usePager(total, size, resetKey) {
  const key = `${resetKey}|${size}`;
  const [state, setState] = useState({ key, page: 0 });
  const bounds = pageBounds(total, size, state.key === key ? state.page : 0);
  const setPage = useCallback((page) => {
    setState({ key, page });
  }, [key]);
  return { ...bounds, total, size, setPage };
}

// A new page starts at the top of the list, not wherever the previous one was
// scrolled to. Only ever upwards, and only as far as the list's first row
// sitting under the pinned toolbar, so a reader who has not scrolled is not
// moved. The toolbar is the pager's parent and the list its next sibling —
// the structure style.css's "Sticky chrome" already requires.
function keepListInView(el) {
  const toolbar = el && el.parentElement;
  const list = toolbar && toolbar.nextElementSibling;
  if (!list) return;
  const gap = parseFloat(getComputedStyle(toolbar).marginBottom) || 0;
  const target = window.scrollY + list.getBoundingClientRect().top
    - toolbar.getBoundingClientRect().bottom - gap;
  if (target < window.scrollY) window.scrollTo(0, Math.max(0, target));
}

export function Pager({ pager }) {
  const ref = useRef(null);
  if (pager.total <= pager.size) return null;
  const go = (page) => { keepListInView(ref.current); pager.setPage(page); };
  return html`
    <div class="tb-pager" ref=${ref}>
      <button class="tb-btn tb-btn-icon" disabled=${pager.page === 0}
        title=${t('pager.previous')} aria-label=${t('pager.previous')}
        onClick=${() => go(pager.page - 1)}>
        <${Icon} name="chevron-left" /></button>
      <span class="tb-pager-range">
        ${t('pager.range', { from: pager.start + 1, to: pager.end, total: pager.total })}
      </span>
      <button class="tb-btn tb-btn-icon" disabled=${pager.page === pager.last}
        title=${t('pager.next')} aria-label=${t('pager.next')}
        onClick=${() => go(pager.page + 1)}>
        <${Icon} name="chevron-right" /></button>
    </div>
  `;
}