diff --git a/common/components/filters.py b/common/components/filters.py
index b09a6b9..acd29ac 100644
--- a/common/components/filters.py
+++ b/common/components/filters.py
@@ -55,7 +55,7 @@ _FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
# range_slider.js wires RangeSlider; ts/filter_bar.ts wires the bar chrome
# (Apply/Clear, presets, search injection). Widget media (dist/search_select.js,
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
-_RANGE_SLIDER_MEDIA = Media(js=("range_slider.js",))
+_RANGE_SLIDER_MEDIA = Media(js=("dist/range_slider.js",))
_FILTER_BAR_MEDIA = Media(js=("dist/filter_bar.js",))
diff --git a/e2e/test_boolean_filter_e2e.py b/e2e/test_boolean_filter_e2e.py
index b6e789c..387a30f 100644
--- a/e2e/test_boolean_filter_e2e.py
+++ b/e2e/test_boolean_filter_e2e.py
@@ -22,7 +22,7 @@ def _bar_page(filter_json: str = "") -> str:
Boolean filter E2E
-
+
diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py
index 9abe8a0..77e5709 100644
--- a/e2e/test_date_filter_e2e.py
+++ b/e2e/test_date_filter_e2e.py
@@ -30,7 +30,7 @@ def _bar_page(filter_json: str = "") -> str:
Date filter E2E
-
+
diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py
index 9aa78c4..43c8a63 100644
--- a/e2e/test_date_range_picker_e2e.py
+++ b/e2e/test_date_range_picker_e2e.py
@@ -29,7 +29,7 @@ def _bar_page(filter_json: str = "") -> str:
Date range picker E2E
-
+
diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py
index b1bfc74..ba60fd6 100644
--- a/e2e/test_range_slider_e2e.py
+++ b/e2e/test_range_slider_e2e.py
@@ -14,7 +14,7 @@ def _bar_page(filter_json: str = "") -> str:
Range Slider E2E
-
+
diff --git a/e2e/test_string_filter_e2e.py b/e2e/test_string_filter_e2e.py
index 24fd7ac..860d99e 100644
--- a/e2e/test_string_filter_e2e.py
+++ b/e2e/test_string_filter_e2e.py
@@ -17,7 +17,7 @@ def _bar_page(filter_json: str = "") -> str:
String filter E2E
-
+
diff --git a/games/static/js/range_slider.js b/games/static/js/range_slider.js
deleted file mode 100644
index 03dce46..0000000
--- a/games/static/js/range_slider.js
+++ /dev/null
@@ -1,230 +0,0 @@
-/**
- * Range slider — custom draggable handles (no native ).
- *
- * Supports two modes on each slider, toggled via the .range-mode-toggle button:
- * range (default) — two handles, min ≤ max constraint
- * point — single handle, sets both number inputs to the same value
- *
- * 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 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 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 ──
-
- 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;
- }
-
- // ── 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 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 ──
-
- 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));
-
- 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 (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 })
- );
- }
- 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");
- }
- mode = newMode;
- updateHandles();
- });
- }
-
- // ── Initial position ──
- updateHandles();
- }
-
- onSwap(".range-slider", initializeSlider);
-})();
diff --git a/tests/test_node_tree.py b/tests/test_node_tree.py
index 3496b02..286c65e 100644
--- a/tests/test_node_tree.py
+++ b/tests/test_node_tree.py
@@ -159,7 +159,7 @@ class RealComponentMediaTest(unittest.TestCase):
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
)
)
- self.assertEqual(media.js, ("range_slider.js",))
+ self.assertEqual(media.js, ("dist/range_slider.js",))
def test_filter_bar_collects_chrome_and_widget_media(self):
"""A FilterBar's media merges its own chrome script with the scripts that
@@ -171,7 +171,7 @@ class RealComponentMediaTest(unittest.TestCase):
media = collect_media(FilterBar())
self.assertIn("dist/filter_bar.js", media.js)
self.assertIn("dist/search_select.js", media.js)
- self.assertIn("range_slider.js", media.js)
+ self.assertIn("dist/range_slider.js", media.js)
class HtpyStyleSugarTest(unittest.TestCase):
diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py
index ec72004..d9eaa44 100644
--- a/tests/test_rendered_pages.py
+++ b/tests/test_rendered_pages.py
@@ -65,7 +65,7 @@ class RenderedPagesTest(TestCase):
html = self.get("games:list_games").content.decode()
self.assertIn("js/dist/filter_bar.js", html)
self.assertIn("js/dist/search_select.js", html)
- self.assertIn("js/range_slider.js", html)
+ self.assertIn("js/dist/range_slider.js", html)
def test_stats_page_auto_loads_datepicker(self):
"""YearPicker declares the datepicker UMD bundle as media; the stats
diff --git a/ts/range_slider.ts b/ts/range_slider.ts
new file mode 100644
index 0000000..7c5e7f4
--- /dev/null
+++ b/ts/range_slider.ts
@@ -0,0 +1,215 @@
+/**
+ * Range slider — custom draggable handles (no native ).
+ *
+ * Supports two modes on each slider, toggled via the .range-mode-toggle button:
+ * range (default) — two handles, min ≤ max constraint
+ * point — single handle, sets both number inputs to the same value
+ *
+ * Handles track-fill positioning and sync between handles and the connected
+ * number inputs (linked via data-target attributes).
+ */
+import { onSwap } from "./utils.js";
+
+(() => {
+ "use strict";
+
+ function initializeSlider(sliderElement: Element) {
+ const slider = sliderElement as HTMLElement;
+ let mode = slider.getAttribute("data-mode") || "range";
+ const trackFill = slider.querySelector(".range-track-fill");
+ const minHandle = slider.querySelector(".range-handle-min");
+ const maxHandle = slider.querySelector(".range-handle-max");
+ if (!minHandle || !maxHandle) return;
+
+ const minTarget = document.getElementById(
+ minHandle.getAttribute("data-target") ?? ""
+ ) as HTMLInputElement | null;
+ const maxTarget = document.getElementById(
+ maxHandle.getAttribute("data-target") ?? ""
+ ) as HTMLInputElement | null;
+ const dataMin = parseInt(slider.getAttribute("data-min") ?? "", 10);
+ const dataMax = parseInt(slider.getAttribute("data-max") ?? "", 10);
+ const step = parseInt(slider.getAttribute("data-step") ?? "", 10) || 1;
+
+ // ── Helpers ──
+
+ function valueToPercent(value: number): number {
+ return ((value - dataMin) / (dataMax - dataMin)) * 100;
+ }
+ function percentToValue(percent: number): number {
+ const raw = dataMin + (percent / 100) * (dataMax - dataMin);
+ return Math.round(raw / step) * step;
+ }
+ function clamp(value: number, low: number, high: number): number {
+ return Math.max(low, Math.min(high, value));
+ }
+
+ function getTargetValue(target: HTMLInputElement | null, defaultValue: number): number {
+ if (!target || target.value === "") return defaultValue;
+ const parsed = parseInt(target.value, 10);
+ return isNaN(parsed) ? defaultValue : parsed;
+ }
+ function setTargetValue(target: HTMLInputElement | null, value: number | string): void {
+ if (target) target.value = String(value);
+ }
+
+ // ── Track fill positioning ──
+
+ function updateTrackFill(): void {
+ if (!trackFill) return;
+ const minValue = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
+ const maxValue = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
+ if (mode === "point") {
+ trackFill.style.left = "0%";
+ trackFill.style.width = valueToPercent(maxValue) + "%";
+ } else {
+ let leftPercent = valueToPercent(minValue);
+ let rightPercent = valueToPercent(maxValue);
+ if (leftPercent > rightPercent) {
+ const temp = leftPercent;
+ leftPercent = rightPercent;
+ rightPercent = temp;
+ }
+ const widthPercent = rightPercent - leftPercent;
+ trackFill.style.left = leftPercent + "%";
+ trackFill.style.width = widthPercent + "%";
+ }
+ }
+
+ function updateHandles(): void {
+ const minValue = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
+ const maxValue = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
+ minHandle!.style.left = valueToPercent(minValue) + "%";
+ maxHandle!.style.left = valueToPercent(maxValue) + "%";
+ updateTrackFill();
+ }
+
+ // ── Dragging ──
+
+ function makeDraggable(handle: HTMLElement, isMin: boolean): void {
+ handle.addEventListener("mousedown", (event) => {
+ event.preventDefault();
+ const rect = slider.getBoundingClientRect();
+
+ function onMove(moveEvent: MouseEvent): void {
+ const percent = ((moveEvent.clientX - rect.left) / rect.width) * 100;
+ const value = percentToValue(clamp(percent, 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 (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 }));
+ }
+ updateHandles();
+ }
+
+ function onUp(): void {
+ document.removeEventListener("mousemove", onMove);
+ document.removeEventListener("mouseup", onUp);
+ }
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ onMove(event);
+ });
+ }
+
+ makeDraggable(minHandle, true);
+ makeDraggable(maxHandle, false);
+
+ // ── Sync from number inputs back to handles ──
+
+ function syncFromInputs(event?: Event): void {
+ if (mode === "point") {
+ const source = (event?.target as HTMLInputElement | null) || minTarget || maxTarget;
+ const value = source ? source.value : "";
+ setTargetValue(minTarget, value);
+ setTargetValue(maxTarget, value);
+ } else if (event && event.target) {
+ const minValue = getTargetValue(minTarget, dataMin);
+ const maxValue = getTargetValue(maxTarget, dataMax);
+ if (event.target === minTarget) {
+ if (minValue > maxValue) {
+ setTargetValue(maxTarget, minValue);
+ }
+ } else if (event.target === maxTarget) {
+ if (maxValue < minValue) {
+ setTargetValue(minTarget, maxValue);
+ }
+ }
+ }
+ updateHandles();
+ }
+
+ function enforceStrictBounds(event: Event): void {
+ const target = event.target as HTMLInputElement | null;
+ if (target) {
+ const value = parseInt(target.value, 10);
+ if (!isNaN(value)) {
+ const clamped = clamp(value, dataMin, dataMax);
+ if (clamped !== value) {
+ setTargetValue(target, clamped);
+ 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 ──
+
+ const block = slider.closest(".range-slider-block");
+ const toggleButton = block && block.querySelector(".range-mode-toggle");
+ if (toggleButton) {
+ toggleButton.addEventListener("click", () => {
+ const newMode = mode === "range" ? "point" : "range";
+ slider.setAttribute("data-mode", newMode);
+
+ // Swap toggle icons
+ const iconRange = toggleButton.querySelector(".range-mode-icon-range");
+ const iconPoint = toggleButton.querySelector(".range-mode-icon-point");
+ if (iconRange) iconRange.classList.toggle("hidden");
+ if (iconPoint) iconPoint.classList.toggle("hidden");
+
+ const 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();
+ }
+
+ onSwap(".range-slider", initializeSlider);
+})();