<x-slider> renders data-md-slider with its size, colour, value label, orientation and centring as data-md-* attributes and its parts as data-md-slider-*; every size, colour role, disabled state, the vertical quarter turn, RTL mirroring and the no-script fallback move into components/slider.css as custom properties per size and colour (plan step 36). slider.js reads the props from the root and marks the renamed handle, tick and icon hooks. Browser tests pin the handle centred on the track at each size, Space held with an arrow, and the vertical slider's drag along Y, keys and value label beside the handle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
349 lines
19 KiB
PHP
349 lines
19 KiB
PHP
{{-- An M3 Expressive slider: a value, or with `range` a span of values, picked along a track.
|
|
|
|
<x-slider label="Volume" wire:model.live="volume" />
|
|
<x-slider label="Price" wire:model="price" range :min="0" :max="500" :step="10" />
|
|
<x-slider label="Balance" name="balance" :min="-50" :max="50" centered ticks :step="10" />
|
|
|
|
Built on native `<input type="range">`s, one per handle, which stay the control: the arrows,
|
|
Home and End, forms, `wire:model` and `x-model`, and screen readers all work as they do on a
|
|
plain range. M3's drawing lies under them (resources/js/slider.js); the inputs are invisible
|
|
and a press on the slider moves the nearest handle to the pointer. Without JavaScript the
|
|
inputs show as native ranges in the colour, and still post.
|
|
|
|
`min` (0), `max` (100), `step` (1, or `any`) and `value` as on a range input, or the bound
|
|
property's value. The binding gets `.number`, so values arrive as numbers: Livewire compares
|
|
what it sent with what came back, and a string that returns as an int would count as a
|
|
change and move a handle back while it is dragged. `range` binds an array `[from, to]` —
|
|
`wire:model="price"` binds `price.0` and `price.1`, `x-model="price"` binds `price[0]` and
|
|
`price[1]` — and posts `name[]` twice, from first; the handles never cross. `centered` fills from the middle of the track, for a
|
|
value that goes below zero. `ticks` marks every step (up to 200 of them, and only while they
|
|
stand 8px apart) and places the handle on the marks, as Compose does for a discrete slider.
|
|
`value-label` is `drag` (the default: while pressed, dragged or focused from the keyboard),
|
|
`always` or `never`. `size` is Expressive's `xs` (the default: a 16px track), `sm` 24px,
|
|
`md` 40px, `lg` 56px or `xl` 96px; `icon` puts a Material Symbol inside the track of an `md`,
|
|
`lg` or `xl` slider, at the start of the active track while it fits and of the inactive
|
|
track once it does not (not on a range or centred slider). `color`: `primary` (the default),
|
|
`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 — a height
|
|
rule on its `class`, 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).
|
|
|
|
From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326
|
|
(Apache-2.0): Slider.kt — SliderImpl, RangeSliderImpl, SliderDefaults.Track, CenteredTrack
|
|
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, 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
|
|
morph.
|
|
|
|
The root renders `data-md-slider` with `data-md-size`, `data-md-color`, `data-md-value-label`,
|
|
`data-md-orientation` and `data-md-centered`; the parts are `data-md-slider-*`, and everything
|
|
they look like is resources/css/components/slider.css. `class` and `style` land on the root. --}}
|
|
|
|
@props([
|
|
'label' => null,
|
|
'hint' => null,
|
|
'name' => null,
|
|
'value' => null,
|
|
'min' => 0,
|
|
'max' => 100,
|
|
'step' => 1,
|
|
'range' => false,
|
|
'centered' => false,
|
|
'size' => 'xs',
|
|
'ticks' => false,
|
|
'valueLabel' => 'drag',
|
|
'icon' => null,
|
|
'color' => 'primary',
|
|
'orientation' => 'horizontal',
|
|
'disabled' => false,
|
|
])
|
|
|
|
@php
|
|
$format = fn (float $number): string => rtrim(rtrim(number_format(round($number, 6) + 0.0, 6, '.', ''), '0'), '.');
|
|
|
|
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'xs';
|
|
$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;
|
|
$continuous = $step === 'any';
|
|
$interval = ! $continuous && is_numeric($step) && (float) $step > 0 ? (float) $step : 1.0;
|
|
// The highest value on the step grid, which is where a native range stops.
|
|
$top = $continuous ? $maximum : $minimum + floor(($maximum - $minimum) / $interval + 1e-9) * $interval;
|
|
$snap = fn (mixed $number, float $fallback): float => ! is_numeric($number)
|
|
? $fallback
|
|
: ($continuous
|
|
? min(max((float) $number, $minimum), $maximum)
|
|
: min(max($minimum + round(((float) $number - $minimum) / $interval) * $interval, $minimum), $top));
|
|
|
|
// The bound property's value, when the slider renders inside a Livewire component.
|
|
$model = $attributes->wire('model')->value() ?: null;
|
|
$livewire = $model !== null ? \Livewire\Livewire::current() : null;
|
|
$source = ($livewire !== null ? data_get($livewire, $model) : null) ?? $value;
|
|
|
|
if ($range) {
|
|
$pair = is_array($source) ? array_values($source) : (is_string($source) ? explode(',', $source) : []);
|
|
$values = [$snap($pair[0] ?? null, $minimum), $snap($pair[1] ?? null, $top)];
|
|
sort($values);
|
|
} else {
|
|
$values = [$snap($source, $centered ? $snap(($minimum + $maximum) / 2, $minimum) : $minimum)];
|
|
}
|
|
|
|
$fractions = array_map(fn (float $number): float => ($number - $minimum) / ($maximum - $minimum), $values);
|
|
|
|
// Bound as numbers: a string sent for an int property comes back "changed", and Livewire
|
|
// would write the server's copy over a handle still being dragged.
|
|
$bindingsFor = fn (int $index): array => collect($attributes->whereStartsWith(['wire:model', 'x-model'])->getAttributes())
|
|
->mapWithKeys(fn (string $expression, string $attribute): array => [
|
|
(str_contains($attribute, '.number') ? $attribute : preg_replace('/^(wire:model|x-model)/', '$1.number', $attribute)) => match (true) {
|
|
! $range => $expression,
|
|
str_starts_with($attribute, 'wire:model') => "{$expression}.{$index}",
|
|
default => "{$expression}[{$index}]",
|
|
},
|
|
])
|
|
->all();
|
|
|
|
$name ??= $model;
|
|
$key = $model ?? ($name !== null ? str_replace(['[', ']'], ['.', ''], $name) : null);
|
|
$messages = $key !== null && isset($errors)
|
|
? \Illuminate\Support\Arr::flatten([$errors->get($key), $range ? $errors->get($key.'.*') : []])
|
|
: [];
|
|
|
|
$id = $attributes->get('id') ?? 'material-slider-'.substr(md5(($model ?? $name ?? '').'|'.$label.'|'.($model ?? $name ?? \Illuminate\Support\Str::random(10))), 0, 10);
|
|
$described = $messages !== [] || filled($hint) ? "{$id}-hint" : null;
|
|
|
|
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'style', 'wire:key', 'id']);
|
|
|
|
// The first frame: Compose's layout and drawTrack, with every position a percentage of the
|
|
// track plus pixels. What only a measured width can decide — whether a segment has room, the
|
|
// icon's place, a tick in a gap — is decided for a 320px track; the script redraws exactly.
|
|
$nominal = 320.0;
|
|
$corner = ['xs' => 8, 'sm' => 8, 'md' => 12, 'lg' => 16, 'xl' => 28][$size];
|
|
$shrink = $size !== 'xs';
|
|
$iconSize = ['md' => 24, 'lg' => 24, 'xl' => 32][$size] ?? null;
|
|
$icon = filled($icon) && $iconSize !== null && ! $range && ! $centered ? $icon : null;
|
|
|
|
$tickFractions = [];
|
|
|
|
if ($ticks && ! $continuous && ($top - $minimum) / $interval <= 200) {
|
|
for ($index = 0; $minimum + $index * $interval <= $top + 1e-9; $index++) {
|
|
$tickFractions[] = $index * $interval / ($maximum - $minimum);
|
|
}
|
|
}
|
|
|
|
$discrete = $tickFractions !== [];
|
|
$length = fn (array $at): float => $at[0] / 100 * $nominal + $at[1];
|
|
$plus = fn (array $at, float $pixels): array => [$at[0], $at[1] + $pixels];
|
|
$onEdge = fn (float $fraction): bool => $discrete && (abs($fraction - $tickFractions[0]) < 1e-6 || abs($fraction - end($tickFractions)) < 1e-6);
|
|
$place = fn (float $fraction): array => $discrete && ! $onEdge($fraction) ? [$fraction * 100, $corner * (1 - 2 * $fraction)] : [$fraction * 100, 0.0];
|
|
|
|
$valueStart = $place($range ? $fractions[0] : 0.0);
|
|
$valueEnd = $place($range ? $fractions[1] : $fractions[0]);
|
|
$startThumb = $range || ($centered && $fractions[0] <= 0.5) ? 4 : 0;
|
|
$endThumb = ! $centered || $fractions[0] >= 0.5 ? 4 : 0;
|
|
$startGap = $range || $centered ? $startThumb / 2 + 6 : 0;
|
|
$endGap = $endThumb / 2 + 6;
|
|
$threshold = ! $shrink || $discrete ? $corner : 0;
|
|
$centre = [50.0, 0.0];
|
|
|
|
$segments = [];
|
|
$stops = [];
|
|
|
|
$adjustedEnd = $centered ? ($length($valueEnd) <= $length($centre) ? $valueEnd : $centre) : $valueStart;
|
|
|
|
if (($centered || $range) && $length($adjustedEnd) > $startGap + $threshold) {
|
|
$segments['start'] = [[0.0, 0.0], $plus($adjustedEnd, -$startGap), $corner, 2];
|
|
$stops['start'] = [0.0, (float) $corner];
|
|
}
|
|
|
|
$adjustedStart = $centered ? ($length($valueEnd) >= $length($centre) ? $valueEnd : $centre) : $valueEnd;
|
|
|
|
if ($length($adjustedStart) < $nominal - $endGap - $threshold) {
|
|
$segments['end'] = [$plus($adjustedStart, $endGap), [100.0, 0.0], 2, $corner];
|
|
$stops['end'] = [100.0, (float) -$corner];
|
|
}
|
|
|
|
$activeFrom = match (true) {
|
|
$centered => $plus($adjustedEnd, $length($adjustedEnd) < $length($centre) ? $startGap : 0),
|
|
$range => $plus($valueStart, $startGap),
|
|
default => [0.0, 0.0],
|
|
};
|
|
$activeTo = $centered ? $plus($adjustedStart, $length($adjustedStart) > $length($centre) ? -$endGap : 0) : $plus($valueEnd, -$endGap);
|
|
$activeStartRadius = $centered || $range ? 2 : $corner;
|
|
|
|
if ($length($activeTo) - $length($activeFrom) > (! $shrink || $discrete ? $activeStartRadius : 0)) {
|
|
$segments['active'] = [$activeFrom, $activeTo, $activeStartRadius, 2];
|
|
}
|
|
|
|
$spacing = count($tickFractions) > 1 ? ($nominal - $corner * 2) * $tickFractions[1] : INF;
|
|
$centreGap = $length($valueEnd) > $nominal / 2 ? $startGap : $endGap;
|
|
$valueGap = $centered && $length($valueEnd) <= $nominal / 2 ? $startGap : $endGap;
|
|
|
|
$tickMarks = array_map(function (float $tick, int $index) use ($tickFractions, $corner, $length, $centered, $range, $nominal, $centreGap, $valueStart, $valueEnd, $startGap, $valueGap, $spacing, $segments, $activeFrom, $activeTo): array {
|
|
$at = [$tick * 100, $corner * (1 - 2 * $tick)];
|
|
$x = $length($at);
|
|
|
|
return [
|
|
'fraction' => $tick,
|
|
'at' => $at,
|
|
'hidden' => (($centered || $range) && $index === 0) || $index === count($tickFractions) - 1
|
|
|| ($centered && abs($x - $nominal / 2) <= $centreGap)
|
|
|| ($range && abs($x - $length($valueStart)) <= $startGap)
|
|
|| abs($x - $length($valueEnd)) <= $valueGap
|
|
|| $spacing < 8,
|
|
'active' => $x >= $length($activeFrom) && $x <= $length($activeTo),
|
|
];
|
|
}, $tickFractions, array_keys($tickFractions));
|
|
|
|
$iconInActive = $icon !== null && isset($segments['active']) && $length($activeTo) - $length($activeFrom) >= $iconSize + 20;
|
|
$iconInInactive = $icon !== null && ! $iconInActive && isset($segments['end']) && $nominal - $length($segments['end'][0]) >= $iconSize + 20;
|
|
$iconAt = $iconInInactive ? $plus($segments['end'][0], 10) : [0.0, 10.0];
|
|
|
|
$calc = fn (array $at): string => 'calc('.$format($at[0]).'% '.($at[1] < 0 ? '-' : '+').' '.$format(abs($at[1])).'px)';
|
|
$segmentStyle = fn (?array $segment): string => $segment === null ? '' : 'left: '.$calc($segment[0]).'; width: '.$calc([$segment[1][0] - $segment[0][0], $segment[1][1] - $segment[0][1]])
|
|
.'; border-radius: '.$segment[2].'px '.$segment[3].'px '.$segment[3].'px '.$segment[2].'px';
|
|
|
|
$thumbs = $range ? ['start', 'end'] : ['start'];
|
|
@endphp
|
|
|
|
<div
|
|
data-md-slider
|
|
data-md-size="{{ $size }}"
|
|
data-md-color="{{ $color }}"
|
|
data-md-value-label="{{ $valueLabel }}"
|
|
@if ($vertical) data-md-orientation="vertical" @endif
|
|
@if ($centered) data-md-centered @endif
|
|
{{ $attributes->only(['class', 'style', 'wire:key']) }}
|
|
>
|
|
@if (filled($label))
|
|
@if ($range)
|
|
<span id="{{ $id }}-label" data-md-slider-label>{{ $label }}</span>
|
|
@else
|
|
<label for="{{ $id }}" id="{{ $id }}-label" data-md-slider-label>{{ $label }}</label>
|
|
@endif
|
|
@endif
|
|
|
|
<div
|
|
x-data="materialSlider"
|
|
x-on:pointerdown="press($event)"
|
|
data-md-slider-control
|
|
@if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif
|
|
>
|
|
<div data-md-slider-drawing wire:ignore aria-hidden="true">
|
|
<div data-md-slider-track>
|
|
@foreach (['start', 'active', 'end'] as $part)
|
|
<span
|
|
data-md-slider-segment="{{ $part }}"
|
|
@if (! isset($segments[$part])) hidden @endif
|
|
style="{{ $segmentStyle($segments[$part] ?? null) }}"
|
|
></span>
|
|
@endforeach
|
|
|
|
@foreach ($tickMarks as $mark)
|
|
<span
|
|
data-md-slider-tick="{{ $format($mark['fraction']) }}"
|
|
@if ($mark['hidden']) hidden @endif
|
|
@if ($mark['active']) data-md-active @endif
|
|
style="left: {{ $calc($mark['at']) }}"
|
|
></span>
|
|
@endforeach
|
|
|
|
@foreach (['start', 'end'] as $part)
|
|
<span
|
|
data-md-slider-stop="{{ $part }}"
|
|
@if (! isset($stops[$part])) hidden @endif
|
|
style="{{ isset($stops[$part]) ? 'left: '.$calc($stops[$part]) : '' }}"
|
|
></span>
|
|
@endforeach
|
|
|
|
@if ($icon !== null)
|
|
<span
|
|
data-md-slider-icon
|
|
@if (! $iconInActive && ! $iconInInactive) hidden @endif
|
|
@if ($iconInActive) data-md-active @endif
|
|
style="left: {{ $calc($iconAt) }}"
|
|
>
|
|
<x-livewire-material::icon :name="$icon" :size="$iconSize" />
|
|
</span>
|
|
@endif
|
|
</div>
|
|
|
|
@foreach ($thumbs as $index => $thumb)
|
|
<span data-md-slider-handle="{{ $thumb }}" style="left: {{ $calc($place($fractions[$index])) }}">
|
|
<span data-md-slider-thumb></span>
|
|
|
|
@if ($valueLabel !== 'never')
|
|
<span data-md-slider-value-anchor>
|
|
<span data-md-slider-value>{{ $format($values[$index]) }}</span>
|
|
</span>
|
|
@endif
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
|
|
@foreach ($thumbs as $index => $thumb)
|
|
<input
|
|
type="range"
|
|
id="{{ $index === 0 ? $id : $id.'-end' }}"
|
|
@if (filled($name)) name="{{ $range ? $name.'[]' : $name }}" @endif
|
|
min="{{ $format($minimum) }}"
|
|
max="{{ $format($maximum) }}"
|
|
step="{{ $continuous ? 'any' : $format($interval) }}"
|
|
value="{{ $format($values[$index]) }}"
|
|
data-md-slider-input="{{ $thumb }}"
|
|
@if ($vertical) aria-orientation="vertical" @endif
|
|
@if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif
|
|
@if ($described) aria-describedby="{{ $described }}" @endif
|
|
@if ($messages !== []) aria-invalid="true" @endif
|
|
@disabled($disabled)
|
|
{{ new \Illuminate\View\ComponentAttributeBag($bindingsFor($index)) }}
|
|
{{ $inputAttributes }}
|
|
/>
|
|
@endforeach
|
|
</div>
|
|
|
|
@if ($messages !== [])
|
|
<div id="{{ $id }}-hint" data-md-slider-errors>
|
|
@foreach ($messages as $message)
|
|
<p>{{ $message }}</p>
|
|
@endforeach
|
|
</div>
|
|
@elseif (filled($hint))
|
|
<p id="{{ $id }}-hint" data-md-slider-hint>{{ $hint }}</p>
|
|
@endif
|
|
</div>
|