Files

167 lines
6.1 KiB
JavaScript
Raw Permalink Normal View History

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
}