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
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