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:
co-authored by
Claude Fable 5.1
parent
da4952e684
commit
66f9d36a37
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user