Files
livewire-material/resources/js/slider.js
T
Andreas Reinhold / reiniandClaude Fable 5.1 790ef93c6a Stand a slider up with orientation="vertical"
M3 Expressive's configuration table lists a vertical orientation the library
had no prop for (plan step 24, audit docs/audits/m3-alignment/inputs.md
§ Missing). The drawing is the horizontal one, laid out as long as the slider
is tall inside a size container and turned a quarter anticlockwise, so the
geometry, the sizes and the tokens are untouched; the value label and the inset
icon are turned back so they read across. The keyboard stays the native range's
— Up and Right raise, Down and Left lower — and the inputs say
aria-orientation. A vertical slider takes its length from its wrapper, so the
header, the SKILL.md entry and the showcase all say to give it a height. M3
keeps range sliders horizontal, so `range` wins over `orientation`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:31:07 +02:00

652 lines
25 KiB
JavaScript

/**
* `materialSlider`: draws `<x-slider>`, M3 Expressive's slider, over its native range inputs —
* the track, split around each handle, the stop indicators, the tick marks, the handle and its
* value label, and the inset icon — the way androidx Compose Material 3 draws them.
*
* The inputs stay the control. Keyboard, forms, `wire:model`, `x-model` and assistive technology
* all talk to them; this only reads their values and paints. A pointer is the one thing handled
* here rather than natively, so that a press lands where Compose puts it in every engine (a
* native range thumb is sized, hit-tested and dragged differently in each): a press moves the
* nearest handle to the pointer, snapped to the step, a drag follows it, and every change is an
* `input` event on the input, with `change` on release — what a native drag sends. A touch waits
* for a sideways move, or a tap, so scrolling past a slider never changes it.
*
* The server draws the first frame. The drawing is `wire:ignore`: a value that Livewire or
* Alpine writes into an input (`input.value = …`, which fires no event) is caught on the input's
* own `value` setter, and attribute changes by a MutationObserver.
*
* - Orientation: the geometry below is always the horizontal one. A vertical slider is that same
* drawing turned a quarter by CSS, so only two things change here — the track's length is the
* slider's height rather than its width, and a pointer is read along Y from the bottom edge up.
* - Range: the handles never cross; an input that would pass the other is held at its value
* before any listener (x-model, wire:model) reads it.
* - PageUp and PageDown move by Compose's page: a tenth of the steps, at least one and at most
* ten (arrows, Home and End are the inputs' own). An arrow pressed while Space is held moves by
* that same large interval, which is M3's "Space & Arrows" keyboard row; in a right-to-left
* slider the left and right arrows swap, as they do natively.
* - Ticks hide while they would sit closer than 8px (Material Components' tick_min_spacing).
*
* ---------------------------------------------------------------------------------------
* Ported from androidx (https://github.com/androidx/androidx), commit
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
*
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt
* (SliderImpl and RangeSliderImpl layout, SliderDefaults.drawTrack, ThumbContent,
* slideOnKeyEvents, RangeSliderLogic.captureThumb)
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/SliderTokens.kt
*
* with the Expressive sizes from Material Components for Android's slider tokens
* (md.comp.slider.xsmall … xlarge, lib/java/com/google/android/material/slider/res/values/tokens.xml).
*
* Copyright 2023-2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ---------------------------------------------------------------------------------------
*/
// Tokens ---------------------------------------------------------------------------------
/**
* Track height and corner per size (md.comp.slider.*). XS is Compose's default track, whose
* corner is half its height and whose segments vanish rather than shrink; the larger sizes pass a
* `trackCornerSize`, which shrinks a segment's corners as it narrows (enableCornerShrinking).
*/
const SIZES = {
xs: { corner: 8, shrink: false, icon: 0 },
sm: { corner: 8, shrink: true, icon: 0 },
md: { corner: 12, shrink: true, icon: 24 },
lg: { corner: 16, shrink: true, icon: 24 },
xl: { corner: 28, shrink: true, icon: 32 },
}
/** SliderTokens.HandleWidth: the handle's box, which the gap is measured from even while it narrows. */
const HANDLE_WIDTH = 4
/** SliderTokens.ActiveHandleLeadingSpace: ThumbTrackGapSize. */
const GAP = 6
/** TrackInsideCornerSize. */
const INSIDE_CORNER = 2
/** The inset icon's padding from its segment's end (m3_slider_track_icon_padding, SliderWithTrackIconsSample). */
const ICON_PADDING = 10
/** Material Components' tick_min_spacing. */
const TICK_MIN_SPACING = 8
/** How far a touch moves sideways before it is a drag rather than the start of a scroll. */
const TOUCH_SLOP = 8
const VALUE = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')
const VALUE_AS_NUMBER = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'valueAsNumber')
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
const near = (a, b) => Math.abs(a - b) < 1e-6
const within = (x, [from, to]) => x >= from && x <= to
/**
* Where the track's parts go, in pixels from the start of a track `width` wide: SliderImpl's and
* RangeSliderImpl's handle placement and SliderDefaults.drawTrack, for a horizontal, left-to-right
* slider (right-to-left mirrors the drawing as a whole).
*
* @returns {{ segments: Object<string, {from: number, to: number, startRadius: number, endRadius: number}>, stops: Object<string, number>, ticks: Array<{x: number, active: boolean}|null>, handles: number[], activeTrack: number[], endTrackStart: number|null }}
*/
function sliderGeometry({ width, size, fractions, centered = false, range = false, tickFractions = [] }) {
const { corner, shrink } = SIZES[size] ?? SIZES.xs
const discrete = tickFractions.length > 0
const onFirstOrLastStep = (fraction) => near(fraction, tickFractions[0]) || near(fraction, tickFractions.at(-1))
// SliderImpl: a discrete handle off the ends sits between the corners, where the ticks are.
const place = (fraction) => (discrete && !onFirstOrLastStep(fraction) ? (width - corner * 2) * fraction + corner : width * fraction)
const activeRangeStart = range ? fractions[0] : 0
const activeRangeEnd = range ? fractions[1] : fractions[0]
const sliderValueStart = place(activeRangeStart)
const sliderValueEnd = place(activeRangeEnd)
// TrackImpl: a centred track has a handle on only one side of its centre.
let startThumbWidth = HANDLE_WIDTH
let endThumbWidth = HANDLE_WIDTH
let startGapSize = GAP
if (!range) {
const before = fractions[0] < 0.5
const after = fractions[0] > 0.5
startThumbWidth = centered && !after ? HANDLE_WIDTH : 0
endThumbWidth = centered ? (before ? 0 : HANDLE_WIDTH) : HANDLE_WIDTH
startGapSize = centered ? GAP : 0
}
const startGap = startGapSize > 0 ? startThumbWidth / 2 + startGapSize : 0
const endGap = endThumbWidth / 2 + GAP
const centerAxis = width / 2
const cornerThreshold = !shrink || discrete ? corner : 0
const segments = {}
const stops = {}
// The inactive track before the active one (centred and range sliders).
const adjustedSliderValueEnd = centered ? Math.min(sliderValueEnd, centerAxis) : sliderValueStart
if ((centered || range) && adjustedSliderValueEnd > startGap + cornerThreshold) {
segments.start = { from: 0, to: adjustedSliderValueEnd - startGap, startRadius: corner, endRadius: INSIDE_CORNER }
stops.start = corner
}
// The inactive track after it.
const adjustedSliderValueStart = centered ? Math.max(sliderValueEnd, centerAxis) : sliderValueEnd
if (adjustedSliderValueStart < width - endGap - cornerThreshold) {
segments.end = { from: adjustedSliderValueStart + endGap, to: width, startRadius: INSIDE_CORNER, endRadius: corner }
stops.end = width - corner
}
// The active track.
const activeTrackStart = centered
? adjustedSliderValueEnd + (adjustedSliderValueEnd < centerAxis ? startGap : 0)
: range
? sliderValueStart + startGap
: 0
const activeTrackEnd = centered ? adjustedSliderValueStart - (adjustedSliderValueStart > centerAxis ? endGap : 0) : sliderValueEnd - endGap
const activeStartRadius = centered || range ? INSIDE_CORNER : corner
if (activeTrackEnd - activeTrackStart > (!shrink || discrete ? activeStartRadius : 0)) {
segments.active = { from: activeTrackStart, to: activeTrackEnd, startRadius: activeStartRadius, endRadius: INSIDE_CORNER }
}
// Ticks, between the corners; none on a stop indicator or in a gap.
const activeTrack = [activeTrackStart, activeTrackEnd]
const centreGap = sliderValueEnd > centerAxis ? startGap : endGap
const valueGap = centered ? (sliderValueEnd > centerAxis ? endGap : startGap) : endGap
const tickCenterGap = [centerAxis - centreGap, centerAxis + centreGap]
const tickStartGap = [sliderValueStart - startGap, sliderValueStart + startGap]
const tickEndGap = [sliderValueEnd - valueGap, sliderValueEnd + valueGap]
const ticks = tickFractions.map((tick, index) => {
if (((centered || range) && index === 0) || index === tickFractions.length - 1) {
return null
}
const x = corner + (width - corner * 2) * tick
if ((centered && within(x, tickCenterGap)) || (range && within(x, tickStartGap)) || within(x, tickEndGap)) {
return null
}
return { x, active: within(x, activeTrack) }
})
return {
segments,
stops,
ticks,
handles: (range ? fractions : [fractions[0]]).map(place),
activeTrack,
endTrackStart: segments.end ? segments.end.from : null,
}
}
// The component ----------------------------------------------------------------------------
/**
* One slider's behaviour, bound to its root (the element with `x-data`). Everything it keeps is
* a local here rather than Alpine data: x-model writes an input's value from inside a reactive
* effect, and anything reactive read or written on the way to the drawing would re-run it.
*/
function slider(root) {
const inputs = [...root.querySelectorAll(':scope > input[type="range"]')]
const drawing = root.querySelector('[data-slider-drawing]')
const range = inputs.length > 1
const cleanups = []
let queued = false
let release = null
// Focus this script gave an input on a press, which draws no ring until a key is pressed.
let focusedByPointer = null
// Space held down, which turns the arrows into M3's large interval.
let spaceHeld = false
const listen = (target, type, handler, capture = false) => {
target.addEventListener(type, handler, capture)
cleanups.push(() => target.removeEventListener(type, handler, capture))
}
/** The inputs' min, max and step, as a native range reads them; `step` is null for `any`. */
const bounds = () => {
const input = inputs[0]
const parsed = (attribute, fallback) => (Number.isFinite(Number.parseFloat(input[attribute])) ? Number.parseFloat(input[attribute]) : fallback)
const min = parsed('min', 0)
const max = parsed('max', 100)
const step = parsed('step', 1)
return { min, max: max > min ? max : min + 100, step: input.step === 'any' ? null : step > 0 ? step : 1 }
}
/** M3 Expressive's second orientation: the drawing is turned a quarter, the geometry is not. */
const vertical = () => root.dataset.orientation === 'vertical'
/** The track's length: the slider's height while it stands up, its width while it lies down. */
const trackLength = () => Math.max((vertical() ? root.clientHeight : root.clientWidth) - HANDLE_WIDTH, 0)
const values = () => inputs.map((input) => Number.parseFloat(input.value))
const thumbOf = (input) => drawing?.querySelector(`[data-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`)
const tickFractions = () => [...(drawing?.querySelectorAll('[data-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.tick))
const geometry = (width) => {
const { min, max } = bounds()
return sliderGeometry({
width,
size: root.dataset.size,
fractions: values().map((value) => (Number.isFinite(value) ? clamp((value - min) / (max - min), 0, 1) : 0)),
centered: root.hasAttribute('data-centered'),
range,
tickFractions: tickFractions(),
})
}
const draw = () => {
if (!drawing) {
return
}
const width = trackLength()
const size = SIZES[root.dataset.size] ?? SIZES.xs
const { segments, stops, ticks, handles, activeTrack, endTrackStart } = geometry(width)
const part = (selector) => drawing.querySelector(selector)
const px = (number) => `${Math.round(number * 100) / 100}px`
for (const name of ['start', 'active', 'end']) {
const element = part(`[data-segment="${name}"]`)
const segment = segments[name]
if (element) {
element.hidden = !segment
if (segment) {
const { from, to, startRadius: start, endRadius: end } = segment
element.style.cssText = `left: ${px(from)}; width: ${px(to - from)}; border-radius: ${px(start)} ${px(end)} ${px(end)} ${px(start)}`
}
}
}
for (const name of ['start', 'end']) {
const element = part(`[data-stop="${name}"]`)
if (element) {
element.hidden = stops[name] === undefined
element.style.left = stops[name] === undefined ? '' : px(stops[name])
}
}
const fractions = tickFractions()
const spacing = fractions.length > 1 ? (width - size.corner * 2) * Math.abs(fractions[1] - fractions[0]) : Infinity
drawing.querySelectorAll('[data-tick]').forEach((element, i) => {
const tick = ticks[i]
element.hidden = !tick || spacing < TICK_MIN_SPACING
if (tick) {
element.style.left = px(tick.x)
element.toggleAttribute('data-active', tick.active)
}
})
handles.forEach((x, i) => {
const thumb = part(`[data-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-value-label]')
if (thumb) {
thumb.style.left = px(x)
}
if (label && label.textContent !== inputs[i].value) {
label.textContent = inputs[i].value
}
})
// The inset icon: at the start of the active track while it fits, else of the inactive one.
const icon = part('[data-track-icon]')
if (icon) {
const room = size.icon + ICON_PADDING * 2
const inActive = Boolean(segments.active) && activeTrack[1] - activeTrack[0] >= room
const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room
icon.hidden = !inActive && !inInactive
icon.toggleAttribute('data-active', inActive)
icon.style.left = px((inActive ? activeTrack[0] : (endTrackStart ?? 0)) + ICON_PADDING)
}
}
const schedule = () => {
if (!queued) {
queued = true
queueMicrotask(() => {
queued = false
draw()
})
}
}
/** A value on the step grid, inside the bounds. */
const snap = (raw) => {
const { min, max, step } = bounds()
if (step === null) {
return clamp(raw, min, max)
}
const decimals = Math.max(...[step, min].map((number) => (String(number).split('.')[1] ?? '').length))
const top = min + Math.floor((max - min) / step + 1e-9) * step
return Number(clamp(min + Math.round((raw - min) / step) * step, min, top).toFixed(decimals))
}
/** Set one input and tell its listeners, as a native change would. */
const commit = (index, raw) => {
const input = inputs[index]
let value = snap(raw)
if (range) {
const other = Number.parseFloat(inputs[1 - index].value)
value = index === 0 ? Math.min(value, other) : Math.max(value, other)
}
if (Number.parseFloat(input.value) === value) {
return false
}
input.value = String(value)
input.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
/** Whether a handle shows keyboard focus: narrowed, ringed and labelled. */
const showFocus = (input, shown) => thumbOf(input)?.toggleAttribute('data-focused', shown)
/** Compose narrows a handle and shows its label while it is pressed or dragged. */
const pressed = (index) => drawing?.querySelectorAll('[data-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-pressed', i === index))
inputs.forEach((input) => {
// A value written by script fires no event: catch it on the input's own setters.
for (const [property, descriptor] of [['value', VALUE], ['valueAsNumber', VALUE_AS_NUMBER]]) {
Object.defineProperty(input, property, {
configurable: true,
get() {
return descriptor.get.call(this)
},
set(value) {
descriptor.set.call(this, value)
schedule()
},
})
}
cleanups.push(() => {
delete input.value
delete input.valueAsNumber
})
listen(input, 'focus', () => showFocus(input, focusedByPointer !== input && input.matches(':focus-visible')))
listen(input, 'blur', () => {
focusedByPointer = null
spaceHeld = false
showFocus(input, false)
})
// slideOnKeyEvents: PageUp and PageDown move a tenth of the steps, one to ten of them —
// and so do the arrows while Space is held, M3's large interval.
listen(input, 'keydown', (event) => {
focusedByPointer = null
showFocus(input, true)
if (input.disabled) {
return
}
if (event.key === ' ' || event.code === 'Space') {
// Space alone does nothing to a range input; hold the page still while it waits.
spaceHeld = true
event.preventDefault()
return
}
const direction = largeStepDirection(event)
if (direction === 0) {
return
}
event.preventDefault()
const { min, max, step } = bounds()
const steps = step === null ? 100 : Math.max(Math.round((max - min) / step), 1)
const delta = ((max - min) / steps) * clamp(Math.floor(steps / 10), 1, 10)
if (commit(inputs.indexOf(input), Number.parseFloat(input.value) + delta * direction)) {
input.dispatchEvent(new Event('change', { bubbles: true }))
}
})
listen(input, 'keyup', (event) => {
if (event.key === ' ' || event.code === 'Space') {
spaceHeld = false
}
})
})
/** +1, -1 or 0: which way a key moves the value by one large interval. */
function largeStepDirection(event) {
if (event.key === 'PageUp') {
return 1
}
if (event.key === 'PageDown') {
return -1
}
if (!spaceHeld) {
return 0
}
const rtl = getComputedStyle(root).direction === 'rtl'
switch (event.key) {
case 'ArrowUp':
return 1
case 'ArrowDown':
return -1
case 'ArrowRight':
return rtl ? -1 : 1
case 'ArrowLeft':
return rtl ? 1 : -1
default:
return 0
}
}
// Before x-model or wire:model reads it, hold a range input at its neighbour instead of passing it.
listen(
root,
'input',
(event) => {
const index = inputs.indexOf(event.target)
const [from, to] = values()
if (range && index !== -1 && from > to) {
VALUE.set.call(event.target, String(index === 0 ? to : from))
}
},
true,
)
listen(root, 'input', schedule)
const mutations = new MutationObserver(schedule)
inputs.forEach((input) => mutations.observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step', 'value', 'disabled'] }))
mutations.observe(root, { attributes: true, attributeFilter: ['data-size', 'data-centered', 'data-orientation', 'dir'] })
const resizes = new ResizeObserver(schedule)
resizes.observe(root)
cleanups.push(
() => mutations.disconnect(),
() => resizes.disconnect(),
)
draw()
return {
destroy() {
release?.()
cleanups.forEach((cleanup) => cleanup())
},
/** A press on the slider: sliderTapModifier, draggable and RangeSliderLogic.captureThumb. */
press(event) {
if ((event.pointerType === 'mouse' && event.button !== 0) || inputs.some((input) => input.disabled) || release) {
return
}
event.preventDefault()
const box = root.getBoundingClientRect()
const upright = vertical()
const width = Math.max((upright ? box.height : box.width) - HANDLE_WIDTH, 0)
const rtl = getComputedStyle(root).direction === 'rtl'
const { min, max } = bounds()
const handles = geometry(width).handles
// Where a pointer is along the track, and how far into it that is from the low end —
// the left edge lying down (the right one in a right-to-left page), the bottom standing up.
const along = (pointer) => (upright ? pointer.clientY : pointer.clientX)
const offset = (coordinate) =>
clamp(
upright
? box.bottom - HANDLE_WIDTH / 2 - coordinate
: rtl
? box.right - HANDLE_WIDTH / 2 - coordinate
: coordinate - box.left - HANDLE_WIDTH / 2,
0,
width,
)
const valueAt = (coordinate) => min + (width > 0 ? offset(coordinate) / width : 0) * (max - min)
const start = along(event)
const before = values()
let dragging = event.pointerType !== 'touch'
let index = null
// The nearest handle; handles on top of each other wait for the first move's direction.
const choose = (coordinate) => {
if (!range) {
return 0
}
const x = offset(coordinate)
const [toStart, toEnd] = handles.map((handle) => Math.abs(handle - x))
if (toStart !== toEnd) {
return toStart < toEnd ? 0 : 1
}
const moved = upright || rtl ? start - coordinate : coordinate - start
return moved === 0 ? null : moved < 0 ? 0 : 1
}
const follow = (coordinate) => {
index ??= choose(coordinate)
if (index === null) {
return
}
if (document.activeElement !== inputs[index]) {
focusedByPointer = inputs[index]
inputs[index].focus({ preventScroll: true })
}
pressed(index)
commit(index, valueAt(coordinate))
}
const move = (moveEvent) => {
if (moveEvent.pointerId !== event.pointerId) {
return
}
if (!dragging && Math.abs(along(moveEvent) - start) < TOUCH_SLOP) {
return
}
dragging = true
follow(along(moveEvent))
}
const end = (endEvent) => {
if (endEvent.pointerId !== event.pointerId) {
return
}
if (endEvent.type === 'pointerup' && !dragging) {
follow(along(endEvent))
}
release()
values().forEach((value, i) => {
if (value !== before[i]) {
inputs[i].dispatchEvent(new Event('change', { bubbles: true }))
}
})
}
release = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', end)
window.removeEventListener('pointercancel', end)
pressed(null)
release = null
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', end)
window.addEventListener('pointercancel', end)
if (dragging) {
follow(start)
}
},
}
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialSlider', () => {
let behaviour = null
return {
init() {
behaviour = slider(this.$el)
},
destroy() {
behaviour?.destroy()
},
press(event) {
behaviour?.press(event)
},
}
})
})