Skip to main content

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
6 files, 279 additions, 3 deletions

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 = "&larr; 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 &rarr;";
+      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()