From 4f3c9d76362ca4b7f6bf980c91425e24b1962a67 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Thu, 18 Jun 2026 15:58:31 -0700 Subject: [PATCH 1/5] Add Range plugin for track fill, value bubble, and tick marks Adds an opt-in JavaScript plugin (`data-bs-range`) that enhances ``. A consistent cross-browser fill can't be done with pseudo-elements alone (only Firefox has `::-moz-range-progress`), so the plugin publishes the current value as a `--bs-range-value` custom property that the track gradient consumes. - Fill: colored track up to the thumb, themeable via `--range-fill-bg` - Value bubble (`data-bs-bubble`): floating value that tracks the thumb - Tick marks (`data-bs-ticks`): generated from a linked `` The bubble and ticks are siblings of the input, so they don't inherit the input's `--range-fill-bg`; the plugin copies the resolved value onto them and the CSS falls back to `--primary-base` so they're never blank. Plain `.form-range` inputs are untouched. Includes unit tests and docs. --- js/index.js | 1 + js/src/range.js | 289 ++++++++++++++++++++++++++ js/tests/unit/range.spec.js | 265 +++++++++++++++++++++++ scss/forms/_form-range.scss | 86 ++++++++ site/src/content/docs/forms/range.mdx | 102 ++++++++- 5 files changed, 741 insertions(+), 2 deletions(-) create mode 100644 js/src/range.js create mode 100644 js/tests/unit/range.spec.js 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..eba90026c5a7 --- /dev/null +++ b/js/src/range.js @@ -0,0 +1,289 @@ +/** + * -------------------------------------------------------------------------- + * 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_RESIZE = `resize${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_DATA_RANGE = '[data-bs-range]' + +const CLASS_NAME_ANCHORED = 'range-anchored' +const CLASS_NAME_BUBBLE = 'range-bubble' +const CLASS_NAME_TICKS = 'range-ticks' +const CLASS_NAME_TICK = 'range-tick' +const CLASS_NAME_TICK_LABEL = 'range-tick-label' +const CLASS_NAME_ACTIVE = 'active' + +// Shipped (`--bs-`-prefixed) custom properties the SCSS exposes (the build prefixes +// the SCSS tokens, so the plugin must read/write the prefixed names to interoperate) +const PROPERTY_VALUE = '--bs-range-value' +const PROPERTY_THUMB_WIDTH = '--bs-range-thumb-width' +const PROPERTY_FILL_BG = '--bs-range-fill-bg' + +const Default = { + bubble: false, // Show a floating value bubble above the thumb + ticks: false, // Render tick marks from the input's linked + formatter: null // (value) => string, for bubble + tick label text +} + +const DefaultType = { + bubble: '(boolean|null)', + ticks: '(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._bubble = null + this._ticks = null + this._updateHandler = () => this._update() + this._resizeHandler = null + + if (this._config.bubble || this._config.ticks) { + this._element.parentNode?.classList.add(CLASS_NAME_ANCHORED) + } + + if (this._config.bubble) { + this._createBubble() + } + + if (this._config.ticks) { + 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() { + // These are bound with raw (non-namespaced) types, so remove them explicitly + EventHandler.off(this._element, EVENT_INPUT, this._updateHandler) + EventHandler.off(this._element, EVENT_CHANGE, this._updateHandler) + + if (this._resizeHandler) { + EventHandler.off(window, EVENT_RESIZE, this._resizeHandler) + } + + const parent = this._element.parentNode + + this._bubble?.remove() + this._ticks?.remove() + + // Drop the positioning class only if no other range decorations remain + if (parent && !SelectorEngine.findOne(`.${CLASS_NAME_BUBBLE}, .${CLASS_NAME_TICKS}`, parent)) { + parent.classList.remove(CLASS_NAME_ANCHORED) + } + + super.dispose() + } + + // Private + _configAfterMerge(config) { + // A bare `data-bs-bubble` / `data-bs-ticks` attribute normalizes to `null`; treat it as enabled + for (const key of ['bubble', 'ticks']) { + if (config[key] === null) { + config[key] = true + } + } + + return config + } + + _addEventListeners() { + EventHandler.on(this._element, EVENT_INPUT, this._updateHandler) + EventHandler.on(this._element, EVENT_CHANGE, this._updateHandler) + + if (this._bubble || this._ticks) { + this._resizeHandler = () => this._reposition() + EventHandler.on(window, EVENT_RESIZE, this._resizeHandler) + } + } + + _min() { + return this._element.min === '' ? 0 : Number.parseFloat(this._element.min) + } + + _max() { + return this._element.max === '' ? 100 : Number.parseFloat(this._element.max) + } + + _value() { + return Number.parseFloat(this._element.value) + } + + _ratio() { + const span = this._max() - this._min() + return span > 0 ? (this._value() - this._min()) / span : 0 + } + + _update() { + this._element.style.setProperty(PROPERTY_VALUE, `${this._ratio() * 100}%`) + this._reposition() + + EventHandler.trigger(this._element, EVENT_CHANGED, { value: this._value() }) + } + + _reposition() { + if (!this._bubble && !this._ticks) { + return + } + + const { offsetLeft, offsetTop, offsetWidth, offsetHeight } = this._element + const ratio = this._ratio() + + if (this._bubble) { + this._bubble.textContent = this._format(this._value()) + // Nudge by the thumb width so the bubble tracks the thumb centre, not just the track percentage + const x = offsetLeft + (ratio * offsetWidth) + ((0.5 - ratio) * this._thumbWidth()) + this._bubble.style.left = `${x}px` + this._bubble.style.top = `${offsetTop}px` + } + + if (this._ticks) { + this._ticks.style.left = `${offsetLeft}px` + this._ticks.style.top = `${offsetTop + offsetHeight}px` + this._ticks.style.width = `${offsetWidth}px` + + const value = this._value() + for (const tick of SelectorEngine.find(`.${CLASS_NAME_TICK}`, this._ticks)) { + tick.classList.toggle(CLASS_NAME_ACTIVE, Number.parseFloat(tick.dataset.bsValue) <= value) + } + } + } + + _format(value) { + return typeof this._config.formatter === 'function' ? this._config.formatter(value) : String(value) + } + + _thumbWidth() { + const raw = getComputedStyle(this._element).getPropertyValue(PROPERTY_THUMB_WIDTH).trim() + + if (raw.endsWith('rem')) { + return Number.parseFloat(raw) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize) + } + + return Number.parseFloat(raw) || 16 + } + + // The bubble and ticks are siblings of the input, so they don't inherit the input's + // `--bs-range-fill-bg` token. Copy its resolved value over so themed fills propagate. + _inheritFillColor(element) { + const fill = getComputedStyle(this._element).getPropertyValue(PROPERTY_FILL_BG).trim() + + if (fill) { + element.style.setProperty(PROPERTY_FILL_BG, fill) + } + } + + _createBubble() { + this._bubble = document.createElement('span') + this._bubble.className = CLASS_NAME_BUBBLE + this._bubble.setAttribute('aria-hidden', 'true') + this._inheritFillColor(this._bubble) + this._element.insertAdjacentElement('afterend', this._bubble) + } + + _createTicks() { + const listId = this._element.getAttribute('list') + const datalist = listId ? document.getElementById(listId) : null + + if (!datalist) { + return + } + + const min = this._min() + const span = this._max() - min || 1 + + this._ticks = document.createElement('div') + this._ticks.className = CLASS_NAME_TICKS + this._ticks.setAttribute('aria-hidden', 'true') + this._inheritFillColor(this._ticks) + + for (const option of SelectorEngine.find('option', datalist)) { + const value = Number.parseFloat(option.value) + + if (Number.isNaN(value)) { + continue + } + + const tick = document.createElement('span') + tick.className = CLASS_NAME_TICK + tick.dataset.bsValue = value + tick.style.left = `${((value - min) / span) * 100}%` + + const labelText = option.label || option.value + if (labelText) { + const label = document.createElement('span') + label.className = CLASS_NAME_TICK_LABEL + label.textContent = labelText + tick.append(label) + } + + this._ticks.append(tick) + } + + this._element.insertAdjacentElement('afterend', this._ticks) + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_DOM_CONTENT_LOADED, () => { + for (const element of SelectorEngine.find(SELECTOR_DATA_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..b04358a0949f --- /dev/null +++ b/js/tests/unit/range.spec.js @@ -0,0 +1,265 @@ +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 = (attributes = '') => { + 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() + expect(Range.Default.ticks).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 set the --bs-range-value 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-value')).toEqual('50%') + }) + + it('should honor min/max when computing the percentage', () => { + fixtureEl.innerHTML = '
' + + const rangeEl = fixtureEl.querySelector('.form-range') + new Range(rangeEl) // eslint-disable-line no-new + + expect(rangeEl.style.getPropertyValue('--bs-range-value')).toEqual('25%') + }) + }) + + describe('update', () => { + it('should update the --bs-range-value custom property on input', () => { + fixtureEl.innerHTML = getRangeHtml() + + const rangeEl = fixtureEl.querySelector('.form-range') + new Range(rangeEl) // eslint-disable-line no-new + + rangeEl.value = '75' + rangeEl.dispatchEvent(createEvent('input')) + + expect(rangeEl.style.getPropertyValue('--bs-range-value')).toEqual('75%') + }) + }) + + describe('bubble', () => { + it('should create a value bubble when enabled', () => { + fixtureEl.innerHTML = getRangeHtml('data-bs-bubble="true"') + + const rangeEl = fixtureEl.querySelector('.form-range') + new Range(rangeEl) // eslint-disable-line no-new + + const bubble = fixtureEl.querySelector('.range-bubble') + expect(bubble).not.toBeNull() + expect(bubble.textContent).toEqual('50') + expect(rangeEl.parentNode).toHaveClass('range-anchored') + }) + + it('should update the bubble text on input', () => { + fixtureEl.innerHTML = getRangeHtml('data-bs-bubble="true"') + + const rangeEl = fixtureEl.querySelector('.form-range') + new Range(rangeEl) // eslint-disable-line no-new + + rangeEl.value = '80' + rangeEl.dispatchEvent(createEvent('input')) + + expect(fixtureEl.querySelector('.range-bubble').textContent).toEqual('80') + }) + + it('should format the bubble text with a custom formatter', () => { + fixtureEl.innerHTML = getRangeHtml('data-bs-bubble="true"') + + const rangeEl = fixtureEl.querySelector('.form-range') + new Range(rangeEl, { formatter: value => `${value}%` }) // eslint-disable-line no-new + + expect(fixtureEl.querySelector('.range-bubble').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('.range-tick') + expect(ticks.length).toEqual(3) + expect(ticks[0].style.left).toEqual('0%') + expect(ticks[2].style.left).toEqual('100%') + }) + + it('should render labels from the option label/value', () => { + fixtureEl.innerHTML = getTicksHtml() + + const rangeEl = fixtureEl.querySelector('.form-range') + new Range(rangeEl) // eslint-disable-line no-new + + const labels = fixtureEl.querySelectorAll('.range-tick-label') + expect(labels[0].textContent).toEqual('Low') + expect(labels[2].textContent).toEqual('High') + }) + + it('should mark ticks at or below the current value as active', () => { + fixtureEl.innerHTML = getTicksHtml() + + const rangeEl = fixtureEl.querySelector('.form-range') + new Range(rangeEl) // eslint-disable-line no-new + + const ticks = fixtureEl.querySelectorAll('.range-tick') + expect(ticks[0]).toHaveClass('active') + expect(ticks[1]).toHaveClass('active') + expect(ticks[2]).not.toHaveClass('active') + }) + + it('should do nothing when there is no linked datalist', () => { + fixtureEl.innerHTML = getRangeHtml('data-bs-ticks="true"') + + const rangeEl = fixtureEl.querySelector('.form-range') + const range = new Range(rangeEl) + + expect(range._ticks).toBeNull() + expect(fixtureEl.querySelector('.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') + new Range(rangeEl) // eslint-disable-line no-new + + rangeEl.addEventListener('changed.bs.range', event => { + expect(event.value).toEqual(90) + resolve() + }) + + rangeEl.value = '90' + rangeEl.dispatchEvent(createEvent('input')) + }) + }) + }) + + describe('dispose', () => { + it('should dispose the instance and remove decorations', () => { + fixtureEl.innerHTML = getRangeHtml('data-bs-bubble="true"') + + const rangeEl = fixtureEl.querySelector('.form-range') + const parent = rangeEl.parentNode + const range = new Range(rangeEl) + + expect(Range.getInstance(rangeEl)).not.toBeNull() + expect(fixtureEl.querySelector('.range-bubble')).not.toBeNull() + + range.dispose() + + expect(Range.getInstance(rangeEl)).toBeNull() + expect(fixtureEl.querySelector('.range-bubble')).toBeNull() + expect(parent).not.toHaveClass('range-anchored') + }) + }) + + 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..4acd14a99628 100644 --- a/scss/forms/_form-range.scss +++ b/scss/forms/_form-range.scss @@ -18,6 +18,7 @@ $range-tokens: defaults( --range-track-bg: var(--bg-3), --range-track-border-radius: 1rem, --range-track-box-shadow: var(--box-shadow-inset), + --range-fill-bg: var(--primary-base), --range-thumb-width: 1rem, --range-thumb-height: var(--range-thumb-width), --range-thumb-bg: var(--primary-base), @@ -128,4 +129,89 @@ $range-tokens: defaults( } } } + + // Opt-in fill / progress track (requires the Range JS plugin to publish `--bs-range-value`). + // Override only `background-image` so the track's base color, radius, and shadow are preserved. + // WebKit and Firefox pseudo-element selectors cannot be combined—each needs its own ruleset. + [data-bs-range] { + &::-webkit-slider-runnable-track { + background-image: + linear-gradient( + to right, + var(--range-fill-bg) var(--bs-range-value, 0%), + transparent var(--bs-range-value, 0%) + ); + } + &::-moz-range-track { + background-image: + linear-gradient( + to right, + var(--range-fill-bg) var(--bs-range-value, 0%), + transparent var(--bs-range-value, 0%) + ); + } + } + + // Positioning context for the value bubble and tick marks (added by the JS plugin) + .range-anchored { + position: relative; + } + + // Floating value bubble; the plugin sets inline `left`/`top` in pixels + .range-bubble { + position: absolute; + z-index: 2; + padding: .125rem .375rem; + font-size: var(--font-size-xs); + line-height: 1.2; + color: var(--primary-contrast); + white-space: nowrap; + pointer-events: none; + // Siblings of the input don't inherit its `--range-fill-bg` token; fall back to the global default. + // The plugin also copies the input's resolved value here so custom fills propagate. + background-color: var(--range-fill-bg, var(--primary-base)); + // Centre horizontally on the thumb and sit just above the track + transform: translate(-50%, calc(-100% - .25rem)); + @include border-radius(var(--radius-4)); + + &::after { + position: absolute; + top: 100%; + left: 50%; + width: .5rem; + height: .5rem; + content: ""; + background-color: inherit; + transform: translate(-50%, -50%) rotate(45deg); + } + } + + // Tick marks generated from a linked ; the plugin sets the container geometry + .range-ticks { + position: absolute; + pointer-events: none; + } + + .range-tick { + position: absolute; + top: .25rem; + width: var(--border-width); + height: .375rem; + background-color: var(--border-color); + transform: translateX(-50%); + + &.active { + background-color: var(--range-fill-bg, var(--primary-base)); + } + } + + .range-tick-label { + position: absolute; + top: .5rem; + left: 50%; + font-size: var(--font-size-xs); + color: var(--fg-1); + white-space: nowrap; + transform: translateX(-50%); + } } diff --git a/site/src/content/docs/forms/range.mdx b/site/src/content/docs/forms/range.mdx index f1e47bbbb240..4cbe96f1b97b 100644 --- a/site/src/content/docs/forms/range.mdx +++ b/site/src/content/docs/forms/range.mdx @@ -8,7 +8,9 @@ mdn: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/ ## Overview -Create custom `` controls with `.form-range`. The track (the background) and thumb (the value) are both styled to appear the same across browsers. As only Firefox supports “filling” their track from the left or right of the thumb as a means to visually indicate progress, we do not currently support it. +Create custom `` controls with `.form-range`. The track (the background) and thumb (the value) are both styled to appear the same across browsers. + +Browsers don’t expose a consistent, CSS-only way to “fill” the track up to the thumb—only Firefox offers `::-moz-range-progress`, while WebKit and Blink have no equivalent and CSS can’t read an input’s value. So our optional Range JavaScript plugin publishes the current value as a `--bs-range-value` custom property that the track gradient consumes, giving you a cross-browser [fill](#fill), [value bubble](#value-bubble), and [tick marks](#tick-marks). Opt in per input with `data-bs-range`; plain `.form-range` inputs are unaffected. Example range `} /> @@ -34,6 +36,34 @@ By default, range inputs “snap” to integer values. To change this, you can s Example range `} /> +## Fill + +Add `data-bs-range` to color the track up to the thumb. The plugin keeps a `--bs-range-value` custom property in sync with the input as it changes, and the track gradient uses it to render the fill consistently across browsers. Customize the color with the `--range-fill-bg` CSS variable. + +Volume +`} /> + +## Value bubble + +Add `data-bs-bubble="true"` to show a floating bubble with the current value that follows the thumb. + +Brightness +`} /> + +## Tick marks + +Add `data-bs-ticks="true"` and link a `` with the `list` attribute to render tick marks. Each `