From 790ef93c6a8b1c2e6fc4c5e116e073d16c05399f Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:31:07 +0200 Subject: [PATCH] Stand a slider up with orientation="vertical" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 7 ++ resources/js/slider.js | 60 ++++++++++------ resources/views/components/slider.blade.php | 68 +++++++++++++++---- .../views/showcase/sections/sliders.blade.php | 12 +++- tests/Feature/Components/SliderTest.php | 30 +++++++- 5 files changed, 144 insertions(+), 33 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 56e114af..71e2bd06 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -597,10 +597,17 @@ M3 Expressive's slider on native ``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 + +``` + ### `` M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire). diff --git a/resources/js/slider.js b/resources/js/slider.js index 8f8a47aa..f8945571 100644 --- a/resources/js/slider.js +++ b/resources/js/slider.js @@ -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) } }, } diff --git a/resources/views/components/slider.blade.php b/resources/views/components/slider.blade.php index 6fda8939..6853a75a 100644 --- a/resources/views/components/slider.blade.php +++ b/resources/views/components/slider.blade.php @@ -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 -
only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized]) }}> +
only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized && ! $vertical, 'flex w-fit flex-col' => $vertical]) }}> @if (filled($label)) @if ($range) {{ $label }} @@ -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', ]) > -