aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/pager.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/pager.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/pager.js89
1 files changed, 89 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/pager.js b/packages/meshbay-hub/src/meshbay_hub/static/pager.js
new file mode 100644
index 0000000..a5c165b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/pager.js
@@ -0,0 +1,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>
+ `;
+}