Files
3dealer/resources/js/stimulus/range-slider-controller.js
T

167 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Controller } from '@hotwired/stimulus'
// Dual-thumb range slider.
//
// Two real <input type="range"> elements stay authoritative — they carry the
// value, the form data, native keyboard support and the no-JS fallback. On
// connect this controller hides their <label>s and mirrors their state onto a
// presentational track: a baseline, a filled span between the two carets, and
// the carets themselves, all positioned with the --min / --max percentage
// custom properties written on the track element.
//
// Pointer drag moves the carets (writing back to the inputs); the keyboard
// drives the inputs directly. Values can't cross — min stays one step below
// max and vice versa. Emits `range-slider:input` while dragging and
// `range-slider:change` on commit, both with { min, max }.
export default class extends Controller {
static targets = ['minInput', 'maxInput', 'field', 'track', 'minThumb', 'maxThumb', 'output']
static values = {
min: Number,
max: Number,
step: { type: Number, default: 1 },
prefix: { type: String, default: '' },
suffix: { type: String, default: '' },
separator: { type: String, default: ' – ' },
}
connect() {
this.#clamp()
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
this.#render()
}
disconnect() {
this.#stopDrag()
}
// ── keyboard / programmatic ──────────────────────────────────────
onInput(event) {
this.#clamp(this.#side(event.target))
this.#render()
this.#emit('input')
}
onChange(event) {
this.#clamp(this.#side(event.target))
this.#render()
this.#emit('change')
}
// The real inputs are visually hidden, so mirror their focus ring onto
// the matching caret to keep a visible focus indicator for keyboard use.
syncFocus(event) {
const thumb = event.target === this.minInputTarget ? this.minThumbTarget : this.maxThumbTarget
thumb.classList.toggle('ring-2', event.type === 'focus')
thumb.classList.toggle('ring-black', event.type === 'focus')
}
// ── pointer drag ────────────────────────────────────────────────────
thumbPointerDown(event) {
const input = event.currentTarget === this.minThumbTarget ? this.minInputTarget : this.maxInputTarget
this.#startDrag(event, input)
}
trackPointerDown(event) {
if (event.target.closest('button')) return // a caret handles its own press
const value = this.#valueAt(event.clientX)
const input = Math.abs(value - this.#lo) <= Math.abs(value - this.#hi)
? this.minInputTarget
: this.maxInputTarget
input.value = value
this.#clamp(this.#side(input))
this.#render()
this.#startDrag(event, input)
}
// ── internals ──────────────────────────────────────────────────────
#startDrag(event, input) {
event.preventDefault()
this.#stopDrag()
const side = this.#side(input)
this.#onMove = (e) => {
input.value = this.#valueAt(e.clientX)
this.#clamp(side)
this.#render()
this.#emit('input')
}
this.#onUp = () => {
this.#stopDrag()
this.#emit('change')
}
window.addEventListener('pointermove', this.#onMove)
window.addEventListener('pointerup', this.#onUp)
}
#side(input) {
return input === this.maxInputTarget ? 'max' : 'min'
}
#stopDrag() {
if (this.#onMove) window.removeEventListener('pointermove', this.#onMove)
if (this.#onUp) window.removeEventListener('pointerup', this.#onUp)
this.#onMove = this.#onUp = null
}
get #lo() { return Number(this.minInputTarget.value) }
get #hi() { return Number(this.maxInputTarget.value) }
// Keep both thumbs inside the group bounds and stop them crossing. When a
// thumb is being moved (`side`), only that one gives way, so the other
// stays put instead of being dragged along.
#clamp(side = null) {
const gap = this.stepValue
let lo = Math.max(this.minValue, Math.min(this.maxValue, Number(this.minInputTarget.value)))
let hi = Math.max(this.minValue, Math.min(this.maxValue, Number(this.maxInputTarget.value)))
if (side === 'max') hi = Math.max(hi, lo + gap)
else if (side === 'min') lo = Math.min(lo, hi - gap)
else if (lo > hi - gap) lo = hi - gap
this.minInputTarget.value = lo
this.maxInputTarget.value = hi
}
#valueAt(clientX) {
const rect = this.trackTarget.getBoundingClientRect()
const ratio = rect.width ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0
const raw = this.minValue + ratio * (this.maxValue - this.minValue)
const step = this.stepValue
return Math.round(raw / step) * step
}
#percent(value) {
const span = this.maxValue - this.minValue
return span ? ((value - this.minValue) / span) * 100 : 0
}
#render() {
const lo = this.#lo
const hi = this.#hi
this.trackTarget.style.setProperty('--min', `${this.#percent(lo)}%`)
this.trackTarget.style.setProperty('--max', `${this.#percent(hi)}%`)
if (this.hasOutputTarget) {
const fmt = (v) => `${this.prefixValue}${v}${this.suffixValue}`
this.outputTarget.textContent = fmt(lo) + this.separatorValue + fmt(hi)
}
}
#emit(name) {
const detail = { min: this.#lo, max: this.#hi }
const key = `${detail.min},${detail.max}`
if (name === 'input' && key === this.#lastInputKey) return // no change since last frame
this.#lastInputKey = key
this.dispatch(name, { detail })
}
#onMove = null
#onUp = null
#lastInputKey = null
}