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
+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',
])