Initialize widget JS via onSwap helper

Port FastHTML's proc_htmx as onSwap(selector, initializeElement) in
utils.js, built on htmx.onLoad: it runs an initializer once per matching
element, on initial page load and inside every htmx-swapped fragment.

Migrate search_select.js, range_slider.js, filter_bar.js and
add_purchase.js to it, removing the hand-rolled DOMContentLoaded +
htmx:afterSwap listeners and per-element guard flags. This also fixes a
latent bug: both events passed the Event object as range_slider's
"force" parameter, so every htmx swap force-re-initialized all sliders
and stacked duplicate listeners. The collapse button's
window.initRangeSliders() call was a no-op (handles are positioned in
percentages, so hidden-init is safe) and is removed with the global.

Add e2e/test_widgets_e2e.py covering the onSwap lifecycle (initial-load
init, htmx-swap init, single-fire toggles) plus FilterSelect pills and
the add-purchase type toggle. The synthetic page in
test_search_select_e2e.py now loads htmx and search_select.js as a
module, matching the new initialization path.

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
Claude
2026-06-12 21:18:36 +00:00
parent 88cf374f33
commit b68a131bae
9 changed files with 440 additions and 278 deletions
+5 -12
View File
@@ -12,8 +12,8 @@
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar.
*
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
* element._searchSelectInit.
* Widgets are initialized via onSwap() (utils.js), which covers the initial
* page load and every htmx-swapped fragment, once per widget.
*
* Dynamically-added rows and pills are cloned from hidden <template> elements
* the server renders with the same Python components (Pill / SearchSelect /
@@ -21,6 +21,8 @@
* and data-* attributes — so all markup and Tailwind class strings live in one
* place (the Python components), never duplicated here.
*/
import { onSwap } from "./utils.js";
(() => {
"use strict";
@@ -32,14 +34,6 @@
// INCLUDES_ONLY) coexist with value pills.
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
const initAll = () => {
document.querySelectorAll("[data-search-select]").forEach(element => {
if (element._searchSelectInit) return;
element._searchSelectInit = true;
initWidget(element);
});
};
const initWidget = (container) => {
const search = container.querySelector("[data-search-select-search]");
const options = container.querySelector("[data-search-select-options]");
@@ -666,6 +660,5 @@
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
onSwap("[data-search-select]", initWidget);
})();