|
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) |
|
}) |
|
} |
|
} |