diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json
index cd2034c8f8fd..fc4f3a119348 100644
--- a/.bundlewatch.config.json
+++ b/.bundlewatch.config.json
@@ -34,19 +34,19 @@
},
{
"path": "./dist/js/bootstrap.bundle.js",
- "maxSize": "80.25 kB"
+ "maxSize": "82.0 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
- "maxSize": "52.25 kB"
+ "maxSize": "53.25 kB"
},
{
"path": "./dist/js/bootstrap.js",
- "maxSize": "51.5 kB"
+ "maxSize": "53.25 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
- "maxSize": "30.25 kB"
+ "maxSize": "31.25 kB"
}
],
"ci": {
diff --git a/js/index.js b/js/index.js
index 7030887c229d..b40e647c33dd 100644
--- a/js/index.js
+++ b/js/index.js
@@ -19,6 +19,7 @@ export { default as Strength } from './src/strength.js'
export { default as OtpInput } from './src/otp-input.js'
export { default as Chips } from './src/chips.js'
export { default as Popover } from './src/popover.js'
+export { default as Range } from './src/range.js'
export { default as ScrollSpy } from './src/scrollspy.js'
export { default as Tab } from './src/tab.js'
export { default as Toast } from './src/toast.js'
diff --git a/js/src/range.js b/js/src/range.js
new file mode 100644
index 000000000000..6c1d90ee4c4f
--- /dev/null
+++ b/js/src/range.js
@@ -0,0 +1,236 @@
+/**
+ * --------------------------------------------------------------------------
+ * 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')
+
+ const arrow = document.createElement('span')
+ arrow.className = 'tooltip-arrow'
+ this._bubbleText = document.createElement('span')
+ 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)) {
+ points.push({ ratio: (value - min) / span, 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
diff --git a/js/tests/unit/range.spec.js b/js/tests/unit/range.spec.js
new file mode 100644
index 000000000000..73a8da0365a5
--- /dev/null
+++ b/js/tests/unit/range.spec.js
@@ -0,0 +1,304 @@
+import Range from '../../src/range.js'
+import { clearFixture, createEvent, getFixture } from '../helpers/fixture.js'
+
+describe('Range', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ const getRangeHtml = (wrapperAttributes = '', inputAttributes = '') => {
+ return `
+
+
+
+ `
+ }
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Range.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Range.DATA_KEY).toEqual('bs.range')
+ })
+ })
+
+ describe('Default', () => {
+ it('should return default config', () => {
+ expect(Range.Default).toEqual(jasmine.any(Object))
+ expect(Range.Default.bubble).toBeFalse()
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return default type config', () => {
+ expect(Range.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const rangeBySelector = new Range('.form-range')
+ expect(rangeBySelector._element).toEqual(rangeEl)
+
+ rangeBySelector.dispose()
+
+ const rangeByElement = new Range(rangeEl)
+ expect(rangeByElement._element).toEqual(rangeEl)
+ })
+
+ it('should find the range input inside the wrapper', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const inputEl = fixtureEl.querySelector('.form-range-input')
+ const range = new Range(rangeEl)
+
+ expect(range._input).toEqual(inputEl)
+ })
+
+ it('should set the --bs-range-fill custom property on init', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ expect(rangeEl.style.getPropertyValue('--bs-range-fill')).toEqual('0.5')
+ })
+
+ it('should honor min/max when computing the fill ratio', () => {
+ fixtureEl.innerHTML = `
+
+
+
+ `
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ expect(rangeEl.style.getPropertyValue('--bs-range-fill')).toEqual('0.25')
+ })
+
+ it('should do nothing when there is no range input', () => {
+ fixtureEl.innerHTML = ''
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(range._input).toBeNull()
+ })
+ })
+
+ describe('update', () => {
+ it('should update the --bs-range-fill custom property on input', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const inputEl = fixtureEl.querySelector('.form-range-input')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ inputEl.value = '75'
+ inputEl.dispatchEvent(createEvent('input'))
+
+ expect(rangeEl.style.getPropertyValue('--bs-range-fill')).toEqual('0.75')
+ })
+ })
+
+ describe('bubble', () => {
+ it('should create a tooltip-based value bubble when enabled', () => {
+ fixtureEl.innerHTML = getRangeHtml('data-bs-bubble')
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ const bubble = fixtureEl.querySelector('.form-range-bubble')
+ expect(bubble).not.toBeNull()
+ expect(bubble).toHaveClass('tooltip')
+ expect(bubble.querySelector('.tooltip-inner').textContent).toEqual('50')
+ })
+
+ it('should not create a bubble by default', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ expect(fixtureEl.querySelector('.form-range-bubble')).toBeNull()
+ })
+
+ it('should update the bubble text on input', () => {
+ fixtureEl.innerHTML = getRangeHtml('data-bs-bubble')
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const inputEl = fixtureEl.querySelector('.form-range-input')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ inputEl.value = '80'
+ inputEl.dispatchEvent(createEvent('input'))
+
+ expect(fixtureEl.querySelector('.tooltip-inner').textContent).toEqual('80')
+ })
+
+ it('should format the bubble text with a custom formatter', () => {
+ fixtureEl.innerHTML = getRangeHtml('data-bs-bubble')
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl, { formatter: value => `${value}%` }) // eslint-disable-line no-new
+
+ expect(fixtureEl.querySelector('.tooltip-inner').textContent).toEqual('50%')
+ })
+ })
+
+ describe('ticks', () => {
+ const getTicksHtml = () => {
+ return `
+
+
+
+
+ `
+ }
+
+ it('should render a tick for each datalist option', () => {
+ fixtureEl.innerHTML = getTicksHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ const ticks = fixtureEl.querySelectorAll('.form-range-tick')
+ expect(ticks.length).toEqual(3)
+ })
+
+ it('should place each tick on a grid line via grid-template-columns (handles uneven values)', () => {
+ fixtureEl.innerHTML = getTicksHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ // datalist values 0/10/100 -> gaps between 0, .1, 1, and 1
+ const ticksEl = fixtureEl.querySelector('.form-range-ticks')
+ expect(ticksEl.style.gridTemplateColumns).toEqual('0fr 0.1fr 0.9fr 0fr')
+
+ const ticks = fixtureEl.querySelectorAll('.form-range-tick')
+ expect(ticks[0].style.gridColumnStart).toEqual('2')
+ expect(ticks[1].style.gridColumnStart).toEqual('3')
+ expect(ticks[2].style.gridColumnStart).toEqual('4')
+ })
+
+ it('should render labels from the option label only', () => {
+ fixtureEl.innerHTML = getTicksHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ const labels = fixtureEl.querySelectorAll('.form-range-tick-label')
+ expect(labels.length).toEqual(2)
+ expect(labels[0].textContent).toEqual('Low')
+ expect(labels[1].textContent).toEqual('High')
+ })
+
+ it('should do nothing when there is no linked datalist', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(range._ticks).toBeNull()
+ expect(fixtureEl.querySelector('.form-range-ticks')).toBeNull()
+ })
+ })
+
+ describe('events', () => {
+ it('should trigger a changed event with the current value', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const inputEl = fixtureEl.querySelector('.form-range-input')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ inputEl.addEventListener('changed.bs.range', event => {
+ expect(event.value).toEqual(90)
+ resolve()
+ })
+
+ inputEl.value = '90'
+ inputEl.dispatchEvent(createEvent('input'))
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose the instance and remove decorations', () => {
+ fixtureEl.innerHTML = getRangeHtml('data-bs-bubble')
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(Range.getInstance(rangeEl)).not.toBeNull()
+ expect(fixtureEl.querySelector('.form-range-bubble')).not.toBeNull()
+
+ range.dispose()
+
+ expect(Range.getInstance(rangeEl)).toBeNull()
+ expect(fixtureEl.querySelector('.form-range-bubble')).toBeNull()
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return range instance', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(Range.getInstance(rangeEl)).toEqual(range)
+ expect(Range.getInstance(rangeEl)).toBeInstanceOf(Range)
+ })
+
+ it('should return null when there is no instance', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Range.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return existing instance', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(Range.getOrCreateInstance(rangeEl)).toEqual(range)
+ expect(Range.getOrCreateInstance(rangeEl)).toBeInstanceOf(Range)
+ })
+
+ it('should create new instance when none exists', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+
+ expect(Range.getInstance(rangeEl)).toBeNull()
+ expect(Range.getOrCreateInstance(rangeEl)).toBeInstanceOf(Range)
+ })
+ })
+})
diff --git a/scss/forms/_form-range.scss b/scss/forms/_form-range.scss
index a784b0dd2a46..2005d8274a51 100644
--- a/scss/forms/_form-range.scss
+++ b/scss/forms/_form-range.scss
@@ -6,6 +6,7 @@
@use "../mixins/gradients" as *;
@use "../mixins/tokens" as *;
+// stylelint-disable custom-property-no-missing-var-function
$range-tokens: () !default;
// scss-docs-start range-tokens
@@ -17,7 +18,8 @@ $range-tokens: defaults(
--range-track-cursor: pointer,
--range-track-bg: var(--bg-3),
--range-track-border-radius: 1rem,
- --range-track-box-shadow: var(--box-shadow-inset),
+ --range-track-fill-bg: var(--primary-base),
+ --range-track-disabled-bg: color-mix(in oklch, var(--bg-4), var(--fg-3)),
--range-thumb-width: 1rem,
--range-thumb-height: var(--range-thumb-width),
--range-thumb-bg: var(--primary-base),
@@ -29,10 +31,14 @@ $range-tokens: defaults(
--range-thumb-transition-property: "background-color, border-color, box-shadow",
--range-thumb-transition-timing: .15s ease-in-out,
--range-thumb-transition: var(--range-thumb-transition-property) var(--range-thumb-transition-timing),
+ --range-tick-width: var(--border-width),
+ --range-tick-height: .375rem,
+ --range-tick-bg: var(--border-color),
),
$range-tokens
);
// scss-docs-end range-tokens
+// stylelint-enable custom-property-no-missing-var-function
// scss-docs-start range-mixins
@mixin range-thumb() {
@@ -53,22 +59,37 @@ $range-tokens: defaults(
@mixin range-track() {
width: var(--range-track-width);
height: var(--range-track-height);
- color: transparent; // Why?
+ color: transparent;
cursor: var(--range-track-cursor);
+ // Fill (progress) up to the thumb. The Range plugin keeps `--range-fill` (0–1) in sync.
background-color: var(--range-track-bg);
- border-color: transparent; // Firefox specific?
+ background-image:
+ linear-gradient(
+ to right,
+ var(--range-track-fill-bg) calc(var(--range-fill, 0) * 100%),
+ transparent calc(var(--range-fill, 0) * 100%)
+ );
+ border-color: transparent;
@include border-radius(var(--range-track-border-radius));
@include box-shadow(var(--range-track-box-shadow));
}
// scss-docs-end range-mixins
@layer forms {
+ // Wrapper: owns the tokens (so the input, bubble, and ticks all inherit them)
+ // and is the positioning context for the bubble and ticks.
.form-range {
@include tokens($range-tokens);
+ position: relative;
+ display: block;
+ width: 100%;
+ }
+
+ .form-range-input {
width: 100%;
height: calc(var(--range-thumb-height) + (var(--focus-ring-width) * 2));
- padding: 0; // Need to reset padding
+ padding: 0;
appearance: none;
background-color: transparent;
@@ -126,6 +147,63 @@ $range-tokens: defaults(
&::-moz-range-thumb {
background-color: var(--range-thumb-disabled-bg);
}
+
+ &::-webkit-slider-runnable-track {
+ --range-track-fill-bg: var(--range-track-disabled-bg);
+ }
+
+ &::-moz-range-track {
+ --range-track-fill-bg: var(--range-track-disabled-bg);
+ }
}
}
+
+ // Value bubble: reuses the tooltip styles (`.tooltip` markup) so we don't duplicate the
+ // pill and arrow. We only add the static positioning the Tooltip plugin would normally do.
+ .form-range-bubble {
+ position: absolute;
+ bottom: 100%;
+ left: calc((var(--range-thumb-width) * .5) + var(--range-fill, 0) * (100% - var(--range-thumb-width)));
+ margin-bottom: var(--tooltip-arrow-height);
+ pointer-events: none;
+ transform: translateX(-50%);
+
+ .tooltip-arrow {
+ position: absolute;
+ bottom: calc(-1 * var(--tooltip-arrow-height));
+ left: 50%;
+ transform: translateX(-50%);
+ }
+ }
+
+ // Tick marks generated from the linked