@coreui/coreui-pro
Version:
The most popular front-end framework for developing responsive, mobile-first projects on the web rewritten by the CoreUI Team
646 lines (512 loc) • 18.8 kB
JavaScript
/**
* --------------------------------------------------------------------------
* CoreUI PRO range-slider.js
* License (https://coreui.io/pro/license/)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import Manipulator from './dom/manipulator.js'
import SelectorEngine from './dom/selector-engine.js'
import { defineJQueryPlugin, isRTL } from './util/index.js'
/**
* Constants
*/
const NAME = 'range-slider'
const DATA_KEY = 'coreui.range-slider'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_CHANGE = `change${EVENT_KEY}`
const EVENT_INPUT = 'input'
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
const EVENT_MOUSEDOWN = `mousedown${EVENT_KEY}`
const EVENT_MOUSEMOVE = `mousemove${EVENT_KEY}`
const EVENT_MOUSEUP = `mouseup${EVENT_KEY}`
const EVENT_RESIZE = `resize${EVENT_KEY}`
const CLASS_NAME_CLICKABLE = 'clickable'
const CLASS_NAME_DISABLED = 'disabled'
const CLASS_NAME_RANGE_SLIDER = 'range-slider'
const CLASS_NAME_RANGE_SLIDER_INPUT = 'range-slider-input'
const CLASS_NAME_RANGE_SLIDER_INPUTS_CONTAINER = 'range-slider-inputs-container'
const CLASS_NAME_RANGE_SLIDER_LABEL = 'range-slider-label'
const CLASS_NAME_RANGE_SLIDER_LABELS_CONTAINER = 'range-slider-labels-container'
const CLASS_NAME_RANGE_SLIDER_TOOLTIP = 'range-slider-tooltip'
const CLASS_NAME_RANGE_SLIDER_TOOLTIP_ARROW = 'range-slider-tooltip-arrow'
const CLASS_NAME_RANGE_SLIDER_TOOLTIP_INNER = 'range-slider-tooltip-inner'
const CLASS_NAME_RANGE_SLIDER_TRACK = 'range-slider-track'
const CLASS_NAME_RANGE_SLIDER_VERTICAL = 'range-slider-vertical'
const SELECTOR_DATA_TOGGLE = '[data-coreui-toggle="range-slider"]'
const SELECTOR_RANGE_SLIDER_INPUT = '.range-slider-input'
const SELECTOR_RANGE_SLIDER_INPUTS_CONTAINER = '.range-slider-inputs-container'
const SELECTOR_RANGE_SLIDER_LABEL = '.range-slider-label'
const SELECTOR_RANGE_SLIDER_LABELS_CONTAINER = '.range-slider-labels-container'
const Default = {
clickableLabels: true,
disabled: false,
distance: 0,
labels: false,
max: 100,
min: 0,
name: null,
step: 1,
tooltips: true,
tooltipsFormat: null,
track: 'fill',
value: 0,
vertical: false
}
const DefaultType = {
clickableLabels: 'boolean',
disabled: 'boolean',
distance: 'number',
labels: '(array|boolean|string)',
max: 'number',
min: 'number',
name: '(array|string|null)',
step: '(number|string)',
tooltips: 'boolean',
tooltipsFormat: '(function|null)',
track: '(boolean|string)',
value: '(array|number)',
vertical: 'boolean'
}
/**
* Class definition
*/
class RangeSlider extends BaseComponent {
constructor(element, config) {
super(element)
this._config = this._getConfig(config)
this._currentValue = this._config.value
this._dragIndex = 0
this._inputs = []
this._isDragging = false
this._sliderTrack = null
this._thumbSize = null
this._tooltips = []
this._initializeRangeSlider()
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
update(config) {
this._config = this._getConfig(config)
this._currentValue = this._config.value
this._element.innerHTML = ''
this._initializeRangeSlider()
}
// Private
_addEventListeners() {
if (this._config.disabled) {
return
}
EventHandler.on(this._element, EVENT_INPUT, SELECTOR_RANGE_SLIDER_INPUT, event => {
const { target } = event
this._isDragging = false
const children = SelectorEngine.children(target.parentElement, SELECTOR_RANGE_SLIDER_INPUT)
const index = Array.from(children).indexOf(target)
this._updateValue(target.value, index)
})
EventHandler.on(this._element, EVENT_MOUSEDOWN, SELECTOR_RANGE_SLIDER_LABEL, event => {
if (!this._config.clickableLabels || event.button !== 0) {
return
}
const value = Manipulator.getDataAttribute(event.target, 'value')
this._updateNearestValue(value)
})
EventHandler.on(this._element, EVENT_MOUSEDOWN, SELECTOR_RANGE_SLIDER_INPUTS_CONTAINER, event => {
if (event.button !== 0) {
return
}
if (!(event.target instanceof HTMLInputElement) && !event.target.className.includes(CLASS_NAME_RANGE_SLIDER_TRACK)) {
return
}
this._isDragging = true
const clickValue = this._calculateClickValue(event)
this._dragIndex = this._getNearestValueIndex(clickValue)
this._updateNearestValue(clickValue)
})
EventHandler.on(document.documentElement, EVENT_MOUSEUP, () => {
this._isDragging = false
})
EventHandler.on(document.documentElement, EVENT_MOUSEMOVE, event => {
if (!this._isDragging) {
return
}
const moveValue = this._calculateMoveValue(event)
this._updateValue(moveValue, this._dragIndex)
})
EventHandler.on(window, EVENT_RESIZE, () => {
this._updateLabelsContainerSize()
})
}
_initializeRangeSlider() {
this._element.classList.add(CLASS_NAME_RANGE_SLIDER)
if (this._config.vertical) {
this._element.classList.add(CLASS_NAME_RANGE_SLIDER_VERTICAL)
}
if (this._config.disabled) {
this._element.classList.add(CLASS_NAME_DISABLED)
}
this._sliderTrack = this._createSliderTrack()
this._createInputs()
this._createLabels()
this._updateLabelsContainerSize()
this._createTooltips()
this._updateGradient()
this._addEventListeners()
}
_createSliderTrack() {
const sliderTrackElement = this._createElement('div', CLASS_NAME_RANGE_SLIDER_TRACK)
return sliderTrackElement
}
_createInputs() {
const container = this._createElement('div', CLASS_NAME_RANGE_SLIDER_INPUTS_CONTAINER)
for (const [index, value] of this._currentValue.entries()) {
const inputElement = this._createInput(index, value)
container.append(inputElement)
this._inputs[index] = inputElement
}
container.append(this._sliderTrack)
this._element.append(container)
}
_createInput(index, value) {
const inputElement = this._createElement('input', CLASS_NAME_RANGE_SLIDER_INPUT)
inputElement.type = 'range'
inputElement.min = this._config.min
inputElement.max = this._config.max
inputElement.step = this._config.step
inputElement.value = value
inputElement.name = Array.isArray(this._config.name) ?
`${this._config.name[index]}` :
`${this._config.name || ''}-${index}`
inputElement.disabled = this._config.disabled
// Accessibility attributes
inputElement.setAttribute('role', 'slider')
inputElement.setAttribute('aria-valuemin', this._config.min)
inputElement.setAttribute('aria-valuemax', this._config.max)
inputElement.setAttribute('aria-valuenow', value)
inputElement.setAttribute('aria-orientation', this._config.vertical ? 'vertical' : 'horizontal')
return inputElement
}
_createLabels() {
const { clickableLabels, disabled, labels, min, max, vertical } = this._config
if (!labels || !Array.isArray(labels) || labels.length === 0) {
return
}
const labelsContainer = this._createElement('div', CLASS_NAME_RANGE_SLIDER_LABELS_CONTAINER)
for (const [index, label] of this._config.labels.entries()) {
const labelElement = this._createElement('div', CLASS_NAME_RANGE_SLIDER_LABEL)
if (clickableLabels && !disabled) {
labelElement.classList.add(CLASS_NAME_CLICKABLE)
}
if (label.class) {
const classNames = Array.isArray(label.class) ? label.class : [label.class]
labelElement.classList.add(...classNames)
}
if (label.style && typeof label.style === 'object') {
Object.assign(labelElement.style, label.style)
}
// Calculate percentage based on index
const percentage = labels.length === 1 ? 0 : (index / (labels.length - 1)) * 100
// Determine label value
const labelValue = typeof label === 'object' ? label.value : min + ((percentage / 100) * (max - min))
// Set data-coreui-value attribute
Manipulator.setDataAttribute(labelElement, 'value', labelValue)
// Set label content
labelElement.textContent = typeof label === 'object' ? label.label : label
// Calculate and set position
const position = this._calculateLabelPosition(label, index, percentage)
if (vertical) {
labelElement.style.bottom = position
} else {
labelElement.style[isRTL() ? 'right' : 'left'] = position
}
labelsContainer.append(labelElement)
}
this._element.append(labelsContainer)
}
_calculateLabelPosition(label, index) {
// Check if label is an object with a specific value
if (typeof label === 'object' && label.value !== undefined) {
return `${((label.value - this._config.min) / (this._config.max - this._config.min)) * 100}%`
}
// Calculate position based on index when label is not an object
return `${(index / (this._config.labels.length - 1)) * 100}%`
}
_updateLabelsContainerSize() {
const labelsContainer = SelectorEngine.findOne(SELECTOR_RANGE_SLIDER_LABELS_CONTAINER, this._element)
if (!this._config.labels || !labelsContainer) {
return
}
const labels = SelectorEngine.find(SELECTOR_RANGE_SLIDER_LABEL, this._element)
if (labels.length === 0) {
return
}
const maxSize = Math.max(...labels.map(label => (this._config.vertical ? label.offsetWidth : label.offsetHeight)))
labelsContainer.style[this._config.vertical ? 'width' : 'height'] = `${maxSize}px`
}
_createTooltips() {
if (!this._config.tooltips) {
return
}
const inputs = SelectorEngine.find(SELECTOR_RANGE_SLIDER_INPUT, this._element)
this._thumbSize = this._getThumbSize()
for (const input of inputs) {
const tooltipElement = this._createElement('div', CLASS_NAME_RANGE_SLIDER_TOOLTIP)
const tooltipInnerElement = this._createElement('div', CLASS_NAME_RANGE_SLIDER_TOOLTIP_INNER)
const tooltipArrowElement = this._createElement('div', CLASS_NAME_RANGE_SLIDER_TOOLTIP_ARROW)
tooltipInnerElement.innerHTML = this._config.tooltipsFormat ? this._config.tooltipsFormat(input.value) : input.value
tooltipElement.append(tooltipInnerElement, tooltipArrowElement)
input.parentNode.insertBefore(tooltipElement, input.nextSibling)
this._positionTooltip(tooltipElement, input)
this._tooltips.push(tooltipElement)
}
}
_getThumbSize() {
const value = window
.getComputedStyle(this._element, null)
.getPropertyValue(
this._config.vertical ? '--cui-range-slider-thumb-height' : '--cui-range-slider-thumb-width'
)
const regex = /^(\d+\.?\d*)([%a-z]*)$/i
const match = value.match(regex)
if (match) {
return {
value: Number.parseFloat(match[1]),
unit: match[2] || null
}
}
return '1rem'
}
_positionTooltip(tooltip, input) {
const thumbSize = this._thumbSize
const percent = (input.value - this._config.min) / (this._config.max - this._config.min)
const margin = percent > 0.5 ?
`-${(percent - 0.5) * thumbSize.value}${thumbSize.unit}` :
`${(0.5 - percent) * thumbSize.value}${thumbSize.unit}`
if (this._config.vertical) {
Object.assign(tooltip.style, {
bottom: `${percent * 100}%`,
marginBottom: margin
})
return
}
Object.assign(tooltip.style, { insetInlineStart: `${percent * 100}%`, marginInlineStart: margin })
}
_updateTooltip(index, value) {
if (!this._config.tooltips) {
return
}
if (this._tooltips[index]) {
this._tooltips[index].children[0].innerHTML = this._config.tooltipsFormat ? this._config.tooltipsFormat(value) : value
const input = SelectorEngine.find(SELECTOR_RANGE_SLIDER_INPUT, this._element)[index]
this._positionTooltip(this._tooltips[index], input)
}
}
_calculateClickValue(event) {
const clickPosition = this._getClickPosition(event)
const value = this._config.min + (clickPosition * (this._config.max - this._config.min))
return this._roundToStep(value, this._config.step)
}
_calculateMoveValue(event) {
const trackRect = this._sliderTrack.getBoundingClientRect()
const position = this._config.vertical ?
this._calculateVerticalPosition(event.clientY, trackRect) :
this._calculateHorizontalPosition(event.clientX, trackRect)
if (typeof position === 'string') {
return position === 'max' ? this._config.max : this._config.min
}
const value = this._config.min + (position * (this._config.max - this._config.min))
return this._roundToStep(value, this._config.step)
}
_calculateVerticalPosition(mouseY, rect) {
if (mouseY < rect.top) {
return 'max'
}
if (mouseY > rect.bottom) {
return 'min'
}
return Math.min(Math.max((rect.bottom - mouseY) / rect.height, 0), 1)
}
_calculateHorizontalPosition(mouseX, rect) {
if (mouseX < rect.left) {
return isRTL() ? 'max' : 'min'
}
if (mouseX > rect.right) {
return isRTL() ? 'min' : 'max'
}
const relativeX = isRTL() ? rect.right - mouseX : mouseX - rect.left
return Math.min(Math.max(relativeX / rect.width, 0), 1)
}
_createElement(tag, className) {
const element = document.createElement(tag)
element.classList.add(className)
return element
}
_getClickPosition(event) {
const { offsetX, offsetY } = event
const { offsetWidth, offsetHeight } = this._sliderTrack
if (this._config.vertical) {
return 1 - (offsetY / offsetHeight)
}
return isRTL() ? 1 - (offsetX / offsetWidth) : offsetX / offsetWidth
}
_getNearestValueIndex(value) {
const values = this._currentValue
const valuesLength = values.length
if (value < values[0]) {
return 0
}
if (value > values[valuesLength - 1]) {
return valuesLength - 1
}
const distances = values.map(v => Math.abs(v - value))
const min = Math.min(...distances)
const firstIndex = distances.indexOf(min)
return value < values[firstIndex] ? firstIndex : distances.lastIndexOf(min)
}
_updateGradient() {
if (!this._config.track) {
return
}
const [min, max] = [Math.min(...this._currentValue), Math.max(...this._currentValue)]
const from = ((min - this._config.min) / (this._config.max - this._config.min)) * 100
const to = ((max - this._config.min) / (this._config.max - this._config.min)) * 100
const direction = this._config.vertical ? 'to top' : (isRTL() ? 'to left' : 'to right')
if (this._currentValue.length === 1) {
this._sliderTrack.style.backgroundImage = `linear-gradient(
${direction},
var(--cui-range-slider-track-in-range-bg) 0%,
var(--cui-range-slider-track-in-range-bg) ${to}%,
transparent ${to}%,
transparent 100%
)`
return
}
this._sliderTrack.style.backgroundImage = `linear-gradient(
${direction},
transparent 0%,
transparent ${from}%,
var(--cui-range-slider-track-in-range-bg) ${from}%,
var(--cui-range-slider-track-in-range-bg) ${to}%,
transparent ${to}%,
transparent 100%
)`
}
_updateNearestValue(value) {
const nearestIndex = this._getNearestValueIndex(value)
this._updateValue(value, nearestIndex)
}
_updateValue(value, index) {
const _value = this._validateValue(value, index)
this._currentValue[index] = _value
this._updateInput(index, _value)
this._updateGradient()
this._updateTooltip(index, _value)
EventHandler.trigger(this._element, EVENT_CHANGE, { value: this._currentValue })
}
_updateInput(index, value) {
const input = this._inputs[index]
input.value = value
input.setAttribute('aria-valuenow', value)
setTimeout(() => {
input.focus()
})
}
_validateValue(value, index) {
const { distance } = this._config
const { length } = this._currentValue
if (length === 1) {
return value
}
const prevValue = index > 0 ? this._currentValue[index - 1] : undefined
const nextValue = index < length - 1 ? this._currentValue[index + 1] : undefined
if (index === 0 && nextValue !== undefined) {
return Math.min(value, nextValue - distance)
}
if (index === length - 1 && prevValue !== undefined) {
return Math.max(value, prevValue + distance)
}
if (prevValue !== undefined && nextValue !== undefined) {
const minVal = prevValue + distance
const maxVal = nextValue - distance
return Math.min(Math.max(value, minVal), maxVal)
}
return value
}
_roundToStep(number, step) {
const _step = step === 0 ? 1 : step
return Math.round(number / _step) * _step
}
_configAfterMerge(config) {
if (typeof config.labels === 'string') {
config.labels = config.labels.split(/,\s*/)
}
if (typeof config.name === 'string') {
config.name = config.name.split(/,\s*/)
}
if (typeof config.value === 'number') {
config.value = [config.value]
}
if (typeof config.value === 'string') {
config.value = config.value.split(/,\s*/).map(Number)
}
return config
}
_getConfig(config) {
const dataAttributes = Manipulator.getDataAttributes(this._element)
config = {
...dataAttributes,
...(typeof config === 'object' && config ? config : {})
}
config = this._mergeConfigObj(config)
config = this._configAfterMerge(config)
this._typeCheckConfig(config)
return config
}
// Static
static rangeSliderInterface(element, config) {
const data = RangeSlider.getOrCreateInstance(element, config)
if (typeof config === 'string') {
if (typeof data[config] === 'undefined') {
throw new TypeError(`No method named "${config}"`)
}
data[config]()
}
}
static jQueryInterface(config) {
return this.each(function () {
const data = RangeSlider.getOrCreateInstance(this)
if (typeof config !== 'string') {
return
}
if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
throw new TypeError(`No method named "${config}"`)
}
data[config](this)
})
}
}
/**
* Data API implementation
*/
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
const ratings = SelectorEngine.find(SELECTOR_DATA_TOGGLE)
for (let i = 0, len = ratings.length; i < len; i++) {
RangeSlider.rangeSliderInterface(ratings[i])
}
})
/**
* jQuery
*/
defineJQueryPlugin(RangeSlider)
export default RangeSlider