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
@@ -597,10 +597,17 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
| `ticks` | `false` | a mark per step (up to 200, hidden while closer than 8px); the handle sits on the marks |
| `value-label` | `drag` | `drag` (while pressed, dragged or keyboard-focused), `always`, `never` |
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
| `orientation` | `horizontal` | `vertical` stands it up: the value grows upwards, the value label sits beside the handle, Up and Down move it. Ignored with `range` — M3 keeps range sliders horizontal |
| `disabled` | `false` | |
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
A vertical slider is as wide as a horizontal one is tall and as long as the wrapper it is in, so **give it a height**`class="h-64"`, which the label and hint share. Without one it is 192px long.
```blade
<x-slider label="Volume" orientation="vertical" wire:model.live="volume" class="h-64" />
```
### `<x-datepicker>`
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
+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)
}
},
}
+56 -12
View File
@@ -26,6 +26,15 @@
`secondary`, `tertiary`, `error`, `success`, `warning`, `info` — the active track and the
handle; the inactive track is its container (secondary-container for primary).
`orientation="vertical"` stands the slider up, M3 Expressive's second orientation: the same
drawing, turned a quarter so the value grows upwards, with the value label beside the handle
instead of above it and every size unchanged. The keyboard is the native range's, so Up and
Right raise the value and Down and Left lower it. A vertical slider is as wide as a horizontal
one is tall and takes its length from the wrapper, so give it an explicit height —
`class="h-64"`, which the label and the hint share; without one it is 192px. M3 keeps range
sliders horizontal ("never use range sliders vertically — too much cognitive load"), so
`range` wins over `orientation`.
`label` is shown above and names the input (with `range`, the group; its handles are "Range
start" and "Range end"); `hint` goes below, and a validation message for the bound property
(or `name`) replaces it. Other attributes go to the input(s).
@@ -71,6 +80,7 @@
'valueLabel' => 'drag',
'icon' => null,
'color' => 'primary',
'orientation' => 'horizontal',
'disabled' => false,
])
@@ -81,6 +91,8 @@
$color = in_array($color, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? $color : 'primary';
$valueLabel = in_array($valueLabel, ['always', 'drag', 'never'], true) ? $valueLabel : 'drag';
$centered = $centered && ! $range;
// M3 § Sliders, Behaviour: "range sliders should stay horizontal only".
$vertical = $orientation === 'vertical' && ! $range;
$minimum = is_numeric($min) ? (float) $min : 0.0;
$maximum = is_numeric($max) && (float) $max > $minimum ? (float) $max : $minimum + 100;
@@ -267,10 +279,19 @@
][$size];
// The label's bottom sits 4px above the handle: half of 44, 44, 52, 68 and 108px, plus 4.
$labelBottom = ['xs' => 'bottom-[calc(50%+1.625rem)]', 'sm' => 'bottom-[calc(50%+1.625rem)]', 'md' => 'bottom-[calc(50%+1.875rem)]', 'lg' => 'bottom-[calc(50%+2.375rem)]', 'xl' => 'bottom-[calc(50%+3.625rem)]'][$size];
// The measure across the slider — the handle's height, and so the slider's own. Standing up it
// is the width instead, and the length comes from the wrapper the caller sized.
$across = [
'xs' => $vertical ? 'w-12' : 'h-12',
'sm' => $vertical ? 'w-12' : 'h-12',
'md' => $vertical ? 'w-13' : 'h-13',
'lg' => $vertical ? 'w-17' : 'h-17',
'xl' => $vertical ? 'w-27' : 'h-27',
][$size];
$thumbs = $range ? ['start', 'end'] : ['start'];
@endphp
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized]) }}>
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized && ! $vertical, 'flex w-fit flex-col' => $vertical]) }}>
@if (filled($label))
@if ($range)
<span id="{{ $id }}-label" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</span>
@@ -284,20 +305,31 @@
x-on:pointerdown="press($event)"
data-slider
data-size="{{ $size }}"
@if ($vertical) data-orientation="vertical" @endif
@if ($centered) data-centered @endif
@if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif
@class([
'group/slider relative cursor-pointer touch-pan-y select-none has-disabled:cursor-not-allowed',
'h-12' => in_array($size, ['xs', 'sm'], true),
'h-13' => $size === 'md',
'h-17' => $size === 'lg',
'h-27' => $size === 'xl',
// Room above for a label that never hides: half the handle, 4px, and the 44px pill.
'mt-12' => $valueLabel === 'always',
'group/slider relative cursor-pointer select-none has-disabled:cursor-not-allowed',
$across,
'touch-pan-y' => ! $vertical,
// Standing up: 192px long unless the wrapper says otherwise, and a size container,
// which is what lets the drawing below be as long as this is tall.
'touch-pan-x h-48 min-h-0 flex-auto [container-type:size]' => $vertical,
// Room for a label that never hides: half the handle, 4px and the 44px pill — above the
// track when the slider lies down, beside it when it stands up.
'mt-12' => $valueLabel === 'always' && ! $vertical,
'ms-12' => $valueLabel === 'always' && $vertical,
'noscript:h-auto noscript:cursor-auto noscript:space-y-2',
])
>
<div data-slider-drawing wire:ignore aria-hidden="true" class="pointer-events-none absolute inset-y-0 inset-x-0.5 rtl:-scale-x-100 noscript:hidden">
<div data-slider-drawing wire:ignore aria-hidden="true" @class([
'pointer-events-none absolute noscript:hidden',
'inset-y-0 inset-x-0.5 rtl:-scale-x-100' => ! $vertical,
// The same drawing, laid out as long as the slider is tall and then turned a quarter
// anticlockwise so the value grows upwards. Everything inside it — the segments, the
// ticks, the handle and its label — is placed as if the slider were lying down.
'top-1/2 left-1/2 h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90' => $vertical,
])>
<div @class(['absolute inset-x-0 top-1/2 -translate-y-1/2', $trackHeight])>
@foreach (['start' => $inactiveInk[$color], 'active' => $activeInk[$color], 'end' => $inactiveInk[$color]] as $part => $ink)
<span
@@ -337,7 +369,14 @@
data-track-icon
@if (! $iconInActive && ! $iconInInactive) hidden @endif
@if ($iconInActive) data-active @endif
@class(['absolute top-1/2 flex -translate-y-1/2 rtl:-scale-x-100', $iconInk[$color], 'group-has-disabled/slider:text-on-surface/38'])
@class([
'absolute top-1/2 flex -translate-y-1/2',
'rtl:-scale-x-100' => ! $vertical,
// Turned back the quarter the drawing turns, so the glyph stands up.
'rotate-90' => $vertical,
$iconInk[$color],
'group-has-disabled/slider:text-on-surface/38',
])
style="left: {{ $calc($iconAt) }}"
>
<x-livewire-material::icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
@@ -358,11 +397,15 @@
])></span>
@if ($valueLabel !== 'never')
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center rtl:-scale-x-100', $labelBottom])>
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center', 'rtl:-scale-x-100' => ! $vertical, $labelBottom])>
<span
data-value-label
@class([
'flex h-11 min-w-12 origin-bottom items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
'flex h-11 min-w-12 items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
'origin-bottom' => ! $vertical,
// Turned back the quarter the drawing turns: the pill lands
// beside the handle rather than above it, and reads across.
'rotate-90' => $vertical,
'transition-[opacity,scale] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
'opacity-0 scale-75 group-data-pressed/thumb:opacity-100 group-data-pressed/thumb:scale-100 group-data-focused/thumb:opacity-100 group-data-focused/thumb:scale-100' => $valueLabel === 'drag',
])
@@ -383,6 +426,7 @@
step="{{ $continuous ? 'any' : $format($interval) }}"
value="{{ $format($values[$index]) }}"
data-thumb="{{ $thumb }}"
@if ($vertical) aria-orientation="vertical" @endif
@if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif
@if ($described) aria-describedby="{{ $described }}" @endif
@if ($messages !== []) aria-invalid="true" @endif
@@ -22,6 +22,16 @@
<x-slider label="Balance" value="15" :min="-50" :max="50" centered />
</div>
BLADE,
'Vertical' => <<<'BLADE'
{{-- M3 Expressive's second orientation. A vertical slider needs a height: it is on the wrapper, which the label and hint share. --}}
<div class="flex flex-wrap items-end gap-10">
<x-slider label="Volume" orientation="vertical" value="40" class="h-64" hint="Up and Down" />
<x-slider label="Warmth" orientation="vertical" value="6" :max="10" ticks class="h-64" />
<x-slider label="Brightness" orientation="vertical" value="70" size="md" icon="light_mode" class="h-64" />
<x-slider label="Always labelled" orientation="vertical" value="55" value-label="always" color="tertiary" class="h-64" />
<x-slider label="Locked" orientation="vertical" value="25" disabled class="h-64" />
</div>
BLADE,
'Colours and value labels' => <<<'BLADE'
<div class="grid w-full gap-8 expanded:grid-cols-3">
<x-slider label="Always labelled" value="55" color="secondary" value-label="always" size="sm" />
@@ -48,7 +58,7 @@
<h2 class="type-headline-md">Sliders</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-slider&gt;</code>: M3 Expressive's slider on native range inputs standard, range and centred, five sizes, ticks, value labels and an inset icon.
<code>&lt;x-slider&gt;</code>: M3 Expressive's slider on native range inputs standard, range and centred, horizontal or vertical, five sizes, ticks, value labels and an inset icon.
</p>
@foreach ($examples as $title => $code)
+29 -1
View File
@@ -38,7 +38,8 @@ it('draws the first frame on the server: the track split around the handle, and
it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () {
expect((string) $this->blade('<x-slider value="40" />'))
->toContain('flex h-11 min-w-12 origin-bottom')
->toContain('flex h-11 min-w-12 items-center')
->toMatch('/data-value-label[^>]*class="[^"]*origin-bottom/')
->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/');
});
@@ -196,6 +197,33 @@ it('shows the value label on drag, always, or never', function () {
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-value-label');
});
it('stands a slider up on request, turning the drawing a quarter', function () {
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />');
expect($html)
->toContain('data-orientation="vertical"')
->toContain('aria-orientation="vertical"')
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
->toContain('w-12')
->toContain('touch-pan-x h-48 min-h-0 flex-auto [container-type:size]')
->toContain('h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90')
// The value label is turned back, so it lands beside the handle and reads across.
->toMatch('/data-value-label[^>]*class="[^"]*rotate-90/')
->not->toContain('origin-bottom')
->not->toContain('rtl:-scale-x-100');
// The sizes are the horizontal ones, across instead of along.
expect((string) $this->blade('<x-slider orientation="vertical" size="xl" />'))->toContain('w-27')
->and((string) $this->blade('<x-slider orientation="vertical" value-label="always" />'))->toContain('ms-12');
});
it('keeps a range slider horizontal, as M3 asks', function () {
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
->not->toContain('data-orientation')
->not->toContain('aria-orientation')
->toContain('h-12');
});
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
->toContain("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38")