javascript: add code to help with pagination
- ID
1a0e9e7- date
2026-07-19 10:24:16+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
675908a- message
javascript: add code to help with pagination- changed files
Changed files
CHANGELOG.md (6850 → 7003)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6afa6f..0342cd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# CHANGELOG
+## v50 - 2026-07-19
+
+Create a `ChivesPagination` manager and `<chives-pagination-nav>` web component that handles pagination for a collection of items.
+
## v49 - 2026-07-19
Create a `<chives-video>` web component that renders an instance of `VideoEntity` created by `chives.media`.
javascript/chives-image.js (6561 → 6561)
diff --git a/javascript/chives-image.js b/javascript/chives-image.js
index 2fbfda5..6cc6443 100644
--- a/javascript/chives-image.js
+++ b/javascript/chives-image.js
@@ -1,5 +1,5 @@
// javascript/chives-image.js
-// chives version: 49
+// chives version: 50
// ChivesImage provides a <chives-image> component that renders
// an ImageEntity on a web page.
javascript/chives-pagination.js (0 → 4483)
diff --git a/javascript/chives-pagination.js b/javascript/chives-pagination.js
new file mode 100644
index 0000000..44d9ec5
--- /dev/null
+++ b/javascript/chives-pagination.js
@@ -0,0 +1,155 @@
+// javascript/chives-pagination.js
+// chives version: 50
+
+// ChivesPagination manages the pagination of a collection of items.
+//
+// Usage:
+//
+// const pagination = new ChivesPagination(
+// { perPage: 200 }, new URLSearchParams(window.location.search)
+// );
+//
+// const itemsToShow = pagination.apply(items);
+//
+// const paginationState = pagination.getState(items.length);
+//
+// if (paginationState.totalPages > 1) {
+// const paginationElem = document.createElement("chives-pagination-nav");
+// paginationElem.state = paginationState;
+// paginationElem.searchParams = urlParams;
+// document.querySelector("#pagination").appendChild(paginationElem);
+// }
+//
+class ChivesPagination {
+ #perPage;
+ #searchParams;
+
+ // All my sites use "page" as the URL parameter for the current page.
+ #paramKey = "page";
+
+ constructor(config, searchParams) {
+ this.#perPage = config.perPage || 50;
+ this.#searchParams = searchParams;
+ }
+
+ // Returns the current page number (1-indexed).
+ getCurrentPage() {
+ const page = parseInt(this.#searchParams.get(this.#paramKey), 10);
+ return (!isNaN(page) && page > 0) ? page : 1;
+ }
+
+ // Calculates the state of the pagination.
+ getState(totalItems) {
+ const currentPage = this.getCurrentPage();
+ const totalPages = Math.max(1, Math.ceil(totalItems / this.#perPage));
+
+ return {
+ currentPage,
+ totalPages,
+ // TODO: Allow callers to pass a perPage query parameter in
+ // the URL to customise page sizes.
+ perPage: this.#perPage,
+ paramKey: this.#paramKey,
+ }
+ }
+
+ // Extract the slice of items that should be shown on the current page.
+ apply(items) {
+ const { currentPage, perPage } = this.getState(items.length);
+
+ // Page numbers are 1-indexed, so page 1 corresponds to
+ // the indices 0…(perPage - 1).
+ const start = (currentPage - 1) * perPage;
+ const end = currentPage * perPage;
+
+ return items.slice(start, end);
+ }
+}
+
+// ChivesPaginationNav provides a <chives-pagination-nav> component that
+// renders navigation controls for pagination.
+//
+// It uses the state provided by ChivesPagination.
+class ChivesPaginationNav extends HTMLElement {
+ #state;
+ #searchParams;
+
+ set state(state) {
+ if (!state) return;
+ this.#state = state;
+ this.#render();
+ }
+
+ set searchParams(params) {
+ if (!params) return;
+ this.#searchParams = params;
+ this.#render();
+ }
+
+ #render() {
+ if (!this.#state || !this.#searchParams) {
+ return null;
+ }
+
+ const { currentPage, totalPages } = this.#state;
+
+ if (totalPages === 1) {
+ return null;
+ }
+
+ const wrapper = document.createElement("nav");
+ wrapper.classList.add("chives_pagination");
+
+ if (currentPage > 1) {
+ const prevLink = document.createElement("a");
+ prevLink.innerHTML = "← previous";
+ // If you're paginated too far, clamp you to the max page
+ prevLink.href = this.#updatePage(Math.min(currentPage - 1, totalPages));
+ wrapper.appendChild(prevLink);
+ }
+
+ const indicator = document.createElement("span");
+ indicator.classList.add("page_indicator");
+ indicator.innerText = `Page ${currentPage} of ${totalPages}`;
+ wrapper.appendChild(indicator);
+
+ if (currentPage < totalPages) {
+ const nextLink = document.createElement("a");
+ nextLink.innerHTML = "next →";
+ nextLink.href = this.#updatePage(currentPage + 1);
+ wrapper.appendChild(nextLink);
+ }
+
+ this.replaceChildren(wrapper);
+ }
+
+ // updatePage returns a copy of the URL search parameters with the
+ // new value for `page`.
+ #updatePage(page) {
+ let params = new URLSearchParams(this.#searchParams);
+ params.delete(this.#state.paramKey);
+ params.append(this.#state.paramKey, page);
+ return "?" + params.toString();
+ }
+}
+
+// Register global styles for the <chives-pagination-nav> element.
+if (typeof document !== "undefined" &&
+ !document.getElementById("chives-pagination-styles")) {
+ const style = document.createElement("style");
+ style.id = "chives-pagination-styles";
+ style.textContent = `
+ .chives_pagination {
+ a + .page_indicator::before,
+ .page_indicator:has(+ a)::after {
+ content: " · ";
+ }
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+// Register the custom HTML tag <chives-pagination-nav>
+if (!customElements.get("chives-pagination-nav")) {
+ customElements.define("chives-pagination-nav", ChivesPaginationNav);
+}
javascript/chives-video.js (5147 → 5147)
diff --git a/javascript/chives-video.js b/javascript/chives-video.js
index 551d83c..fe6d0ab 100644
--- a/javascript/chives-video.js
+++ b/javascript/chives-video.js
@@ -1,5 +1,5 @@
// javascript/chives-video.js
-// chives version: 49
+// chives version: 50
// ChivesVideo provides a <chives-video> component that renders
// a VideoEntity on a web page.
python/chives/__init__.py (391 → 391)
diff --git a/python/chives/__init__.py b/python/chives/__init__.py
index 01fdb9c..deaf60f 100644
--- a/python/chives/__init__.py
+++ b/python/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
"""
-__version__ = "49"
+__version__ = "50"
tests/javascript/test_chives_pagination.py (0 → 3433)
diff --git a/tests/javascript/test_chives_pagination.py b/tests/javascript/test_chives_pagination.py
new file mode 100644
index 0000000..0fe73e0
--- /dev/null
+++ b/tests/javascript/test_chives_pagination.py
@@ -0,0 +1,117 @@
+"""
+Tests for the <chives-pagination-nav> web component.
+"""
+
+import json
+from typing import Any
+
+from playwright.sync_api import expect, Page
+import pytest
+
+
+def apply_pagination(
+ page: Page, *, config: dict[str, Any], url_query: str, items: Any
+) -> None:
+ """
+ Render a list of items and the pagination component on the page.
+ """
+ page.goto("about:blank")
+
+ with open("javascript/chives-pagination.js") as f:
+ component_js = f.read()
+
+ page.evaluate(
+ """
+ %s
+ const config = %s;
+ const params = new URLSearchParams(%s);
+ const pagination = new ChivesPagination(config, params);
+
+ const items = %s;
+ const itemsToShow = pagination.apply(items);
+
+ const listOfItems = document.createElement("p");
+ listOfItems.innerText = JSON.stringify(itemsToShow);
+ document.body.appendChild(listOfItems);
+
+ const paginationElem = document.createElement("chives-pagination-nav");
+ paginationElem.state = pagination.getState(items.length);
+ paginationElem.searchParams = params;
+ document.body.appendChild(paginationElem);
+ """
+ % (component_js, json.dumps(config), json.dumps(url_query), json.dumps(items))
+ )
+
+
+@pytest.mark.parametrize(
+ "url_query, expected_items, expected_nav",
+ [
+ (
+ "?page=1",
+ "[1,2,3,4,5]",
+ '<nav class="chives_pagination">'
+ '<span class="page_indicator">Page 1 of 3</span>'
+ '<a href="?page=2">next →</a>'
+ "</nav>",
+ ),
+ (
+ "?page=2",
+ "[6,7,8,9,10]",
+ '<nav class="chives_pagination">'
+ '<a href="?page=1">← previous</a>'
+ '<span class="page_indicator">Page 2 of 3</span>'
+ '<a href="?page=3">next →</a>'
+ "</nav>",
+ ),
+ (
+ "?page=3",
+ "[11,12,13,14,15]",
+ '<nav class="chives_pagination">'
+ '<a href="?page=2">← previous</a>'
+ '<span class="page_indicator">Page 3 of 3</span>'
+ "</nav>",
+ ),
+ (
+ "?page=10",
+ "[]",
+ '<nav class="chives_pagination">'
+ '<a href="?page=3">← previous</a>'
+ '<span class="page_indicator">Page 10 of 3</span>'
+ "</nav>",
+ ),
+ ],
+)
+def test_render_basic_pagination(
+ page: Page, url_query: str, expected_items: str, expected_nav: str
+) -> None:
+ """
+ The <chives-pagination> component renders basic pagination controls.
+ """
+ apply_pagination(
+ page,
+ config={"perPage": 5},
+ url_query=url_query,
+ items=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
+ )
+
+ items_elem = page.locator("p")
+ assert items_elem.inner_text() == expected_items
+
+ pagination_elem = page.locator("chives-pagination-nav")
+ expect(pagination_elem).to_be_visible()
+ assert pagination_elem.inner_html() == expected_nav
+
+
+def test_no_pagination_element_if_single_page(page: Page) -> None:
+ """
+ If there's only a single page, no navigation controls are shown.
+ """
+ apply_pagination(
+ page,
+ config={"perPage": 100},
+ url_query="",
+ items=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
+ )
+
+ pagination_elem = page.locator("chives-pagination-nav")
+ expect(pagination_elem.locator("chives_pagination")).not_to_be_visible()