Skip to content

Instantly share code, notes, and snippets.

@crock
Last active January 12, 2024 16:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save crock/385825f81203fb6986efbb078705027b to your computer and use it in GitHub Desktop.
Save crock/385825f81203fb6986efbb078705027b to your computer and use it in GitHub Desktop.
Multi-handle range slider input (HTML5 Web Component)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Range Slider Web Component</title>
</head>
<body>
<range-slider min="1" max="63" step="1" values="[7,15]"></range-slider>
<template id="range-slider-template">
<style>
.range-slider__container{
margin-bottom: 100px;
}
.range-slider__container{
position: relative;
}
.range-slider__container span{
display: inline-block;
}
.range-slider__rail {
width: 100%;
position: absolute;
transform: translateY(-50%);
left: 0;
cursor: pointer;
}
.range-slider__track {
position: absolute;
transform: translateY(-50%);
cursor: pointer;
}
.range-slider__point {
top: 0;
transform: translateX(-50%);
position: absolute;
border-radius: 50%;
cursor: pointer;
transition: box-shadow 150ms;
}
.range-slider__container .range-slider__tooltip {
min-width: 30px;
font-size: 16px;
padding: 0.3em 0.6em;
background-color: gray;
color: white;
position: absolute;
left: 0;
top: -100%;
text-align: center;
border-radius: 3px;
user-select: none;
transform: translate(-50%, -50%) scale(0);
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
}
.range-slider__container .range-slider__tooltip::after {
content: '';
background-color: gray;
width: 1em;
height: 1em;
position: absolute;
bottom: -5px;
transform: translate(-50%) rotate(45deg);
left: 50%;
z-index: -1;
}
</style>
<div class="range-slider"></div>
</template>
<script src="main.js"></script>
</body>
</html>
import RangeSlider from './RangeSlider';
if ("customElements" in window) {
customElements.define('range-slider', RangeSlider)
}
// Declarative Shadow DOM polyfill
// Supports both streaming (shadowrootmode) and non-streaming (shadowroot)
export function polyfillDeclarativeShadowDom(node) {
let shadowroot = node.shadowRoot;
if(!shadowroot) {
let tmpl = node.querySelector(":scope > template:is([shadowrootmode], [shadowroot])");
if(tmpl) {
// default mode is "closed"
let mode = tmpl.getAttribute("shadowrootmode") || tmpl.getAttribute("shadowroot") || "closed";
shadowroot = node.attachShadow({ mode });
shadowroot.appendChild(tmpl.content.cloneNode(true));
}
}
}
import { polyfillDeclarativeShadowDom } from "./pollyfill";
/**
* Multi-handle range slider input Web Component
* @class RangeSlider
* @extends HTMLElement
* @property {number} min - Minimum value of the slider
* @property {number} max - Maximum value of the slider
* @property {number} step - Step value of the slider
* @property {number[]} values - Array of values for each handle
* @property {number} pointRadius - Radius of the slider handles
* @property {number} railHeight - Height of the slider rail
* @property {number} trackHeight - Height of the slider tracks
* @property {string} pointsColor - Color of the slider handles
* @property {string} railColor - Color of the slider rail
* @property {string} tracksColor - Color of the slider tracks
* @example
* <range-slider
* min="0"
* max="100"
* step="1"
* values="[0, 100]"
* point-radius="15"
* rail-height="5"
* track-height="5"
* points-color="rgb(25, 118, 210)"
* rail-color="rgba(25, 118, 210, 0.4)"
* tracks-color="rgb(25, 118, 210)"
* ></range-slider>
*/
class RangeSlider extends HTMLElement {
constructor() {
// establish prototype chain
super();
let template = document.getElementById("range-slider-template");
let templateContent = template.content;
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(templateContent.cloneNode(true));
// get attribute values from getters
const values = this.values;
const step = this.step;
const minLength = this.minLength;
const maxLength = this.maxLength;
const pointsColor = this.pointsColor;
const railColor = this.railColor;
const tracksColor = this.tracksColor;
const pointRadius = this.pointRadius;
const railHeight = this.railHeight;
const trackHeight = this.trackHeight;
this.defaultProps = {
values: values,
step: step,
min: minLength,
max: maxLength,
colors: {
points: pointsColor,
rail: railColor,
tracks: tracksColor
},
pointRadius,
railHeight,
trackHeight
};
this.allProps = {
...this.defaultProps,
values: [...this.defaultProps.values],
colors: {
...this.defaultProps.colors,
}
};
this.container = this.initContainer(".range-slider");
this.pointPositions = this.generatePointPositions();
this.possibleValues = this.generatePossibleValues();
this.jump =
this.container.offsetWidth /
Math.ceil(
(this.allProps.max - this.allProps.min) / this.allProps.step
);
this.rail = this.initRail();
this.tracks = this.initTracks(this.allProps.values.length - 1);
this.tooltip = this.initTooltip();
this.points = this.initPoints(this.allProps.values.length);
this.drawScene();
this.selectedPointIndex = -1;
this.changeHandlers = [];
// binding methods
this.onChange = this.onChange.bind(this);
this.draw = this.draw.bind(this);
this.railClickHandler = this.railClickHandler.bind(this);
this.documentMouseupHandler = this.documentMouseupHandler.bind(this);
this.documentMouseMoveHandler = this.documentMouseMoveHandler.bind(this);
this.pointClickHandler = this.pointClickHandler.bind(this);
this.pointMouseoverHandler = this.pointMouseoverHandler.bind(this);
this.pointMouseOutHandler = this.pointMouseOutHandler.bind(this);
}
// fires after the element has been attached to the DOM
connectedCallback() {
polyfillDeclarativeShadowDom(this);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'values') {
this.allProps.values = JSON.parse(newValue);
this.draw();
}
}
static get observedAttributes() {
return ['values'];
}
get minLength() {
return parseInt(this.getAttribute('min')) || 0;
}
get maxLength() {
return parseInt(this.getAttribute('max')) || 100;
}
get step() {
return parseInt(this.getAttribute('step')) || 1;
}
get values() {
return JSON.parse(this.getAttribute('values')) || [0, 100];
}
get pointRadius() {
return parseInt(this.getAttribute('point-radius')) || 15;
}
get railHeight() {
return parseInt(this.getAttribute('rail-height')) || 5;
}
get trackHeight() {
return parseInt(this.getAttribute('track-height')) || 5;
}
get pointsColor() {
return this.getAttribute('points-color') || 'rgb(25, 118, 210)';
}
get railColor() {
return this.getAttribute('rail-color') || 'rgba(25, 118, 210, 0.4)';
}
get tracksColor() {
return this.getAttribute('tracks-color') || 'rgb(25, 118, 210)';
}
/**
* Draw all elements with initial positions
*/
drawScene() {
this.container.classList.add("range-slider__container");
this.container.appendChild(this.rail);
this.container.appendChild(this.tooltip);
this.tracks.forEach(track => this.container.appendChild(track));
this.points.forEach(point => this.container.appendChild(point));
}
generatePointPositions() {
return this.allProps.values.map(value => {
let percentage = (value / this.allProps.max) * 100;
return Math.floor((percentage / 100) * this.container.offsetWidth);
});
}
/**
* Generate all values that can slider have starting from min, to max increased by step
*/
generatePossibleValues() {
let values = [];
for (
let i = this.allProps.min;
i <= this.allProps.max;
i += this.allProps.step
) {
values.push(Math.round(i * 100) / 100);
}
if (this.allProps.max % this.allProps.step > 0) {
values.push(Math.round(this.allProps.max * 100) / 100);
}
return values;
}
/**
* Initialize container
* @param {string} selector
*/
initContainer(selector) {
const container = this.shadowRoot.querySelector(selector);
container.classList.add("range-slider__container");
container.style.height = this.allProps.pointRadius * 2 + "px";
return container;
}
/**
* Initialize Rail
*/
initRail() {
const rail = document.createElement("span");
rail.classList.add("range-slider__rail");
rail.style.background = this.allProps.colors.rail;
rail.style.height = this.allProps.railHeight + "px";
rail.style.top = this.allProps.pointRadius + "px";
rail.addEventListener("click", e => this.railClickHandler(e));
return rail;
}
/**
* Initialize all tracks (equal to number of points - 1)
* @param {number} count
*/
initTracks(count) {
let tracks = [];
for (let i = 0; i < count; i++) {
tracks.push(this.initTrack(i));
}
return tracks;
}
/**
* Initialize single track at specific index position
* @param {number} index
*/
initTrack(index) {
const track = document.createElement("span");
track.classList.add("range-slider__track");
let trackPointPositions = this.pointPositions.slice(index, index + 2);
track.style.left = Math.min(...trackPointPositions) + "px";
track.style.top = this.allProps.pointRadius + "px";
track.style.width =
Math.max(...trackPointPositions) -
Math.min(...trackPointPositions) +
"px";
track.style.height = this.allProps.trackHeight + "px";
let trackColors = this.allProps.colors.tracks;
track.style.background = Array.isArray(trackColors)
? trackColors[index] || trackColors[trackColors.length - 1]
: trackColors;
track.addEventListener("click", e => this.railClickHandler(e));
return track;
}
/**
* Initialize all points (equal to number of values)
* @param {number} count
*/
initPoints(count) {
let points = [];
for (let i = 0; i < count; i++) {
points.push(this.initPoint(i));
}
return points;
}
/**
* Initialize single track at specific index position
* @param {number} index
*/
initPoint(index) {
const point = document.createElement("span");
point.classList.add("range-slider__point");
point.style.width = this.allProps.pointRadius * 2 + "px";
point.style.height = this.allProps.pointRadius * 2 + "px";
point.style.left = `${(this.pointPositions[index] /
this.container.offsetWidth) *
100}%`;
let pointColors = this.allProps.colors.points;
point.style.background = Array.isArray(pointColors)
? pointColors[index] || pointColors[pointColors.length - 1]
: pointColors;
point.addEventListener("mousedown", e =>
this.pointClickHandler(e, index)
);
point.addEventListener("mouseover", e =>
this.pointMouseoverHandler(e, index)
);
point.addEventListener("mouseout", e =>
this.pointMouseOutHandler(e, index)
);
return point;
}
/**
* Initialize tooltip
*/
initTooltip() {
const tooltip = document.createElement("span");
tooltip.classList.add("range-slider__tooltip");
tooltip.style.fontSize = this.allProps.pointRadius + "px";
return tooltip;
}
/**
* Draw points, tracks and tooltip (on rail click or on drag)
*/
draw() {
this.points.forEach((point, i) => {
point.style.left = `${(this.pointPositions[i] /
this.container.offsetWidth) *
100}%`;
});
this.tracks.forEach((track, i) => {
let trackPointPositions = this.pointPositions.slice(i, i + 2);
track.style.left = Math.min(...trackPointPositions) + "px";
track.style.width =
Math.max(...trackPointPositions) -
Math.min(...trackPointPositions) +
"px";
});
this.tooltip.style.left =
this.pointPositions[this.selectedPointIndex] + "px";
this.tooltip.textContent = this.allProps.values[
this.selectedPointIndex
];
}
/**
* Redraw on rail click
* @param {Event} e
*/
railClickHandler(e) {
let newPosition = this.getMouseRelativePosition(e.pageX);
let closestPositionIndex = this.getClosestPointIndex(newPosition);
this.pointPositions[closestPositionIndex] = newPosition;
this.draw();
}
/**
* Find the closest possible point position fro current mouse position
* in order to move the point
* @param {number} mousePoisition
*/
getClosestPointIndex(mousePoisition) {
let shortestDistance = Infinity;
let index = 0;
for (let i in this.pointPositions) {
let dist = Math.abs(mousePoisition - this.pointPositions[i]);
if (shortestDistance > dist) {
shortestDistance = dist;
index = i;
}
}
return index;
}
/**
* Stop point moving on mouse up
*/
documentMouseupHandler() {
this.changeHandlers.forEach(func => func(this.allProps.values));
this.points[this.selectedPointIndex].style.boxShadow = `none`;
this.selectedPointIndex = -1;
this.tooltip.style.transform = "translate(-50%, -60%) scale(0)";
document.removeEventListener("mouseup", this.documentMouseupHandler);
document.removeEventListener(
"mousemove",
this.documentMouseMoveHandler
);
}
/**
* Start point moving on mouse move
* @param {Event} e
*/
documentMouseMoveHandler(e) {
let newPosition = this.getMouseRelativePosition(e.pageX);
let extra = Math.floor(newPosition % this.jump);
if (extra > this.jump / 2) {
newPosition += this.jump - extra;
} else {
newPosition -= extra;
}
if (newPosition < 0) {
newPosition = 0;
} else if (newPosition > this.container.offsetWidth) {
newPosition = this.container.offsetWidth;
}
this.pointPositions[this.selectedPointIndex] = newPosition;
this.allProps.values[this.selectedPointIndex] = this.possibleValues[
Math.floor(newPosition / this.jump)
];
this.dispatchEvent(new CustomEvent('rangeSliderValueChanged', {
detail: { values: this.allProps.values },
bubbles: true,
composed: true
}));
this.draw();
}
/**
* Register document listeners on point click
* and save clicked point index
* @param {Event} e
*/
pointClickHandler(e, index) {
e.preventDefault();
this.selectedPointIndex = index;
document.addEventListener("mouseup", this.documentMouseupHandler);
document.addEventListener("mousemove", this.documentMouseMoveHandler);
}
/**
* Point mouse over box shadow and tooltip displaying
* @param {Event} e
* @param {number} index
*/
pointMouseoverHandler(e, index) {
const transparentColor = RangeSlider.addTransparencyToColor(
this.points[index].style.backgroundColor,
16
);
if (this.selectedPointIndex < 0) {
this.points[index].style.boxShadow = `0px 0px 0px ${Math.floor(
this.allProps.pointRadius / 1.5
)}px ${transparentColor}`;
}
this.tooltip.style.transform = "translate(-50%, -60%) scale(1)";
this.tooltip.style.left = this.pointPositions[index] + "px";
this.tooltip.textContent = this.allProps.values[index];
}
/**
* Add transparency for rgb, rgba or hex color
* @param {string} color
* @param {number} percentage
*/
static addTransparencyToColor(color, percentage) {
if (color.startsWith("rgba")) {
return color.replace(/(\d+)(?!.*\d)/, percentage + "%");
}
if (color.startsWith("rgb")) {
let newColor = color.replace(/(\))(?!.*\))/, `, ${percentage}%)`);
return newColor.replace("rgb", "rgba");
}
if (color.startsWith("#")) {
return color + percentage.toString(16);
}
return color;
}
/**
* Hide shadow and tooltip on mouse out
* @param {Event} e
* @param {number} index
*/
pointMouseOutHandler(e, index) {
if (this.selectedPointIndex < 0) {
this.points[index].style.boxShadow = `none`;
this.tooltip.style.transform = "translate(-50%, -60%) scale(0)";
}
}
/**
* Get mouse position relatively from containers left position on the page
*/
getMouseRelativePosition(pageX) {
return pageX - this.container.offsetLeft;
}
/**
* Register onChange callback to call it on slider move end passing all the present values
*/
onChange(func) {
if (typeof func !== "function") {
throw new Error("Please provide function as onChange callback");
}
this.changeHandlers.push(func);
return this;
}
}
export default RangeSlider;
@crock
Copy link
Author

crock commented Jan 12, 2024

I am using ESM syntax, so I recommend adding a transpile step for better browser support.

@crock
Copy link
Author

crock commented Jan 12, 2024

To subscribe to value changes, you can listen for a custom event that is emitted from this Web Component. Here's an example of how you would listen for changes to the values prop.

document.addEventListener("rangeSliderValueChanged", function (event) {
     console.log(event.detail.values)
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment