blob: b93fea0623724b153dbea0bd646e817952db4232 [file] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap range.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
/**
* Constants
*/
const NAME = 'range'
const DATA_KEY = 'bs.range'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_CHANGED = `changed${EVENT_KEY}`
const EVENT_DOM_CONTENT_LOADED = `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`
// `input` is not in EventHandler's native-event list, so it can't be namespaced; bind it raw
const EVENT_INPUT = 'input'
const EVENT_CHANGE = 'change'
const SELECTOR_RANGE = '.form-range'
const SELECTOR_INPUT = '.form-range-input'
const CLASS_NAME_BUBBLE = 'form-range-bubble'
const CLASS_NAME_TICKS = 'form-range-ticks'
const CLASS_NAME_TICK = 'form-range-tick'
const CLASS_NAME_TICK_LABEL = 'form-range-tick-label'
// Shipped (`--bs-`-prefixed) custom properties; the build prefixes the SCSS tokens, so the
// plugin must write the prefixed names to interoperate with the rendered CSS.
const PROPERTY_FILL = '--bs-range-fill'
const Default = {
bubble: false, // Show a value bubble above the thumb
formatter: null // (value) => string, for the bubble and tick labels
}
const DefaultType = {
bubble: '(boolean|null)',
formatter: '(function|null)'
}
/**
* Class definition
*/
class Range extends BaseComponent {
constructor(element, config) {
super(element, config)
// BaseComponent bails (no `_element`) when the element can't be resolved
if (!this._element) {
return
}
this._input = SelectorEngine.findOne(SELECTOR_INPUT, this._element)
if (!this._input) {
return
}
this._bubble = null
this._bubbleText = null
this._ticks = null
this._updateHandler = () => this._update()
if (this._config.bubble) {
this._createBubble()
}
this._createTicks()
this._addEventListeners()
this._update()
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
update() {
this._update()
}
dispose() {
EventHandler.off(this._input, EVENT_INPUT, this._updateHandler)
EventHandler.off(this._input, EVENT_CHANGE, this._updateHandler)
this._bubble?.remove()
this._ticks?.remove()
super.dispose()
}
// Private
_configAfterMerge(config) {
// A bare `data-bs-bubble` attribute normalizes to `null`; treat it as enabled
if (config.bubble === null) {
config.bubble = true
}
return config
}
_addEventListeners() {
EventHandler.on(this._input, EVENT_INPUT, this._updateHandler)
EventHandler.on(this._input, EVENT_CHANGE, this._updateHandler)
}
_min() {
return this._input.min === '' ? 0 : Number.parseFloat(this._input.min)
}
_max() {
return this._input.max === '' ? 100 : Number.parseFloat(this._input.max)
}
_value() {
return Number.parseFloat(this._input.value)
}
_ratio() {
const span = this._max() - this._min()
return span > 0 ? (this._value() - this._min()) / span : 0
}
_update() {
// The fill ratio drives the track gradient and the bubble/tick positions, all in CSS
this._element.style.setProperty(PROPERTY_FILL, `${this._ratio()}`)
if (this._bubbleText) {
this._bubbleText.textContent = this._format(this._value())
}
EventHandler.trigger(this._input, EVENT_CHANGED, { value: this._value() })
}
_format(value) {
return typeof this._config.formatter === 'function' ? this._config.formatter(value) : String(value)
}
_createBubble() {
// Reuse the tooltip markup so we don't duplicate the pill and arrow styles
this._bubble = document.createElement('output')
this._bubble.className = `${CLASS_NAME_BUBBLE} tooltip bs-tooltip-top show`
this._bubble.setAttribute('aria-hidden', 'true')
// Match the Tooltip template's block-level markup: `.tooltip-inner` has no `display` rule,
// so an inline `<span>` would let its padding bleed outside the bubble and clip the arrow.
const arrow = document.createElement('div')
arrow.className = 'tooltip-arrow'
this._bubbleText = document.createElement('div')
this._bubbleText.className = 'tooltip-inner'
this._bubble.append(arrow, this._bubbleText)
this._input.insertAdjacentElement('afterend', this._bubble)
}
_createTicks() {
const listId = this._input.getAttribute('list')
const datalist = listId ? document.getElementById(listId) : null
if (!datalist) {
return
}
const min = this._min()
const span = this._max() - min || 1
const points = []
for (const option of SelectorEngine.find('option', datalist)) {
const value = Number.parseFloat(option.value)
if (!Number.isNaN(value)) {
// Clamp to [0, 1] so out-of-range options can't produce negative `fr` tracks
const ratio = Math.min(Math.max((value - min) / span, 0), 1)
points.push({ ratio, label: option.label })
}
}
if (points.length === 0) {
return
}
points.sort((a, b) => a.ratio - b.ratio)
this._ticks = document.createElement('div')
this._ticks.className = CLASS_NAME_TICKS
this._ticks.setAttribute('aria-hidden', 'true')
// Columns are the gaps between 0, each tick, and 1, so every tick lands on a grid line
const stops = [0, ...points.map(point => point.ratio), 1]
this._ticks.style.gridTemplateColumns = stops.slice(1).map((stop, index) => `${stop - stops[index]}fr`).join(' ')
for (const [index, point] of points.entries()) {
const tick = document.createElement('span')
tick.className = CLASS_NAME_TICK
tick.style.gridColumnStart = `${index + 2}`
if (point.label) {
const label = document.createElement('span')
label.className = CLASS_NAME_TICK_LABEL
label.textContent = point.label
tick.append(label)
}
this._ticks.append(tick)
}
this._element.append(this._ticks)
}
}
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_DOM_CONTENT_LOADED, () => {
for (const element of SelectorEngine.find(SELECTOR_RANGE)) {
Range.getOrCreateInstance(element)
}
})
export default Range