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
652 lines
25 KiB
JavaScript
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)
|
|
},
|
|
}
|
|
})
|
|
})
|