generated from boboko/starter
167 lines
6.1 KiB
JavaScript
167 lines
6.1 KiB
JavaScript
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
|
||
}
|