generated from boboko/starter
category page filters components and general ui - no functionality yet
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user