From d021c280d25e3b377abfe7822199e00aa0c6d536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Wed, 10 Jun 2026 20:33:56 +0200 Subject: [PATCH] Fix RangeSlider visual bugs --- common/components/filters.py | 33 ++++++--- common/components/primitives.py | 2 +- e2e/test_range_slider_e2e.py | 115 ++++++++++++++++++++++++++++++++ games/static/base.css | 34 ++++++++++ games/static/js/range_slider.js | 80 ++++++++++++++++------ 5 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 e2e/test_range_slider_e2e.py diff --git a/common/components/filters.py b/common/components/filters.py index 20e4bb3..aa2e5f6 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -272,7 +272,9 @@ def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText attributes=[("class", "flex items-center gap-4 h-9")], children=[ Radio(name=name, label="True", checked=value is True, value="true"), - Radio(name=name, label="False", checked=value is False, value="false"), + Radio( + name=name, label="False", checked=value is False, value="false" + ), ], ), ], @@ -419,7 +421,7 @@ def RangeSlider( # ── Slider row ── Div( attributes=[ - ("class", "range-slider relative h-10 select-none mt-1"), + ("class", "range-slider relative h-10 w-5/6 select-none mt-1"), ("data-mode", initial_mode), ("data-min", str(range_min)), ("data-max", str(range_max)), @@ -748,7 +750,9 @@ def FilterBar( purchase_type_choice = _filter_get_choice(existing, "purchase_type") purchase_ownership_choice = _filter_get_choice(existing, "purchase_ownership_type") playevent_note_value = existing.get("playevent_note", {}).get("value", "") - playevent_note_modifier = existing.get("playevent_note", {}).get("modifier", "EQUALS") + playevent_note_modifier = existing.get("playevent_note", {}).get( + "modifier", "EQUALS" + ) year_min, year_max = _parse_range(existing, "year_released") original_year_min, original_year_max = _parse_range( @@ -1199,9 +1203,13 @@ def PurchaseFilterBar( infinite_value = _parse_bool_nullable(existing, "infinite") needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update") price_currency_value = existing.get("price_currency", {}).get("value", "") - price_currency_modifier = existing.get("price_currency", {}).get("modifier", "EQUALS") + price_currency_modifier = existing.get("price_currency", {}).get( + "modifier", "EQUALS" + ) converted_currency_value = existing.get("converted_currency", {}).get("value", "") - converted_currency_modifier = existing.get("converted_currency", {}).get("modifier", "EQUALS") + converted_currency_modifier = existing.get("converted_currency", {}).get( + "modifier", "EQUALS" + ) date_purchased_min, date_purchased_max = _parse_range(existing, "date_purchased") date_refunded_min, date_refunded_max = _parse_range(existing, "date_refunded") @@ -1344,7 +1352,9 @@ def PurchaseFilterBar( _filter_boolean_radio( "filter-refunded", "Refunded", is_refunded_value ), - _filter_boolean_radio("filter-infinite", "Infinite", infinite_value), + _filter_boolean_radio( + "filter-infinite", "Infinite", infinite_value + ), _filter_boolean_radio( "filter-needs-price-update", "Needs Price Update", @@ -1495,7 +1505,7 @@ def StringFilter( attributes=[ ("data-string-modifier-radio", ""), ("onclick", "toggleStringFilterInput(this)"), - ] + ], ) for mod_val, lbl in options ] @@ -1517,10 +1527,15 @@ def StringFilter( input_attrs.append(("disabled", "true")) return Div( - attributes=[("class", "flex flex-col gap-2")], + attributes=[("class", "flex flex-col gap-2 @container")], children=[ Div( - attributes=[("class", "grid grid-cols-2 sm:grid-cols-4 gap-2 py-1")], + attributes=[ + ( + "class", + "grid grid-cols-2 @md:grid-cols-4 gap-2 py-1", + ) + ], children=radio_buttons, ), Input(attributes=input_attrs), diff --git a/common/components/primitives.py b/common/components/primitives.py index 02ab6d9..8fb683e 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -459,7 +459,7 @@ def Radio( return Label( attributes=[ - ("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer") + ("class", "flex items-center gap-1 text-sm text-heading cursor-pointer") ], children=[input_el, label], ) diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py new file mode 100644 index 0000000..3fd080d --- /dev/null +++ b/e2e/test_range_slider_e2e.py @@ -0,0 +1,115 @@ +"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior. +""" + +import json +import urllib.parse + +import pytest +from django.http import HttpResponse +from django.test import override_settings +from django.urls import path + +from common.components import FilterBar + + +def _bar_page(filter_json: str = "") -> str: + return f""" + + + Range Slider E2E + + + + + + {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} + +""" + + +def empty_bar_view(request): + return HttpResponse(_bar_page()) + + +urlpatterns = [ + path("test-range-slider/", empty_bar_view), +] + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") +def test_range_slider_crossover_min_higher_than_max(live_server, page): + page.goto(live_server.url + "/test-range-slider/") + + # 1. Start with known state: Min is empty, Max is empty + min_input = page.locator('input[name="filter-session-count-min"]') + max_input = page.locator('input[name="filter-session-count-max"]') + + # 2. Type "20" into max input + max_input.fill("20") + + # 3. Type "50" into min input (which is higher than 20) + min_input.fill("50") + + # 4. Max input should have automatically synchronized/snapped to 50 + assert max_input.input_value() == "50" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") +def test_range_slider_crossover_max_less_than_min(live_server, page): + page.goto(live_server.url + "/test-range-slider/") + + min_input = page.locator('input[name="filter-session-count-min"]') + max_input = page.locator('input[name="filter-session-count-max"]') + + # 1. Type "50" into min input + min_input.fill("50") + + # 2. Type "30" into max input (which is less than 50) + max_input.fill("30") + + # 3. Min input should have automatically synchronized/snapped to 30 + assert min_input.input_value() == "30" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") +def test_range_slider_strict_bounds_clamping_on_blur(live_server, page): + page.goto(live_server.url + "/test-range-slider/") + + min_input = page.locator('input[name="filter-session-count-min"]') + max_input = page.locator('input[name="filter-session-count-max"]') + + # 1. Type value higher than dataMax (100 is max, type "150") + max_input.fill("150") + max_input.blur() # triggers "change" event + + assert max_input.input_value() == "100" + + # 2. Type value lower than dataMin (0 is min, type "-20") + min_input.fill("-20") + min_input.blur() # triggers "change" event + + assert min_input.input_value() == "0" + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") +def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page): + page.goto(live_server.url + "/test-range-slider/") + + # Locate handles + max_handle = page.locator('.range-handle-max[data-target="filter-session-count-max"]') + + # Initially, max_input is empty, so handle should sit at 100% (far right) + style = max_handle.get_attribute("style") + assert "left:100%" in style or "left: 100%" in style + + # Set min to 50 + min_input = page.locator('input[name="filter-session-count-min"]') + min_input.fill("50") + + # Max handle should STILL stay at 100% since max input is still empty (defaults to max_value) + style = max_handle.get_attribute("style") + assert "left:100%" in style or "left: 100%" in style diff --git a/games/static/base.css b/games/static/base.css index 4761651..fce8bdc 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -466,6 +466,9 @@ } } @layer utilities { + .\@container { + container-type: inline-size; + } .pointer-events-auto { pointer-events: auto; } @@ -1742,6 +1745,9 @@ .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } .grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -2708,6 +2714,9 @@ .opacity-0 { opacity: 0%; } + .opacity-50 { + opacity: 50%; + } .opacity-100 { opacity: 100%; } @@ -2761,6 +2770,11 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } .transition-opacity { transition-property: opacity; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); @@ -3345,6 +3359,11 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + .sm\:grid-cols-4 { + @media (width >= 40rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } .sm\:rounded-t-lg { @media (width >= 40rem) { border-top-left-radius: var(--radius-lg); @@ -3510,6 +3529,21 @@ max-width: var(--breakpoint-2xl); } } + .\@sm\:grid-cols-3 { + @container (width >= 24rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .\@md\:grid-cols-4 { + @container (width >= 28rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + .\@lg\:grid-cols-6 { + @container (width >= 32rem) { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + } .rtl\:rotate-180 { &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { rotate: 180deg; diff --git a/games/static/js/range_slider.js b/games/static/js/range_slider.js index a44bbff..607444e 100644 --- a/games/static/js/range_slider.js +++ b/games/static/js/range_slider.js @@ -46,8 +46,10 @@ return Math.max(lo, Math.min(hi, value)); } - function getTargetValue(target) { - return parseInt(target ? target.value : 0, 10) || dataMin; + 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; @@ -57,22 +59,30 @@ function updateTrackFill() { if (!trackFill) return; - var minValue = getTargetValue(minTarget); - var maxValue = getTargetValue(maxTarget); + 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(maxValue) + "%"; + trackFill.style.width = valueToPercent(maxVal) + "%"; } else { - var leftPct = valueToPercent(minValue); - var widthPct = valueToPercent(maxValue) - leftPct; + 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() { - minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%"; - maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%"; + 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(); } @@ -101,7 +111,7 @@ } else if (isMin) { setTargetValue( minTarget, - clamp(value, dataMin, getTargetValue(maxTarget)) + clamp(value, dataMin, getTargetValue(maxTarget, dataMax)) ); if (minTarget) minTarget.dispatchEvent( @@ -110,7 +120,7 @@ } else { setTargetValue( maxTarget, - clamp(value, getTargetValue(minTarget), dataMax) + clamp(value, getTargetValue(minTarget, dataMin), dataMax) ); if (maxTarget) maxTarget.dispatchEvent( @@ -135,19 +145,49 @@ // ── Sync from number inputs back to handles ── - function syncFromInputs() { + function syncFromInputs(e) { if (mode === "point") { - var value = - getTargetValue(minTarget) || getTargetValue(maxTarget); - setTargetValue(minTarget, value); - setTargetValue(maxTarget, value); + 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(); } - if (minTarget) + + 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); - if (maxTarget) + minTarget.addEventListener("change", enforceStrictBounds); + } + if (maxTarget) { maxTarget.addEventListener("input", syncFromInputs); + maxTarget.addEventListener("change", enforceStrictBounds); + } // ── Mode toggle ── @@ -172,7 +212,7 @@ var dashSpan = block && block.querySelector(".range-dash"); if (newMode === "point") { minHandle.style.display = "none"; - setTargetValue(minTarget, getTargetValue(maxTarget)); + setTargetValue(minTarget, maxTarget ? maxTarget.value : ""); if (minTarget) minTarget.classList.add("hidden"); if (dashSpan) dashSpan.classList.add("hidden"); } else { @@ -193,4 +233,4 @@ document.addEventListener("DOMContentLoaded", initAll); document.addEventListener("htmx:afterSwap", initAll); window.initRangeSliders = initAll; -})(); +})(); \ No newline at end of file