generated from boboko/starter
179 lines
6.5 KiB
JavaScript
179 lines
6.5 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', 'reset']
|
|||
|
|
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.defaults = { min: this.#lo, max: this.#hi }
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
reset() {
|
|||
|
|
this.minInputTarget.value = this.defaults.min
|
|||
|
|
this.maxInputTarget.value = this.defaults.max
|
|||
|
|
this.#render()
|
|||
|
|
this.#emit('change')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.hasResetTarget) {
|
|||
|
|
this.resetTarget.hidden = lo === this.defaults.min && hi === this.defaults.max
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#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
|
|||
|
|
}
|