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>
`;
}
|