From: Mark Otto Date: Mon, 22 Jun 2026 17:51:31 +0000 (-0700) Subject: Add Range plugin for track fill, value bubble, and tick marks (#42525) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=b5a618663e6dafd3bd9ce701f95fcd247388b8ce;p=thirdparty%2Fbootstrap.git Add Range plugin for track fill, value bubble, and tick marks (#42525) * 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. * Range: anchor positioning on .form-range and rename track fill token - Make `.form-range` the positioning context (`position: relative`) and drop the JS-added `.range-anchored` class; the bubble and ticks are siblings of the input, so they already share its offset parent. - Rename `--range-fill-bg` to `--range-track-fill-bg` for accuracy. - Keep SCSS custom properties unprefixed (the build adds `--bs-`) and have the plugin read/write the prefixed names, matching the codebase. - Trim obvious comments. * Bump bundlewatch JS size limits for the Range plugin * Range: make it a JS component with a wrapper, consolidate the CSS Restructure range as an always-JS component so the fill, value bubble, and tick marks are driven by a single `--bs-range-fill` ratio in CSS. - `.form-range` is now a wrapper that owns the tokens; the input takes `.form-range-input`. Children inherit the tokens, so the old JS color copying and px positioning are gone. - The plugin only sets `--bs-range-fill` (0–1) and the bubble text; the track gradient, bubble position, and tick positions are pure CSS calc. Drops `_thumbWidth`, `_inheritFillColor`, the resize listener, and the `.range-anchored` class. - Value bubble reuses the tooltip markup/styles instead of duplicating a pill and arrow. Tick marks are generated from the linked `` and positioned per-value (handles uneven values); tick coloring dropped. - Auto-inits every `.form-range`; rename fill token to `--range-track-fill-bg`. - Validation moves to `.form-range-input` with a `:has()` feedback toggle. - Add the "New" badge to the Range sidebar item and document the breaking changes in the migration guide. * Range: lay out tick marks with CSS grid instead of absolute positioning Build `grid-template-columns` from the gaps between the datalist values so each tick lands on a grid line — this handles unevenly-spaced values just like the old per-tick calc did, but keeps the ticks and their labels in normal flow (the container sizes to fit the labels instead of them overflowing an absolutely-positioned box). Inset by half the thumb so the end ticks align with the thumb travel. Also wraps the token map in the `custom-property-no-missing-var-function` stylelint disable (matching _strength.scss). Includes the disabled-track fill styling. * Nudge bundlewatch JS limits after rebasing onto v6-dev * fix bubble, grid, ticks, and more * comment --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 9da0126d36..2c89f7ea47 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,19 +34,19 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "81.0 kB" + "maxSize": "82.5 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "52.25 kB" + "maxSize": "53.25 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "52.25 kB" + "maxSize": "53.75 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 7030887c22..b40e647c33 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 0000000000..b93fea0623 --- /dev/null +++ b/js/src/range.js @@ -0,0 +1,240 @@ +/** + * -------------------------------------------------------------------------- + * 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 `` 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 diff --git a/js/tests/unit/range.spec.js b/js/tests/unit/range.spec.js new file mode 100644 index 0000000000..73a8da0365 --- /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 a784b0dd2a..fa0515497c 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: .5rem, + --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,10 +59,17 @@ $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)); } @@ -66,9 +79,16 @@ $range-tokens: defaults( .form-range { @include tokens($range-tokens); + position: relative; + display: block; + width: 100%; + } + + .form-range-input { + display: block; 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; @@ -84,7 +104,6 @@ $range-tokens: defaults( &:focus-visible { outline: 0; - // Pseudo-elements must be split across multiple rulesets to have an effect. &::-webkit-slider-thumb { @include focus-ring(true); --focus-ring-offset: 0; @@ -101,7 +120,7 @@ $range-tokens: defaults( &::-webkit-slider-thumb { @include range-thumb(); - margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) * .5); // Webkit specific + margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) * .5); } &::-moz-range-thumb { @@ -126,6 +145,72 @@ $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 . Plugin builds `grid-template-columns` + // from the gaps between values so each tick lands on a grid line (handles uneven values). + // Track is inset by 1/4th of the thumb width to keep alignment. + .form-range-ticks { + display: grid; + padding-inline: calc(var(--range-thumb-width) * .25); + } + + .form-range-tick { + display: flex; + flex-direction: column; + align-items: center; + justify-self: start; + // Zero-width items so labels never widen their `fr` column; the tick line and label + // overflow centered on the grid line via `align-items`. + width: 0; + + &::before { + width: var(--range-tick-width); + height: var(--range-tick-height); + content: ""; + background-color: var(--range-tick-bg); + } + + &:first-child { + align-items: flex-start; + } + + &:last-child { + align-items: flex-end; + } + } + + .form-range-tick-label { + margin-top: .125rem; + font-size: var(--font-size-sm); + color: var(--fg-2); + white-space: nowrap; + } } diff --git a/scss/forms/_validation.scss b/scss/forms/_validation.scss index 1337be222d..acf51fd20b 100644 --- a/scss/forms/_validation.scss +++ b/scss/forms/_validation.scss @@ -281,8 +281,9 @@ $validation-states: defaults( } } - // Range — .form-range IS the , so the mixin applies directly. - .form-range { + // Range — the validation class lives on .form-range-input, while feedback sits outside + // the .form-range wrapper, so we use :has() to toggle it. + .form-range-input { @include form-validation-state-selector($state) { &::-webkit-slider-thumb { background: var(--#{$theme}-bg); } &::-moz-range-thumb { background: var(--#{$theme}-bg); } @@ -295,12 +296,14 @@ $validation-states: defaults( @include focus-ring(true, $color: var(--#{$theme}-focus-ring)); } } - - ~ .#{$state}-feedback, - ~ .#{$state}-tooltip { display: block; } } } + .form-range:has(.form-range-input.is-#{$state}) { + ~ .#{$state}-feedback, + ~ .#{$state}-tooltip { display: block; } + } + // Input group — feedback lives outside the input-group in the parent // .form-field, so we use :has() to toggle display. .form-field:has(.input-group .form-control.is-#{$state}) { diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index 3be5874321..0eea5431ff 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -81,6 +81,8 @@ - title: Radio - title: Switch - title: Range + meta: + - added: 6.0.0 - title: Input group - title: Floating labels - title: Form adorn diff --git a/site/src/assets/examples/cheatsheet/index.astro b/site/src/assets/examples/cheatsheet/index.astro index 707d73b797..09df41763c 100644 --- a/site/src/assets/examples/cheatsheet/index.astro +++ b/site/src/assets/examples/cheatsheet/index.astro @@ -359,7 +359,9 @@ export const body_class = 'bg-body-tertiary'
- +
+ +
`} /> @@ -414,7 +416,9 @@ export const body_class = 'bg-body-tertiary'
- +
+ +
diff --git a/site/src/content/docs/forms/range.mdx b/site/src/content/docs/forms/range.mdx index f1e47bbbb2..f87bafac8d 100644 --- a/site/src/content/docs/forms/range.mdx +++ b/site/src/content/docs/forms/range.mdx @@ -1,69 +1,151 @@ --- title: Range -description: Use our custom range inputs for consistent cross-browser styling and built-in customization. +description: Use our custom range inputs for consistent cross-browser styling with a filled track, value bubble, and tick marks. toc: true css_layer: forms +js: required mdn: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/range --- ## 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. +The native `` is hard to style and can’t show a filled track in a consistent, cross-browser way using CSS alone—only Firefox offers `::-moz-range-progress`, and CSS can’t read an input’s value. So our range is a small **JavaScript component**: wrap an `` in `.form-range` and the plugin keeps a `--bs-range-fill` custom property (0–1) in sync, which the CSS uses to draw the fill, the value bubble, and tick marks. + +The `.form-range` wrapper owns the component’s tokens, so the input and any decorations inherit them. Every `.form-range` on the page is initialized automatically. Example range -`} /> +
+ +
`} /> ## Disabled -Add the `disabled` boolean attribute on an input to give it a grayed out appearance, remove pointer events, and prevent focusing. +Add the `disabled` boolean attribute on the input to give it a grayed out appearance, remove pointer events, and prevent focusing. Disabled range -`} /> +
+ +
`} /> ## Min and max Range inputs have implicit values for `min` and `max`—`0` and `100`, respectively. You may specify new values for those using the `min` and `max` attributes. Example range -`} /> +
+ +
`} /> ## Steps By default, range inputs “snap” to integer values. To change this, you can specify a `step` value. In the example below, we double the number of steps by using `step="0.5"`. Example range -`} /> +
+ +
`} /> + +## Value bubble + +Add `data-bs-bubble` to the wrapper to show a value bubble that follows the thumb. The bubble reuses our [tooltip](/docs/[[config:docs_version]]/components/tooltips/) styles. + +Brightness +
+ +
`} /> + +## Tick marks + +Link a `` to the input with the `list` attribute and the plugin renders a tick mark for each `