diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 38ecc56c..a7c80ca1 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -574,7 +574,7 @@ M3 selection controls on native inputs; the whole row is the label. ### `` -M3 Expressive's slider on native ``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 ``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 diff --git a/resources/js/slider.js b/resources/js/slider.js index 43a6fca9..8f8a47aa 100644 --- a/resources/js/slider.js +++ b/resources/js/slider.js @@ -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, diff --git a/resources/views/components/slider.blade.php b/resources/views/components/slider.blade.php index 5db369b9..6fda8939 100644 --- a/resources/views/components/slider.blade.php +++ b/resources/views/components/slider.blade.php @@ -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 @@ @endforeach @@ -343,7 +362,7 @@ $valueLabel === 'drag', ]) diff --git a/tests/Feature/Components/SliderTest.php b/tests/Feature/Components/SliderTest.php index 26edd92c..ff6a6743 100644 --- a/tests/Feature/Components/SliderTest.php +++ b/tests/Feature/Components/SliderTest.php @@ -36,6 +36,12 @@ it('draws the first frame on the server: the track split around the handle, and ->toMatch('/data-value-label\s+class="[^"]*opacity-0[^"]*"\s*>40<\/span>/'); }); +it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () { + expect((string) $this->blade('')) + ->toContain('flex h-11 min-w-12 origin-bottom') + ->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/'); +}); + it('snaps and clamps the value to the step grid', function (string $template, string $value) { expect((string) $this->blade($template))->toContain("value=\"{$value}\""); })->with([ @@ -139,7 +145,7 @@ it('sizes the track and the handle by Expressive\'s tokens', function (string $s })->with([ 'xs' => ['xs', 'h-4', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'], 'sm' => ['sm', 'h-6', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'], - 'md' => ['md', 'h-10', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'], + 'md' => ['md', 'h-10', 'h-13 group-data-focused/thumb:h-11.5', 'h-13'], 'lg' => ['lg', 'h-14', 'h-17 group-data-focused/thumb:h-15.5', 'h-17'], 'xl' => ['xl', 'h-24', 'h-27 group-data-focused/thumb:h-25.5', 'h-27'], ]); @@ -148,7 +154,7 @@ it('insets an icon in the track from md, but not on a small, range or centred sl expect((string) $this->blade('')) ->toContain('data-track-icon') ->toMatch('/data-track-icon\s+data-active/') - ->toContain('text-primary data-active:text-secondary-container'); + ->toContain('text-on-secondary-container data-active:text-on-primary'); expect((string) $this->blade('')) ->toMatch('/data-track-icon\s+class/') @@ -172,7 +178,7 @@ it('marks every step when asked, and no more than 200 of them', function () { expect(substr_count($html, 'data-tick="'))->toBe(11) ->and($html)->toContain('data-tick="0.5"') - ->and($html)->toContain('bg-primary data-active:bg-secondary-container'); + ->and($html)->toContain('bg-on-secondary-container data-active:bg-on-primary'); expect((string) $this->blade(''))->not->toContain('data-tick=') ->and((string) $this->blade(''))->not->toContain('data-tick=') @@ -184,7 +190,7 @@ it('shows the value label on drag, always, or never', function () { expect((string) $this->blade('')) ->toContain('data-value-label') - ->toContain('mt-9') + ->toContain('mt-12') ->not->toContain('opacity-0 scale-75'); expect((string) $this->blade(''))->not->toContain('data-value-label');