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); +})();