<h2>Rangeslider</h2>

<p>A minimal range slider implementation. See <a href="https://gist.github.com/Loilo/b37e941bbab05bcfe2a2c40df2c21612" target="_blank">this Gist</a> for more information.</p>

<div id="slider">
  <label for="transformed">Check it out:</label>
  <input id="transformed" type="range" value=50 min=0 max=100>
</div>

<div id="properties">
  <p>Change the following properties to adjust the slider:</p>
  <div><label><input type="number" id="demoValue" value="50" step="1" min="0" max="100"> <code>value</code></label></div>
  <div><label><input type="number" id="demoMin" value="0"> <code>min</code></label></div>
  <div><label><input type="number" id="demoMax" value="100"> <code>max</code></label></div>
  <div><label><input type="number" id="demoStep" value="1"> <code>step</code></label></div>
  <div><label><input type="checkbox" id="demoDisabled" value="0"> <code>disabled</code></label></div>
</div>
$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

External CSS

  1. https://fonts.googleapis.com/css?family=Roboto|Roboto+Mono

External JavaScript

  1. https://cdnjs.cloudflare.com/polyfill/v2/polyfill.min.js