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 -4
View File
@@ -1,4 +1,4 @@
import { getEl, disableElementsWhenTrue } from "./utils.js";
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
@@ -38,8 +38,9 @@ function setupElementHandlers() {
]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
onSwap("#id_type", (typeSelect) => {
setupElementHandlers();
typeSelect.addEventListener("change", () => {
setupElementHandlers();
});
});
+42 -42
View File
@@ -4,6 +4,8 @@
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX — plain fetch() and window.location for all interactions.
*/
import { onSwap } from "./utils.js";
(function () {
"use strict";
@@ -410,27 +412,25 @@
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
// ── Inject the search input into a filter form ──
function injectSearchInput(form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
}
/**
@@ -438,25 +438,25 @@
*/
function setupDeselectableRadios() {
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
radio.addEventListener('click', function (e) {
if (this.wasChecked) {
this.checked = false;
this.wasChecked = false;
this.dispatchEvent(new Event('change', { bubbles: true }));
} else {
var name = this.getAttribute('name');
if (name) {
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
r.wasChecked = false;
});
}
this.wasChecked = true;
radio.addEventListener('click', function (e) {
if (this.wasChecked) {
this.checked = false;
this.wasChecked = false;
this.dispatchEvent(new Event('change', { bubbles: true }));
} else {
var name = this.getAttribute('name');
if (name) {
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
r.wasChecked = false;
});
}
});
if (radio.checked) {
radio.wasChecked = true;
this.wasChecked = true;
}
});
if (radio.checked) {
radio.wasChecked = true;
}
});
}
/**
@@ -464,14 +464,14 @@
*/
function setupStringFilters() {
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
radio.addEventListener('change', function () {
window.toggleStringFilterInput(this);
});
radio.addEventListener('change', function () {
window.toggleStringFilterInput(this);
});
});
}
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
onSwap('[id^="filter-bar-form"]', function (form) {
injectSearchInput(form);
setupDeselectableRadios();
setupStringFilters();
loadPresets();
+195 -201
View File
@@ -8,229 +8,223 @@
* Handles track-fill positioning and sync between handles and the connected
* number inputs (linked via data-target attributes).
*/
import { onSwap } from "./utils.js";
(function () {
"use strict";
function initAll(force) {
document.querySelectorAll(".range-slider").forEach(function (slider) {
if (force) slider._rsInit = false;
if (slider._rsInit) return;
slider._rsInit = true;
function initializeSlider(slider) {
var mode = slider.getAttribute("data-mode") || "range";
var trackFill = slider.querySelector(".range-track-fill");
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
if (!minHandle || !maxHandle) return;
var mode = slider.getAttribute("data-mode") || "range";
var trackFill = slider.querySelector(".range-track-fill");
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
if (!minHandle || !maxHandle) return;
var minTarget = document.getElementById(
minHandle.getAttribute("data-target")
);
var maxTarget = document.getElementById(
maxHandle.getAttribute("data-target")
);
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
var minTarget = document.getElementById(
minHandle.getAttribute("data-target")
);
var maxTarget = document.getElementById(
maxHandle.getAttribute("data-target")
);
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
// ── Helpers ──
// ── Helpers ──
function valueToPercent(value) {
return ((value - dataMin) / (dataMax - dataMin)) * 100;
}
function percentToValue(percent) {
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
return Math.round(raw / step) * step;
}
function clamp(value, lo, hi) {
return Math.max(lo, Math.min(hi, value));
}
function valueToPercent(value) {
return ((value - dataMin) / (dataMax - dataMin)) * 100;
}
function percentToValue(percent) {
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
return Math.round(raw / step) * step;
}
function clamp(value, lo, hi) {
return Math.max(lo, Math.min(hi, value));
}
function getTargetValue(target, defaultVal) {
if (!target || target.value === "") return defaultVal;
var parsed = parseInt(target.value, 10);
return isNaN(parsed) ? defaultVal : parsed;
}
function setTargetValue(target, value) {
if (target) target.value = value;
}
function getTargetValue(target, defaultVal) {
if (!target || target.value === "") return defaultVal;
var parsed = parseInt(target.value, 10);
return isNaN(parsed) ? defaultVal : parsed;
}
function setTargetValue(target, value) {
if (target) target.value = value;
}
// ── Track fill positioning ──
// ── Track fill positioning ──
function updateTrackFill() {
if (!trackFill) return;
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
if (mode === "point") {
trackFill.style.left = "0%";
trackFill.style.width = valueToPercent(maxVal) + "%";
} else {
var leftPct = valueToPercent(minVal);
var rightPct = valueToPercent(maxVal);
if (leftPct > rightPct) {
var tmp = leftPct;
leftPct = rightPct;
rightPct = tmp;
}
var widthPct = rightPct - leftPct;
trackFill.style.left = leftPct + "%";
trackFill.style.width = widthPct + "%";
function updateTrackFill() {
if (!trackFill) return;
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
if (mode === "point") {
trackFill.style.left = "0%";
trackFill.style.width = valueToPercent(maxVal) + "%";
} else {
var leftPct = valueToPercent(minVal);
var rightPct = valueToPercent(maxVal);
if (leftPct > rightPct) {
var tmp = leftPct;
leftPct = rightPct;
rightPct = tmp;
}
var widthPct = rightPct - leftPct;
trackFill.style.left = leftPct + "%";
trackFill.style.width = widthPct + "%";
}
}
function updateHandles() {
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
minHandle.style.left = valueToPercent(minVal) + "%";
maxHandle.style.left = valueToPercent(maxVal) + "%";
updateTrackFill();
}
function updateHandles() {
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
minHandle.style.left = valueToPercent(minVal) + "%";
maxHandle.style.left = valueToPercent(maxVal) + "%";
updateTrackFill();
}
// ── Dragging ──
// ── Dragging ──
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var value = percentToValue(clamp(pct, 0, 100));
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var value = percentToValue(clamp(pct, 0, 100));
if (mode === "point") {
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
if (mode === "point") {
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else {
setTargetValue(
maxTarget,
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
}
updateHandles();
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// ── Sync from number inputs back to handles ──
function syncFromInputs(e) {
if (mode === "point") {
var src = (e && e.target) || minTarget || maxTarget;
var val = src ? src.value : "";
setTargetValue(minTarget, val);
setTargetValue(maxTarget, val);
} else if (e && e.target) {
var minVal = getTargetValue(minTarget, dataMin);
var maxVal = getTargetValue(maxTarget, dataMax);
if (e.target === minTarget) {
if (minVal > maxVal) {
setTargetValue(maxTarget, minVal);
}
} else if (e.target === maxTarget) {
if (maxVal < minVal) {
setTargetValue(minTarget, maxVal);
}
}
}
updateHandles();
}
function enforceStrictBounds(e) {
if (e && e.target) {
var val = parseInt(e.target.value, 10);
if (!isNaN(val)) {
var clamped = clamp(val, dataMin, dataMax);
if (clamped !== val) {
setTargetValue(e.target, clamped);
e.target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
}
}
if (minTarget) {
minTarget.addEventListener("input", syncFromInputs);
minTarget.addEventListener("change", enforceStrictBounds);
}
if (maxTarget) {
maxTarget.addEventListener("input", syncFromInputs);
maxTarget.addEventListener("change", enforceStrictBounds);
}
// ── Mode toggle ──
var block = slider.closest(".range-slider-block");
var toggleButton =
block && block.querySelector(".range-mode-toggle");
if (toggleButton) {
toggleButton.addEventListener("click", function () {
var newMode = mode === "range" ? "point" : "range";
slider.setAttribute("data-mode", newMode);
// Swap toggle icons
var iconRange = toggleButton.querySelector(
".range-mode-icon-range"
);
var iconPoint = toggleButton.querySelector(
".range-mode-icon-point"
);
if (iconRange) iconRange.classList.toggle("hidden");
if (iconPoint) iconPoint.classList.toggle("hidden");
var dashSpan = block && block.querySelector(".range-dash");
if (newMode === "point") {
minHandle.style.display = "none";
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
} else {
minHandle.style.display = "";
if (minTarget) minTarget.classList.remove("hidden");
if (dashSpan) dashSpan.classList.remove("hidden");
setTargetValue(
maxTarget,
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
}
mode = newMode;
updateHandles();
});
}
}
// ── Initial position ──
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// ── Sync from number inputs back to handles ──
function syncFromInputs(e) {
if (mode === "point") {
var src = (e && e.target) || minTarget || maxTarget;
var val = src ? src.value : "";
setTargetValue(minTarget, val);
setTargetValue(maxTarget, val);
} else if (e && e.target) {
var minVal = getTargetValue(minTarget, dataMin);
var maxVal = getTargetValue(maxTarget, dataMax);
if (e.target === minTarget) {
if (minVal > maxVal) {
setTargetValue(maxTarget, minVal);
}
} else if (e.target === maxTarget) {
if (maxVal < minVal) {
setTargetValue(minTarget, maxVal);
}
}
}
updateHandles();
});
}
function enforceStrictBounds(e) {
if (e && e.target) {
var val = parseInt(e.target.value, 10);
if (!isNaN(val)) {
var clamped = clamp(val, dataMin, dataMax);
if (clamped !== val) {
setTargetValue(e.target, clamped);
e.target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
}
}
if (minTarget) {
minTarget.addEventListener("input", syncFromInputs);
minTarget.addEventListener("change", enforceStrictBounds);
}
if (maxTarget) {
maxTarget.addEventListener("input", syncFromInputs);
maxTarget.addEventListener("change", enforceStrictBounds);
}
// ── Mode toggle ──
var block = slider.closest(".range-slider-block");
var toggleButton =
block && block.querySelector(".range-mode-toggle");
if (toggleButton) {
toggleButton.addEventListener("click", function () {
var newMode = mode === "range" ? "point" : "range";
slider.setAttribute("data-mode", newMode);
// Swap toggle icons
var iconRange = toggleButton.querySelector(
".range-mode-icon-range"
);
var iconPoint = toggleButton.querySelector(
".range-mode-icon-point"
);
if (iconRange) iconRange.classList.toggle("hidden");
if (iconPoint) iconPoint.classList.toggle("hidden");
var dashSpan = block && block.querySelector(".range-dash");
if (newMode === "point") {
minHandle.style.display = "none";
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
} else {
minHandle.style.display = "";
if (minTarget) minTarget.classList.remove("hidden");
if (dashSpan) dashSpan.classList.remove("hidden");
}
mode = newMode;
updateHandles();
});
}
// ── Initial position ──
updateHandles();
}
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
window.initRangeSliders = initAll;
})();
onSwap(".range-slider", initializeSlider);
})();
+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);
})();
+26
View File
@@ -1,3 +1,28 @@
/**
* @description Runs initializeElement once for each element matching selector,
* on initial page load and inside every htmx-swapped fragment (a port of
* FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for
* each swapped-in element, so a single registration covers both; the WeakSet
* guarantees once-per-element initialization, replacing the old
* DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern.
* @param {string} selector
* @param {function(Element): void} initializeElement
*/
function onSwap(selector, initializeElement) {
const initialized = new WeakSet();
htmx.onLoad((swappedElement) => {
const elements = Array.from(htmx.findAll(swappedElement, selector));
if (swappedElement.matches && swappedElement.matches(selector)) {
elements.unshift(swappedElement);
}
for (const element of elements) {
if (initialized.has(element)) continue;
initialized.add(element);
initializeElement(element);
}
});
}
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
}
export {
onSwap,
toISOUTCString,
syncSelectInputUntilChanged,
getEl,