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