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