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