diff --git a/CLAUDE.md b/CLAUDE.md
index abef602..5a19d87 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -35,6 +35,7 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests
+e2e/ — Playwright browser tests (run via `make test-e2e`)
contrib/ — One-off scripts (exchange rate import)
docs/ — Additional documentation
```
@@ -113,13 +114,15 @@ Only a small number of HTML templates remain (platform icon snippets and partial
### Frontend stack
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
-- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
-- **Flowbite** (CDN) — navbar collapse, dropdown toggles
+- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
+- **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
+- All third-party JS is served locally from `games/static/js/` (no CDNs), so pages and browser tests work offline
- **Custom JS** in `games/static/js/`:
- - `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
+ - `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event); also defines `window.fetchWithHtmxTriggers`
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
- - `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
+ - `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
+- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
### Deployment
@@ -155,7 +158,7 @@ Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pyt
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
-**Browser/E2E tests**: `pytest-playwright` is a dev dependency for testing JavaScript behavior in a real browser (combine pytest-django's `live_server` fixture with Playwright's `page` fixture). Browser binaries must be installed once via `uv run playwright install chromium`. Note: pages load Alpine.js and Flowbite from CDNs, so browser tests must not depend on CDN-served scripts when running offline (htmx and all widget JS are served locally from `games/static/js/`).
+**Browser/E2E tests** live in `e2e/` and run with `make test-e2e` (`pytest-playwright` driving a real Chromium against pytest-django's `live_server`). `e2e/conftest.py` sets `DJANGO_ALLOW_ASYNC_UNSAFE` and prefers a system Chrome/Chromium; otherwise install browsers once via `uv run playwright install chromium`. All JS (including Alpine/Flowbite) is vendored in `games/static/js/`, so the tests run fully offline. Note that a bare `pytest` (`make test`) collects `e2e/` too, so it needs a browser as well. Key files: `test_widgets_e2e.py` (onSwap initialization lifecycle, FilterSelect/RangeSlider/add-purchase behavior), `test_search_select_e2e.py` (single-select edge cases on a synthetic page).
## Conventions for AI assistants
diff --git a/common/components/filters.py b/common/components/filters.py
index 1c7f59b..8be048c 100644
--- a/common/components/filters.py
+++ b/common/components/filters.py
@@ -559,9 +559,11 @@ def _filter_collapse_button() -> SafeText:
tag_name="button",
attributes=[
("type", "button"),
+ # Slider handles are positioned in percentages, so initializing
+ # them while the body is hidden is safe — no re-init on reveal.
(
"onclick",
- "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()",
+ "document.getElementById('filter-bar-body').classList.toggle('hidden')",
),
(
"class",
diff --git a/e2e/test_search_select_e2e.py b/e2e/test_search_select_e2e.py
index 210e41d..af7840a 100644
--- a/e2e/test_search_select_e2e.py
+++ b/e2e/test_search_select_e2e.py
@@ -4,35 +4,43 @@ from django.http import HttpResponse
from django.test import override_settings
from common.components import SearchSelect
+
def e2e_test_view(request):
html = f"""
SearchSelect E2E Test
-
+
+
+
- {SearchSelect(
- name="games",
- selected=[{"value": "7", "label": "Game A", "data": {}}],
- options=[
- {"value": "7", "label": "Game A", "data": {}},
- {"value": "8", "label": "Game B", "data": {}},
- ],
- multi_select=False
- )}
+ {
+ SearchSelect(
+ name="games",
+ selected=[{"value": "7", "label": "Game A", "data": {}}],
+ options=[
+ {"value": "7", "label": "Game A", "data": {}},
+ {"value": "8", "label": "Game B", "data": {}},
+ ],
+ multi_select=False,
+ )
+ }
"""
return HttpResponse(html)
+
urlpatterns = [
path("test-search-select/", e2e_test_view),
]
+
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
def test_search_select_backspace_clears_single_select(live_server, page):
@@ -52,9 +60,9 @@ def test_search_select_backspace_clears_single_select(live_server, page):
}""")
search_input = page.locator("input[data-search-select-search]")
-
+
assert search_input.input_value() == "Game A"
-
+
hidden_input = page.locator('input[name="games"]')
assert hidden_input.first.get_attribute("value") == "7"
@@ -85,7 +93,7 @@ def test_search_select_typing_replaces_single_select(live_server, page):
page.goto(live_server.url + "/test-search-select/")
search_input = page.locator("input[data-search-select-search]")
-
+
search_input.focus()
assert search_input.input_value() == ""
diff --git a/e2e/test_widgets_e2e.py b/e2e/test_widgets_e2e.py
new file mode 100644
index 0000000..4e95e88
--- /dev/null
+++ b/e2e/test_widgets_e2e.py
@@ -0,0 +1,135 @@
+"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
+add_purchase.js) and their onSwap() initialization lifecycle.
+
+These run a real Chromium via pytest-playwright against pytest-django's
+``live_server``. All JavaScript under test is served locally from
+``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
+vendored), so no network access is needed beyond the live server itself.
+
+Browser binaries must be installed once: ``uv run playwright install chromium``.
+"""
+
+import pytest
+from django.urls import reverse
+from playwright.sync_api import Page, expect
+
+
+@pytest.fixture
+def authenticated_page(live_server, page: Page, django_user_model) -> Page:
+ django_user_model.objects.create_user(username="tester", password="secret123")
+ page.goto(f"{live_server.url}{reverse('login')}")
+ page.fill('input[name="username"]', "tester")
+ page.fill('input[name="password"]', "secret123")
+ page.click('input[type="submit"]')
+ page.wait_for_url(f"{live_server.url}/tracker**")
+ return page
+
+
+def open_filter_bar(page: Page) -> None:
+ page.click("#filter-bar button:has-text('Filters')")
+ expect(page.locator("#filter-bar-body")).to_be_visible()
+
+
+def status_filter_widget(page: Page):
+ return page.locator('[data-search-select][data-name="status"]')
+
+
+def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
+ """Clicking into a FilterSelect search box opens its options panel —
+ proof that onSwap ran the widget initializer on the initial page load."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+ open_filter_bar(page)
+
+ widget = status_filter_widget(page)
+ widget.locator("[data-search-select-search]").click()
+
+ options_panel = widget.locator("[data-search-select-options]")
+ expect(options_panel).to_be_visible()
+ # The pinned "(Any)" modifier pseudo-option is rendered server-side and
+ # only becomes interactable through the initialized panel.
+ expect(
+ options_panel.locator("[data-search-select-modifier-option]").first
+ ).to_have_text("(Any)")
+
+
+def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
+ """Clicking an enum option row adds an include pill (full widget wiring)."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+ open_filter_bar(page)
+
+ widget = status_filter_widget(page)
+ widget.locator("[data-search-select-search]").click()
+ widget.locator('[data-search-select-option][data-label="Finished"]').click()
+
+ pill = widget.locator("[data-search-select-pills] [data-pill]")
+ expect(pill).to_have_count(1)
+ expect(pill).to_contain_text("Finished")
+
+
+def test_range_slider_mode_toggle_fires_exactly_once(
+ authenticated_page: Page, live_server
+):
+ """One click on the mode toggle flips the slider from range to point mode
+ exactly once. Double-bound listeners (the old force-re-init bug) would
+ flip it twice, leaving data-mode unchanged."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+ open_filter_bar(page)
+
+ block = page.locator(".range-slider-block").first
+ slider = block.locator(".range-slider")
+ expect(slider).to_have_attribute("data-mode", "range")
+
+ block.locator(".range-mode-toggle").click()
+ expect(slider).to_have_attribute("data-mode", "point")
+
+
+def test_widgets_initialize_inside_htmx_swapped_content(
+ authenticated_page: Page, live_server
+):
+ """Widgets arriving via an htmx swap initialize without a page load.
+
+ The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
+ uninitialized DOM. The swapped-in FilterSelect must open its panel and the
+ swapped-in slider must toggle exactly once, proving the htmx:load half of
+ onSwap and the once-per-element guard."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+
+ page.evaluate(
+ "htmx.ajax('GET', window.location.pathname, "
+ "{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
+ )
+ # The swapped-in bar arrives collapsed again; opening it proves the swap
+ # happened and the fresh DOM is in place.
+ open_filter_bar(page)
+
+ widget = status_filter_widget(page)
+ widget.locator("[data-search-select-search]").click()
+ expect(widget.locator("[data-search-select-options]")).to_be_visible()
+
+ block = page.locator(".range-slider-block").first
+ slider = block.locator(".range-slider")
+ expect(slider).to_have_attribute("data-mode", "range")
+ block.locator(".range-mode-toggle").click()
+ expect(slider).to_have_attribute("data-mode", "point")
+
+
+def test_add_purchase_type_toggles_disabled_fields(
+ authenticated_page: Page, live_server
+):
+ """add_purchase.js disables name/related-purchase while type is "game"
+ and re-enables them for other types."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
+
+ name_input = page.locator("#id_name")
+ expect(name_input).to_be_disabled()
+
+ page.select_option("#id_type", "dlc")
+ expect(name_input).to_be_enabled()
+
+ page.select_option("#id_type", "game")
+ expect(name_input).to_be_disabled()
diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js
index 248d38a..05a9253 100644
--- a/games/static/js/add_purchase.js
+++ b/games/static/js/add_purchase.js
@@ -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();
+ });
});
diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js
index 9281509..e83fa99 100644
--- a/games/static/js/filter_bar.js
+++ b/games/static/js/filter_bar.js
@@ -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();
diff --git a/games/static/js/range_slider.js b/games/static/js/range_slider.js
index 607444e..03dce46 100644
--- a/games/static/js/range_slider.js
+++ b/games/static/js/range_slider.js
@@ -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;
-})();
\ No newline at end of file
+ onSwap(".range-slider", initializeSlider);
+})();
diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js
index 40a2273..62f59a0 100644
--- a/games/static/js/search_select.js
+++ b/games/static/js/search_select.js
@@ -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 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);
})();
diff --git a/games/static/js/utils.js b/games/static/js/utils.js
index ee74ecc..82f9d15 100644
--- a/games/static/js/utils.js
+++ b/games/static/js/utils.js
@@ -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,