Skip to main content

javascript: add code to help with sorting

ID
cd8d40e
date
2026-07-19 19:12:08+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
1a0e9e7
message
javascript: add code to help with sorting
changed files
8 files, 394 additions, 12 deletions

Changed files

CHANGELOG.md (7003 → 7139)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0342cd1..f319083 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,12 @@
 # CHANGELOG
 
+## v51 - 2026-07-19
+
+Add a `ChiveSorting` class and `<chives-sort-controls>` web component that handle sorting for a collection of items.
+
 ## v50 - 2026-07-19
 
-Create a `ChivesPagination` manager and `<chives-pagination-nav>` web component that handles pagination for a collection of items.
+Create a `ChivesPagination` class and `<chives-pagination-nav>` web component that handle pagination for a collection of items.
 
 ## v49 - 2026-07-19
 

javascript/chives-image.js (6561 → 6561)

diff --git a/javascript/chives-image.js b/javascript/chives-image.js
index 6cc6443..00f2070 100644
--- a/javascript/chives-image.js
+++ b/javascript/chives-image.js
@@ -1,5 +1,5 @@
 // javascript/chives-image.js
-// chives version: 50
+// chives version: 51
 
 // ChivesImage provides a <chives-image> component that renders
 // an ImageEntity on a web page.

javascript/chives-pagination.js (4483 → 4929)

diff --git a/javascript/chives-pagination.js b/javascript/chives-pagination.js
index 44d9ec5..9f57a8b 100644
--- a/javascript/chives-pagination.js
+++ b/javascript/chives-pagination.js
@@ -1,5 +1,5 @@
 // javascript/chives-pagination.js
-// chives version: 50
+// chives version: 51
 
 // ChivesPagination manages the pagination of a collection of items.
 //
@@ -38,7 +38,7 @@ class ChivesPagination {
     return (!isNaN(page) && page > 0) ? page : 1;
   }
 
