$track-color: #ddd !default;
$active-track-color: #81c8ff !default;
$thumb-color: #4fa3e4 !default;
$disabled-color: #e5e5e5 !default;
$track-size: 3px !default;
$thumb-size: 16px !default;
.rangeslider--hidden {
opacity: 0;
pointer-events: none;
position: absolute;
}
.rangeslider {
position: relative;
display: flex;
align-items: center;
&--disabled {
pointer-events: none;
}
&--horizontal {
flex-direction: row;
height: $thumb-size;
}
&--vertical {
flex-direction: column;
width: $thumb-size;
height: 100%;
}
&--horizontal &__track {
height: $track-size;
left: 0;
right: 0;
}
&--horizontal &__active-track {
left: 0;
top: 0;
bottom: 0;
width: 0;
}
&--horizontal &__thumb {
transform: translateX(-50%);
height: 100%;
img {
width: auto;
height: 100%;
display: block;
}
}
&--vertical &__track {
width: $track-size;
top: 0;
bottom: 0;
}
&--vertical &__active-track {
left: 0;
right: 0;
bottom: 0;
height: 0;
}
&--vertical &__thumb {
transform: translateY(50%);
width: 100%;
img {
width: 100%;
height: auto;
display: block;
}
}
&__track {
background-color: $track-color;
position: absolute;
}
&__active-track {
background-color: $active-track-color;
position: absolute;
}
&__thumb {
position: absolute;
box-sizing: border-box;
background: $thumb-color;
box-shadow: 0 0 0 3px white;
&:focus {
outline: none;
}
}
&--disabled &__track,
&--disabled &__active-track,
&--disabled &__thumb {
background-color: $disabled-color;
}
&--keyboard-navigating &__thumb:focus {
box-shadow: 0 0 0 3px $active-track-color, 0 0 0 3px white;
}
}
// Just some CodePen boilerplate code
::selection {
background-color: rgba($active-track-color, 0.5);
}
body {
padding: 5em;
min-height: 500px;
font-family: Roboto;
}
label {
display: block;
}
code {
font-family: 'Roboto Mono', Consolas, Monaco, Menlo, monospace;
background: #f5f5f5;
border: 1px solid #ddd;
padding: 0.1em 0.3em;
color: #888;
border-radius: 3px;
}
input {
font-family: inherit;
font-size: inherit;
font-weight: inherit;
font-style: inherit;
padding: 0.5em 0.8em;
margin: 0;
border: 1px solid #ddd;
&[type="number"] {
width: 100px;
}
&:focus {
outline: none;
border-color: $thumb-color;
}
}
#slider {
padding-bottom: 2em;
label {
margin: 0 0 1em;
}
}
#properties > div {
margin: 0.25em 0;
}
a {
color: $thumb-color;
}
View Compiled
interface RangesliderOptions {
orientation: 'horizontal' | 'vertical'
assignToProperty: false | string | symbol
}
interface RangesliderComputed {
range: number
realmax: number
offsetProp: string
stepDecimals: number
}
class Rangeslider {
protected static defaultOptions: RangesliderOptions = {
assignToProperty: false,
orientation: 'horizontal'
}
protected el: HTMLInputElement
protected dom: HTMLElement
protected thumb: HTMLElement
protected track: HTMLElement
protected activeTrack: HTMLElement
protected originalTabIndex: number
protected min: number
protected max: number
protected step: number
protected value: number
protected disabled: boolean = false
protected options: RangesliderOptions
protected computed: RangesliderComputed
constructor (el: HTMLInputElement, options: Partial<RangesliderOptions> = {}) {
this.el = el
this.setOptions(Object.assign(this.getDefaultOptions(), options))
this.el.addEventListener('focus', this.redirectLabels)
if (options.assignToProperty) {
this.el[options.assignToProperty] = this
}
this.init()
this.grabDomProperties()
}
refresh () {
this.grabDomProperties()
this.updateValue(this.value, true, false)
if (this.disabled) {
this.dom.classList.add('rangeslider--disabled')
this.thumb.tabIndex = -1
} else {
this.dom.classList.remove('rangeslider--disabled')
this.thumb.tabIndex = 0
}
}
protected getDefaultOptions () {
return Object.assign({}, Rangeslider.defaultOptions)
}
protected grabDomProperties () {
this.min = (this.el.min != null && this.el.min.length)
? +this.el.min
: 0
this.max = (this.el.max != null && this.el.max.length)
? +this.el.max
: 100
this.step = (this.el.step != null && this.el.step.length)
? +this.el.step
: 1
this.value = (this.el.value != null && this.el.value.length)
? +this.el.value
: this.restrict((this.max - this.min) / 2)
this.disabled = this.el.disabled
this.compute()
this.el.min = String(this.min)
this.el.max = String(this.max)
this.el.step = String(this.step)
this.el.value = String(this.value)
this.thumb.setAttribute('aria-disabled', String(this.disabled))
this.thumb.setAttribute('aria-valuemin', this.el.min)
this.thumb.setAttribute('aria-valuemax', this.el.min)
this.thumb.setAttribute('aria-valuenow', this.el.value)
}
protected dispatch (type: 'change' | 'input') {
const evt = new Event(type)
this.el.dispatchEvent(evt)
}
protected setOptions (options: RangesliderOptions, dispatchEvents: boolean = true) {
this.options = options
this.compute()
}
protected countDecimals (value: number) {
if (Math.floor(value.valueOf()) === value.valueOf()) return 0
return value.toString().split(".")[1].length || 0
}
protected stepRemainder (value: number) {
const factor = Math.pow(10, this.computed.stepDecimals)
return ((value * factor) % (this.step * factor)) / factor
}
protected compute () {
this.computed = {} as any
this.computed.range = this.max - this.min
this.computed.stepDecimals = this.countDecimals(this.step || 0)
this.computed.realmax = this.max - this.stepRemainder(this.computed.range)
this.computed.offsetProp = this.options.orientation === 'horizontal'
? 'clientX'
: 'clientY'
}
protected restrict (value: number): number {
if (value < this.min) return this.min
if (value > this.computed.realmax) return this.computed.realmax
const remainder = this.stepRemainder(value - this.min)
if (remainder === 0) {
return value
} else {
if (remainder > this.step / 2 && (this.step - remainder) <= this.max) {
return value + (this.step - remainder)
} else {
return value - remainder
}
}
}
protected updateValue (value: number, updateVisuals: boolean = null, dispatchInput: boolean = true) {
const restrictedValue = this.restrict(value)
if (restrictedValue === this.value && updateVisuals !== true) return false
this.value = restrictedValue
this.el.value = String(restrictedValue)
this.thumb.setAttribute('aria-valuenow', this.el.value)
if (updateVisuals !== false) {
this.updateProgress()
}
if (dispatchInput) {
this.dispatch('input')
}
return true
}
protected updateProgress () {
const progress = (this.value - this.min) / this.computed.range * 100
if (this.options.orientation === 'horizontal') {
this.thumb.style.left = `${progress}%`
this.activeTrack.style.width = `${progress}%`
} else {
this.thumb.style.bottom = `${progress}%`
this.activeTrack.style.height = `${progress}%`
}
}
protected redirectLabels = () => {
this.thumb.focus()
}
protected init () {
this.el.classList.add('rangeslider--hidden')
this.originalTabIndex = this.el.tabIndex
this.el.tabIndex = -1
this.dom = document.createElement('div')
this.dom.classList.add('rangeslider')
this.dom.classList.add(`rangeslider--${this.options.orientation}`)
this.thumb = document.createElement('span')
this.thumb.setAttribute('role', 'slider')
this.thumb.classList.add('rangeslider__thumb')
this.thumb.tabIndex = 0
this.thumb.innerHTML = '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApocMXEAAAAASUVORK5CYII=">'
if (this.el.id) {
const labelledBy = []
const labelTargetId = this.el.id
.replace(/\n/g, '\\00000a')
.replace(/"/g, '\\000022')
const labels = document.querySelectorAll(`label[id][for="${labelTargetId}"]`)
if (labels.length > 0) {
for (let i = 0; i < labels.length; i++) {
labelledBy.push(labels[i].id)
}
this.thumb.setAttribute('aria-labelledby', labelledBy.join(' '))
}
}
this.thumb.addEventListener('blur', () => {
this.dom.classList.remove('rangeslider--keyboard-navigating')
})
this.thumb.addEventListener('focus', () => {
if (tabWasJustPressed) {
this.dom.classList.add('rangeslider--keyboard-navigating')
}
})
this.thumb.addEventListener('keydown', evt => {
let changed
switch (evt.keyCode) {
// Arrow left & down
case 37:
case 40:
changed = this.updateValue(this.value - this.step)
break
// Arrow right & up
case 39:
case 38:
changed = this.updateValue(this.value + this.step)
break
// Page down
case 34:
changed = this.updateValue(this.value - Math.max(this.step, this.computed.range / 10))
break
// Page up
case 33:
changed = this.updateValue(this.value + Math.max(this.step, this.computed.range / 10))
break
// Home
case 36:
changed = this.updateValue(this.min)
break
// End
case 35:
changed = this.updateValue(this.computed.realmax)
break
default: return
}
evt.preventDefault()
this.dom.classList.add('rangeslider--keyboard-navigating')
if (changed) {
this.dispatch('change')
}
})
this.track = document.createElement('div')
this.track.classList.add('rangeslider__track')
this.activeTrack = document.createElement('div')
this.activeTrack.classList.add('rangeslider__active-track')
this.track.appendChild(this.activeTrack)
this.dom.appendChild(this.track)
this.dom.appendChild(this.thumb)
let tabWasJustPressed = false
let tabWasJustPressedTimeout = null
document.addEventListener('keydown', evt => {
if (evt.keyCode === 9) {
if (tabWasJustPressedTimeout !== null) {
clearTimeout(tabWasJustPressedTimeout)
}
tabWasJustPressedTimeout = setTimeout(() => {
tabWasJustPressed = false
}, 0)
tabWasJustPressed = true
}
})
this.el.insertAdjacentElement('afterend', this.dom)
this.updateProgress()
this.dom.classList[this.disabled ? 'add' : 'remove']('rangeslider--disabled')
this.addTouchHandlers()
this.addMouseHandlers()
}
public destroy () {
this.el.classList.remove('rangeslider--hidden')
this.el.tabIndex = this.originalTabIndex
this.dom.parentNode.removeChild(this.dom)
if (this.options.assignToProperty) {
delete this.el[this.options.assignToProperty]
this.el.removeEventListener('focus', this.redirectLabels)
}
}
protected addMouseHandlers () {
let lowerBoundingPos: number
let upperBoundingPos: number
let oldValue: number
const mouseupHandler = evt => {
if (oldValue !== this.value) {
this.dispatch('change')
}
window.removeEventListener('mousemove', mousemoveHandler)
window.removeEventListener('mouseup', mouseupHandler)
}
const mousemoveHandler = evt => {
const fraction = (evt[this.computed.offsetProp] - lowerBoundingPos) / (upperBoundingPos - lowerBoundingPos)
const value = fraction * this.computed.range + this.min
this.updateValue(value)
}
const mousedownHandler = evt => {
evt.preventDefault()
evt.stopPropagation()
this.thumb.focus()
oldValue = this.value
const clientRect = this.dom.getBoundingClientRect()
if (this.options.orientation === 'horizontal') {
lowerBoundingPos = clientRect.left
upperBoundingPos = clientRect.right
} else {
lowerBoundingPos = clientRect.bottom
upperBoundingPos = clientRect.top
}
window.addEventListener('mousemove', mousemoveHandler)
window.addEventListener('mouseup', mouseupHandler)
}
this.thumb.addEventListener('mousedown', mousedownHandler)
this.dom.addEventListener('mousedown', evt => {
mousedownHandler(evt)
mousemoveHandler(evt)
})
}
protected addTouchHandlers () {
let lowerBoundingPos: number
let upperBoundingPos: number
let oldValue: number
const touchendHandler = evt => {
if (evt.touches.length > 1) return
if (oldValue !== this.value) {
this.dispatch('change')
}
window.removeEventListener('touchmove', touchmoveHandler)
window.removeEventListener('touchend', touchendHandler)
}
const touchmoveHandler = evt => {
if (evt.touches.length > 1) return
const fraction = (evt.touches[0][this.computed.offsetProp] - lowerBoundingPos) / (upperBoundingPos - lowerBoundingPos)
const value = fraction * this.computed.range - this.min
this.updateValue(value)
}
const touchstartHandler = evt => {
if (evt.touches.length > 1) return
oldValue = this.value
const clientRect = this.dom.getBoundingClientRect()
if (this.options.orientation === 'horizontal') {
lowerBoundingPos = clientRect.left
upperBoundingPos = clientRect.right
} else {
lowerBoundingPos = clientRect.bottom
upperBoundingPos = clientRect.top
}
window.addEventListener('touchmove', touchmoveHandler)
window.addEventListener('touchend', touchendHandler)
}
this.thumb.addEventListener('touchstart', touchstartHandler)
this.dom.addEventListener('touchstart', evt => {
touchstartHandler(evt)
touchmoveHandler(evt)
})
}
}
// Demo code
const transformed: HTMLInputElement = document.querySelector('#transformed') as HTMLInputElement
const rangeslider = new Rangeslider(transformed)
transformed.addEventListener('input', evt => {
demoValue.value = transformed.value
})
demoValue.addEventListener('input', evt => {
transformed.value = demoValue.value
rangeslider.refresh()
})
demoMin.addEventListener('input', evt => {
transformed.min = demoMin.value
rangeslider.refresh()
demoValue.value = transformed.value
demoValue.min = demoMin.value
})
demoMax.addEventListener('input', evt => {
transformed.max = demoMax.value
rangeslider.refresh()
demoValue.value = transformed.value
demoValue.max = demoMax.value
})
demoStep.addEventListener('input', evt => {
transformed.step = demoStep.value
rangeslider.refresh()
demoValue.value = transformed.value
demoValue.step = demoStep.value
})
demoDisabled.addEventListener('change', evt => {
transformed.disabled = demoDisabled.checked
rangeslider.refresh()
})
View Compiled