/** * `materialSlider`: draws ``, 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. * * - 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). * - 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, stops: Object, 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 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 } } 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 = Math.max(root.clientWidth - HANDLE_WIDTH, 0) 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 showFocus(input, false) }) // slideOnKeyEvents: PageUp and PageDown move a tenth of the steps, one to ten of them. listen(input, 'keydown', (event) => { focusedByPointer = null showFocus(input, true) if (!['PageUp', 'PageDown'].includes(event.key) || input.disabled) { 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) + (event.key === 'PageUp' ? delta : -delta))) { input.dispatchEvent(new Event('change', { bubbles: true })) } }) }) // 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', '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 width = Math.max(box.width - HANDLE_WIDTH, 0) const rtl = getComputedStyle(root).direction === 'rtl' const { min, max } = bounds() const handles = geometry(width).handles const offset = (clientX) => clamp(rtl ? box.right - HANDLE_WIDTH / 2 - clientX : clientX - box.left - HANDLE_WIDTH / 2, 0, width) const valueAt = (clientX) => min + (width > 0 ? offset(clientX) / width : 0) * (max - min) const startX = event.clientX 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 = (clientX) => { if (!range) { return 0 } const x = offset(clientX) const [start, end] = handles.map((handle) => Math.abs(handle - x)) if (start !== end) { return start < end ? 0 : 1 } const moved = rtl ? startX - clientX : clientX - startX return moved === 0 ? null : moved < 0 ? 0 : 1 } const follow = (clientX) => { index ??= choose(clientX) if (index === null) { return } if (document.activeElement !== inputs[index]) { focusedByPointer = inputs[index] inputs[index].focus({ preventScroll: true }) } pressed(index) commit(index, valueAt(clientX)) } const move = (moveEvent) => { if (moveEvent.pointerId !== event.pointerId) { return } if (!dragging && Math.abs(moveEvent.clientX - startX) < TOUCH_SLOP) { return } dragging = true follow(moveEvent.clientX) } const end = (endEvent) => { if (endEvent.pointerId !== event.pointerId) { return } if (endEvent.type === 'pointerup' && !dragging) { follow(endEvent.clientX) } 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(event.clientX) } }, } } 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) }, } }) })