-  // Calculates the state of the pagination.
+  // Returns the state of the pagination.
   getState(totalItems) {
     const currentPage = this.getCurrentPage();
     const totalPages = Math.max(1, Math.ceil(totalItems / this.#perPage));
@@ -153,3 +153,16 @@ if (typeof document !== "undefined" &&
 if (!customElements.get("chives-pagination-nav")) {
   customElements.define("chives-pagination-nav", ChivesPaginationNav);
 }
+
+// renderPaginationNav renders the pagination navigation as
+// a child of `querySelector`.
+function renderPaginationNav({ querySelector, pagination, searchParams, totalItems }) {
+  state = pagination.getState(totalItems);
+
+  if (state.totalPages > 1) {
+    const elem = document.createElement("chives-pagination-nav");
+    elem.state = state;
+    elem.searchParams = searchParams;
+    document.querySelector(querySelector).appendChild(elem);
+  }
+}

javascript/chives-sorting.js (0 → 5034)

diff --git a/javascript/chives-sorting.js b/javascript/chives-sorting.js
new file mode 100644
index 0000000..4f4a035
--- /dev/null
+++ b/javascript/chives-sorting.js
@@ -0,0 +1,177 @@
+// javascript/chives-sorting.js
+// chives version: 51
+
+// ChivesSorting manages the sorting of a collection of items.
+//
+// It requires a list of sort options. A sort option is an object with
+// three fields:
+//
+//    - key: the query parameter key in the URL (for example, 'titleAtoZ')
+//    - label: the human-readable description (for example, 'title (A to Z)')
+//    - compareFn: a predicate function that determines the order of the items
+//
+// For example:
+//
+//    const bookmarkSortOptions = [
+//      {
+//        key: "titleAtoZ",
+//        label: "title (A to Z)",
+//        filterFn: (a, b) => a.title > b.title ? 1 : -1,
+//      }
+//    ];
+//
+class ChivesSorting {
+  #sortOptions;
+  #searchParams;
+
+  #defaultSortOrder;
+
+  // All my sites use "sortOrder" as the URL parameter for the currently
+  // selected sort order.
+  #paramKey = "sortOrder";
+
+  constructor(sortOptions, searchParams) {
+    this.#sortOptions = sortOptions;
+    this.#searchParams = searchParams;
+  }
+
+  // Set the default sort key that will be used if the user has not
+  // specified a sort key in the URL query parameters.
+  set defaultSortOrder(sortKey) {
+    if (!sortKey) return;
+    this.#defaultSortOrder = sortKey;
+  }
+
+  // getActiveSortOrder returns the currently active sort order.
+  getActiveSortOrder() {
+    let sortOrderKey = this.#searchParams.get(this.#paramKey);
+
+    if (sortOrderKey === null && typeof this.#defaultSortOrder === 'string') {
+      sortOrderKey = this.#defaultSortOrder;
+    }
+
+    if (sortOrderKey !== null) {
+      const matchingSort = this.#sortOptions.find(s => s.key === sortOrderKey);
+      if (matchingSort) return matchingSort;
+    }
+
+    return this.#sortOptions[0];
+  }
+
+  // Sort the collection of items using the currently selected sort order.
+  apply(items, defaultSortKey) {
+    const activeSortOrder = this.getActiveSortOrder();
+
+    console.debug(`Active sort order: ${activeSortOrder.key}`);
+
+    return items.sort(activeSortOrder.compareFn);
+  }
+
+  // Returns the state of the sorting.
+  getState() {
+    return {
+      activeSortOrderKey: this.getActiveSortOrder().key,
+      sortOptions: this.#sortOptions,
+      paramKey: this.#paramKey,
+    }
+  }
+}
+
+// ChivesSortOptions provides a <chives-sort-options> component that
+// renders navigation controls for sort order.
+//
+// It uses the state provided by ChivesSorting.
+class ChivesSortOptions 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 wrapper = document.createElement("nav");
+    wrapper.classList.add("chives_sort_options");
+
+    const label = document.createElement("label");
+    label.setAttribute("for", "sortOrder");
+    label.innerHTML = "sort by:"
+    wrapper.appendChild(label);
+
+    const select = document.createElement("select");
+    select.id = label.getAttribute("for");
+    this.#state.sortOptions.forEach(so => {
+      const option = document.createElement("option");
+      option.value = so.key;
+      option.innerText = so.label;
+      if (so.key === this.#state.activeSortOrderKey) {
+        option.setAttribute("selected", "");
+      } else {
+        option.removeAttribute("selected");
+      }
+      select.appendChild(option);
+    })
+
+    // When the user uses the dropdown to select a new sort order,
+    // reload the page with the new sort order applied.
+    select.addEventListener("change", () => {
+      const newSortOrderKey = select.value;
+
+      console.log(`newSortOrderKey = ${newSortOrderKey}`);
+
+      if (newSortOrderKey === this.#state.activeSortOrderKey) {
+        return;
+      }
+
+      const params = new URLSearchParams(this.#searchParams);
+      params.set(this.#state.paramKey, newSortOrderKey);
+      params.delete("page");
+      window.location.search = params.toString();
+    })
+
+    wrapper.appendChild(select);
+
+    this.replaceChildren(wrapper);
+  }
+}
+
+// Register global styles for the <chives-sorting-options> element.
+if (typeof document !== "undefined" &&
+    !document.getElementById("chives-sorting-styles")) {
+  const style = document.createElement("style");
+  style.id = "chives-sorting-styles";
+  style.textContent = `
+    .chives_sort_options {
+      label::after {
+        content: " ";
+      }
+    }
+  `;
+  document.head.appendChild(style);
+}
+
+// Register the custom HTML tag <chives-sort-options>
+if (!customElements.get("chives-sort-options")) {
+  customElements.define("chives-sort-options", ChivesSortOptions);
+}
+
+// renderSortingControls renders the sorting controls as
+// a child of `querySelector`.
+function renderSortingControls({ querySelector, sorting, searchParams }) {
+  const elem = document.createElement("chives-sort-options");
+  elem.state = sorting.getState();
+  elem.searchParams = searchParams;
+  document.querySelector(querySelector).appendChild(elem);
+}

javascript/chives-video.js (5147 → 5147)

diff --git a/javascript/chives-video.js b/javascript/chives-video.js
index fe6d0ab..a404553 100644
--- a/javascript/chives-video.js
+++ b/javascript/chives-video.js
@@ -1,5 +1,5 @@
 // javascript/chives-video.js
-// chives version: 50
+// chives version: 51
 
 // 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 deaf60f..deaf8ed 100644
--- a/python/chives/__init__.py
+++ b/python/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "50"
+__version__ = "51"

tests/javascript/test_chives_pagination.py (3433 → 3369)

diff --git a/tests/javascript/test_chives_pagination.py b/tests/javascript/test_chives_pagination.py
index 0fe73e0..1a288fa 100644
--- a/tests/javascript/test_chives_pagination.py
+++ b/tests/javascript/test_chives_pagination.py
@@ -24,8 +24,8 @@ def apply_pagination(
         """
         %s
         const config = %s;
-        const params = new URLSearchParams(%s);
-        const pagination = new ChivesPagination(config, params);
+        const searchParams = new URLSearchParams(%s);
+        const pagination = new ChivesPagination(config, searchParams);
 
         const items = %s;
         const itemsToShow = pagination.apply(items);
@@ -34,10 +34,12 @@ def apply_pagination(
         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);
+        renderPaginationNav({
+            "querySelector": "body",
+            totalItems: items.length,
+            pagination,
+            searchParams,
+        });
         """
         % (component_js, json.dumps(config), json.dumps(url_query), json.dumps(items))
     )

tests/javascript/test_chives_sorting.py (0 → 5098)

diff --git a/tests/javascript/test_chives_sorting.py b/tests/javascript/test_chives_sorting.py
new file mode 100644
index 0000000..bb7acf7
--- /dev/null
+++ b/tests/javascript/test_chives_sorting.py
@@ -0,0 +1,186 @@
+"""
+Tests for ChivesSorting and the <chives-sort-options> web component.
+"""
+
+import json
+from typing import Any
+
+from playwright.sync_api import expect, Page
+import pytest
+
+from chives.browser_fixtures import file_uri
+
+
+def apply_sorting(
+    page: Page,
+    *,
+    sorting_config_json: str,
+    url_query: str,
+    default_sort_order: str,
+    items: Any,
+) -> None:
+    """
+    Sort a list of items and the sorting controls on the page.
+    """
+    page.goto("about:blank")
+
+    with open("javascript/chives-sorting.js") as f:
+        component_js = f.read()
+
+    page.evaluate(
+        """
+        %s
+        const sortOptions = %s;
+        const searchParams = new URLSearchParams(%s);
+        const sorting = new ChivesSorting(sortOptions, searchParams);
+
+        const defaultSortOrder = %s;
+        if (defaultSortOrder) {
+            sorting.defaultSortOrder = defaultSortOrder;
+        }
+
+        const items = %s;
+        const sortedItems = sorting.apply(items);
+
+        const listOfItems = document.createElement("p");
+        listOfItems.innerText = JSON.stringify(sortedItems);
+        document.body.appendChild(listOfItems);
+
+        renderSortingControls({
+            "querySelector": "body",
+            sorting,
+            searchParams,
+        });
+        """
+        % (
+            component_js,
+            sorting_config_json.strip(),
+            json.dumps(url_query),
+            json.dumps(default_sort_order),
+            json.dumps(items),
+        )
+    )
+
+
+@pytest.mark.parametrize(
+    "url_query, expected_items, default_sort_order, expected_sort_order_key",
+    [
+        ("", '["apple","banana","coconut","date","elderberry"]', "", "AtoZ"),
+        (
+            "sortOrder=AtoZ",
+            '["apple","banana","coconut","date","elderberry"]',
+            "",
+            "AtoZ",
+        ),
+        (
+            "sortOrder=ZtoA",
+            '["elderberry","date","coconut","banana","apple"]',
+            "",
+            "ZtoA",
+        ),
+        # Default sort order is used if URL params don't specify
+        ("", '["elderberry","date","coconut","banana","apple"]', "ZtoA", "ZtoA"),
+        (
+            "sortOrder=length",
+            '["date","apple","banana","coconut","elderberry"]',
+            "ZtoA",
+            "length",
+        ),
+        # Unrecognised sort order => falls back to the first in the list
+        (
+            "sortOrder=unknown",
+            '["apple","banana","coconut","date","elderberry"]',
+            "",
+            "AtoZ",
+        ),
+    ],
+)
+def test_render_basic_sorting(
+    page: Page,
+    url_query: str,
+    expected_items: str,
+    default_sort_order: str,
+    expected_sort_order_key: str,
+) -> None:
+    """
+    Test basic sorting order and controls.
+    """
+    apply_sorting(
+        page,
+        sorting_config_json="""
+            [
+                {
+                    key: "AtoZ",
+                    label: "title (A to Z)",
+                    compareFn: (a, b) => a > b ? 1 : -1,
+                },
+                {
+                    key: "ZtoA",
+                    label: "title (Z to A)",
+                    compareFn: (a, b) => a > b ? -1 : 1,
+                },
+                {
+                    key: "length",
+                    label: "length",
+                    compareFn: (a, b) => a.length - b.length,
+                },
+            ]
+        """,
+        url_query=url_query,
+        default_sort_order=default_sort_order,
+        items=["apple", "banana", "coconut", "date", "elderberry"],
+    )
+
+    items_elem = page.locator("p")
+    assert items_elem.inner_text() == expected_items
+
+    sorting_elem = page.locator("chives-sort-options")
+    expect(sorting_elem).to_be_visible()
+    assert (
+        sorting_elem.locator("option[selected]").get_attribute("value")
+        == expected_sort_order_key
+    )
+
+
+def test_selecting_a_sort_order_resets_page_number(page: Page) -> None:
+    """
+    Selecting a sort order resets the page number.
+    """
+    uri = (
+        file_uri("tests/fixtures/html/greeting.html")
+        + "?sortOrder=ZtoA&page=2&tag=cats"
+    )
+    page.goto(uri)
+
+    with open("javascript/chives-sorting.js") as f:
+        component_js = f.read()
+
+    page.evaluate(
+        """
+        %s
+        const sortOptions = [
+            {key: "AtoZ", label: "title (A to Z)", compareFn: (a, b) => 1},
+            {key: "ZtoA", label: "title (Z to A)", compareFn: (a, b) => 1},
+        ];
+        const searchParams = new URLSearchParams(window.location.search);
+        const sorting = new ChivesSorting(sortOptions, searchParams);
+
+        const items = ["apple", "banana", "coconut", "date", "elderberry"];
+        const sortedItems = sorting.apply(items);
+
+        renderSortingControls({
+            "querySelector": "body",
+            sorting,
+            searchParams,
+        });
+        """
+        % component_js
+    )
+
+    assert page.locator("option").count() == 2
+
+    with page.expect_navigation():
+        page.get_by_label("sort by:").select_option("title (A to Z)")
+
+    assert "sortOrder=AtoZ" in page.url
+    assert "page=" not in page.url