Files
Andreas Reinhold / reiniandClaude Opus 5 281c21b0a4 Draw the slider without Tailwind
<x-slider> renders data-md-slider with its size, colour, value label,
orientation and centring as data-md-* attributes and its parts as
data-md-slider-*; every size, colour role, disabled state, the vertical
quarter turn, RTL mirroring and the no-script fallback move into
components/slider.css as custom properties per size and colour (plan
step 36). slider.js reads the props from the root and marks the
renamed handle, tick and icon hooks.

Browser tests pin the handle centred on the track at each size, Space
held with an arrow, and the vertical slider's drag along Y, keys and
value label beside the handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:01:19 +02:00

654 lines
26 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) {
// The component's root carries the props; `root` is the control inside it the pointer presses.
const host = root.closest('[data-md-slider]') ?? root
const inputs = [...root.querySelectorAll(':scope > input[type="range"]')]
const drawing = root.querySelector('[data-md-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 = () => host.dataset.mdOrientation === '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-md-slider-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`)
const tickFractions = () => [...(drawing?.querySelectorAll('[data-md-slider-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.mdSliderTick))
const geometry = (width) => {
const { min, max } = bounds()
return sliderGeometry({
width,
size: host.dataset.mdSize,
fractions: values().map((value) => (Number.isFinite(value) ? clamp((value - min) / (max - min), 0, 1) : 0)),
centered: host.hasAttribute('data-md-centered'),
range,
tickFractions: tickFractions(),
})
}
const draw = () => {
if (!drawing) {
return
}
const width = trackLength()
const size = SIZES[host.dataset.mdSize] ?? 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-md-slider-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-md-slider-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-md-slider-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-md-active', tick.active)
}
})
handles.forEach((x, i) => {
const thumb = part(`[data-md-slider-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-md-slider-value]')
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-md-slider-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-md-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-md-focused', shown)
/** Compose narrows a handle and shows its label while it is pressed or dragged. */
const pressed = (index) => drawing?.querySelectorAll('[data-md-slider-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-md-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(host, { attributes: true, attributeFilter: ['data-md-size', 'data-md-centered', 'data-md-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)
},
}
})
})