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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:31:07 +02:00
co-authored by Claude Fable 5.1
parent cc91e99a5d
commit 790ef93c6a
5 changed files with 144 additions and 33 deletions
+41 -19
View File
@@ -15,6 +15,9 @@
* 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
@@ -224,6 +227,12 @@ function slider(root) {
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'}"]`)
@@ -247,7 +256,7 @@ function slider(root) {
return
}
const width = Math.max(root.clientWidth - HANDLE_WIDTH, 0)
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)
@@ -484,7 +493,7 @@ function slider(root) {
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'] })
mutations.observe(root, { attributes: true, attributeFilter: ['data-size', 'data-centered', 'data-orientation', 'dir'] })
const resizes = new ResizeObserver(schedule)
resizes.observe(root)
@@ -511,39 +520,52 @@ function slider(root) {
event.preventDefault()
const box = root.getBoundingClientRect()
const width = Math.max(box.width - HANDLE_WIDTH, 0)
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
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)
// 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 startX = event.clientX
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 = (clientX) => {
const choose = (coordinate) => {
if (!range) {
return 0
}
const x = offset(clientX)
const [start, end] = handles.map((handle) => Math.abs(handle - x))
const x = offset(coordinate)
const [toStart, toEnd] = handles.map((handle) => Math.abs(handle - x))
if (start !== end) {
return start < end ? 0 : 1
if (toStart !== toEnd) {
return toStart < toEnd ? 0 : 1
}
const moved = rtl ? startX - clientX : clientX - startX
const moved = upright || rtl ? start - coordinate : coordinate - start
return moved === 0 ? null : moved < 0 ? 0 : 1
}
const follow = (clientX) => {
index ??= choose(clientX)
const follow = (coordinate) => {
index ??= choose(coordinate)
if (index === null) {
return
@@ -555,7 +577,7 @@ function slider(root) {
}
pressed(index)
commit(index, valueAt(clientX))
commit(index, valueAt(coordinate))
}
const move = (moveEvent) => {
@@ -563,12 +585,12 @@ function slider(root) {
return
}
if (!dragging && Math.abs(moveEvent.clientX - startX) < TOUCH_SLOP) {
if (!dragging && Math.abs(along(moveEvent) - start) < TOUCH_SLOP) {
return
}
dragging = true
follow(moveEvent.clientX)
follow(along(moveEvent))
}
const end = (endEvent) => {
@@ -577,7 +599,7 @@ function slider(root) {
}
if (endEvent.type === 'pointerup' && !dragging) {
follow(endEvent.clientX)
follow(along(endEvent))
}
release()
@@ -602,7 +624,7 @@ function slider(root) {
window.addEventListener('pointercancel', end)
if (dragging) {
follow(event.clientX)
follow(start)
}
},
}