Draw the slider on M3's own specs table

The md handle is 52px, not the 44 the MDC tokens gave it; the value
indicator is M3's 44x48 label container, not Flutter's 32px pill; and a
stop indicator, a tick and the inset icon now take the "on" colour of the
part of the track they sit on, which M3's nine colour roles spell out and
the library had inverted. Space held with an arrow moves by the same
large interval as PageUp, M3's "Space & Arrows" row.

Plan step 20, findings IN-04, IN-05, IN-06 and IN-24.

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 05:55:41 +02:00
co-authored by Claude Fable 5.1
parent da4952e684
commit 66f9d36a37
4 changed files with 114 additions and 33 deletions
@@ -574,7 +574,7 @@ M3 selection controls on native inputs; the whole row is the label.
### `<x-slider>`
M3 Expressive's slider on native `<input type="range">`s (one per handle), so the arrow keys, Home, End, forms and screen readers work as on a plain range; PageUp and PageDown move a tenth of the steps (1 to 10). A press anywhere on the slider moves the nearest handle there. Without JavaScript the native range shows, and posts.
M3 Expressive's slider on native `<input type="range">`s (one per handle), so the arrow keys, Home, End, forms and screen readers work as on a plain range; PageUp and PageDown move a tenth of the steps (1 to 10), and so does an arrow pressed while Space is held (M3's large interval). A press anywhere on the slider moves the nearest handle there. Without JavaScript the native range shows, and posts.
```blade
<x-slider label="Volume" wire:model.live="volume" hint="Applies at once" />
+60 -4
View File
@@ -18,7 +18,9 @@
* - 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).
* 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).
*
* ---------------------------------------------------------------------------------------
@@ -203,6 +205,8 @@ function slider(root) {
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)
@@ -386,15 +390,31 @@ function slider(root) {
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.
// 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 (!['PageUp', 'PageDown'].includes(event.key) || input.disabled) {
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
}
@@ -404,12 +424,48 @@ function slider(root) {
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) + (event.key === 'PageUp' ? delta : -delta))) {
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,
+43 -24
View File
@@ -35,14 +35,22 @@
and drawTrack, ThumbContent and SliderTokens.kt: a 4px handle, narrowed to 2px while
pressed or focused, in a 6px gap that splits the track, 2px inside corners, a 4px stop
indicator at each open end of the track, active track and handle in primary, inactive track
in secondary-container, ticks in the other part's colour, disabled parts in on-surface at 38%
and 12%. Compose leaves the value label to the app; this one is M3's as Flutter draws it
(RoundedRectSliderValueIndicatorShape): a 32px inverse-surface pill with label-large, 10px
padding, 4px above the handle, which is 6px shorter while its focus ring shows (Material
Components' m3_slider_focus_ring_thumb_height_decrease). The Expressive sizes are
Material Components for Android's md.comp.slider.xsmall xlarge tokens: tracks of 16, 24,
40, 56 and 96px with 8, 8, 12, 16 and 28px corners, handles of 44, 44, 44, 68 and 108px,
icons of 24, 24 and 32px, 10px from their segment's end.
in secondary-container, disabled parts in on-surface at 38% and 12%. A stop indicator, a tick
and the inset icon take the "on" colour of the part of the track they sit on on-primary on
the active track, on-secondary-container on the inactive one which is the order M3's own
colour list gives (docs/reference/m3/components-navigation-selection-inputs.md § Sliders),
against SliderTokens' StopIndicatorColor. Compose leaves the value label to the app; this one
is M3's label container: 44px tall and at least 48px wide in inverse-surface with label-large,
4px above the handle (the library's spacing, where SliderTokens has
ValueIndicatorActiveBottomSpace = 12dp), and the handle is 6px shorter while its focus ring
shows (Material Components' m3_slider_focus_ring_thumb_height_decrease). The Expressive sizes
are M3's sliders/specs table: tracks of 16, 24, 40, 56 and 96px with 8, 8, 12, 16 and 28px
corners, handles of 44, 44, 52, 68 and 108px, icons of 24, 24 and 32px, 10px from their
segment's end.
The keyboard is the inputs' own the arrows, Home and End with PageUp and PageDown moving
by Compose's page (a tenth of the steps) and Space held with an arrow moving by that same large
interval, which is M3's "Space & Arrows" row (resources/js/slider.js).
The server draws the first frame; the drawing is `wire:ignore`, so a Livewire render never
fights a handle being dragged, and a value the server changes moves the handle after the
@@ -223,18 +231,27 @@
'primary' => 'bg-secondary-container', 'secondary' => 'bg-secondary-container', 'tertiary' => 'bg-tertiary-container',
'error' => 'bg-error-container', 'success' => 'bg-success-container', 'warning' => 'bg-warning-container', 'info' => 'bg-info-container',
];
// A tick, like the icon, takes the colour of the part of the track it is not on.
// A tick, a stop indicator and the icon are drawn on the track, so each takes the "on" colour of
// the part it sits on: on-primary over the active track, on-secondary-container over the
// inactive one (M3's nine slider colour roles, in anatomy order). `data-active` marks the ones
// over the active track.
$tickInk = [
'primary' => 'bg-primary data-active:bg-secondary-container', 'secondary' => 'bg-secondary data-active:bg-secondary-container',
'tertiary' => 'bg-tertiary data-active:bg-tertiary-container', 'error' => 'bg-error data-active:bg-error-container',
'success' => 'bg-success data-active:bg-success-container', 'warning' => 'bg-warning data-active:bg-warning-container',
'info' => 'bg-info data-active:bg-info-container',
'primary' => 'bg-on-secondary-container data-active:bg-on-primary', 'secondary' => 'bg-on-secondary-container data-active:bg-on-secondary',
'tertiary' => 'bg-on-tertiary-container data-active:bg-on-tertiary', 'error' => 'bg-on-error-container data-active:bg-on-error',
'success' => 'bg-on-success-container data-active:bg-on-success', 'warning' => 'bg-on-warning-container data-active:bg-on-warning',
'info' => 'bg-on-info-container data-active:bg-on-info',
];
// A stop indicator only ever sits at an open end of the track, which is inactive.
$stopInk = [
'primary' => 'bg-on-secondary-container', 'secondary' => 'bg-on-secondary-container', 'tertiary' => 'bg-on-tertiary-container',
'error' => 'bg-on-error-container', 'success' => 'bg-on-success-container', 'warning' => 'bg-on-warning-container',
'info' => 'bg-on-info-container',
];
$iconInk = [
'primary' => 'text-primary data-active:text-secondary-container', 'secondary' => 'text-secondary data-active:text-secondary-container',
'tertiary' => 'text-tertiary data-active:text-tertiary-container', 'error' => 'text-error data-active:text-error-container',
'success' => 'text-success data-active:text-success-container', 'warning' => 'text-warning data-active:text-warning-container',
'info' => 'text-info data-active:text-info-container',
'primary' => 'text-on-secondary-container data-active:text-on-primary', 'secondary' => 'text-on-secondary-container data-active:text-on-secondary',
'tertiary' => 'text-on-tertiary-container data-active:text-on-tertiary', 'error' => 'text-on-error-container data-active:text-on-error',
'success' => 'text-on-success-container data-active:text-on-success', 'warning' => 'text-on-warning-container data-active:text-on-warning',
'info' => 'text-on-info-container data-active:text-on-info',
];
$accent = [
'primary' => 'noscript:accent-primary', 'secondary' => 'noscript:accent-secondary', 'tertiary' => 'noscript:accent-tertiary',
@@ -245,11 +262,11 @@
// A focused handle is 6px shorter, so its ring stays clear of the label (Material Components'
// m3_slider_focus_ring_thumb_height_decrease).
$handleHeight = [
'xs' => 'h-11 group-data-focused/thumb:h-9.5', 'sm' => 'h-11 group-data-focused/thumb:h-9.5', 'md' => 'h-11 group-data-focused/thumb:h-9.5',
'xs' => 'h-11 group-data-focused/thumb:h-9.5', 'sm' => 'h-11 group-data-focused/thumb:h-9.5', 'md' => 'h-13 group-data-focused/thumb:h-11.5',
'lg' => 'h-17 group-data-focused/thumb:h-15.5', 'xl' => 'h-27 group-data-focused/thumb:h-25.5',
][$size];
// The label's bottom sits 4px above the handle.
$labelBottom = ['xs' => 'bottom-[calc(50%+1.625rem)]', 'sm' => 'bottom-[calc(50%+1.625rem)]', 'md' => 'bottom-[calc(50%+1.625rem)]', 'lg' => 'bottom-[calc(50%+2.375rem)]', 'xl' => 'bottom-[calc(50%+3.625rem)]'][$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];
$thumbs = $range ? ['start', 'end'] : ['start'];
@endphp
@@ -271,10 +288,12 @@
@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', 'md'], true),
'h-12' => in_array($size, ['xs', 'sm'], true),
'h-13' => $size === 'md',
'h-17' => $size === 'lg',
'h-27' => $size === 'xl',
'mt-9' => $valueLabel === 'always',
// Room above for a label that never hides: half the handle, 4px, and the 44px pill.
'mt-12' => $valueLabel === 'always',
'noscript:h-auto noscript:cursor-auto noscript:space-y-2',
])
>
@@ -308,7 +327,7 @@
<span
data-stop="{{ $part }}"
@if (! isset($stops[$part])) hidden @endif
@class(['absolute top-1/2 size-1 -translate-1/2 rounded-corner-full', $activeInk[$color], 'group-has-disabled/slider:bg-on-surface/38'])
@class(['absolute top-1/2 size-1 -translate-1/2 rounded-corner-full', $stopInk[$color], 'group-has-disabled/slider:bg-on-surface/38'])
style="{{ isset($stops[$part]) ? 'left: '.$calc($stops[$part]) : '' }}"
></span>
@endforeach
@@ -343,7 +362,7 @@
<span
data-value-label
@class([
'flex h-8 min-w-9 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 origin-bottom items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
'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',
])