Draw the slider without Tailwind
<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
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bb5adab0e
commit
281c21b0a4
@@ -30,8 +30,8 @@
|
||||
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 —
|
||||
`class="h-64"`, which the label and the hint share; without one it is 192px. M3 keeps range
|
||||
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`.
|
||||
|
||||
@@ -63,7 +63,11 @@
|
||||
|
||||
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. --}}
|
||||
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,
|
||||
@@ -142,7 +146,6 @@
|
||||
$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;
|
||||
|
||||
$sized = preg_match('/(^|\s)w-/', (string) $attributes->get('class')) === 1;
|
||||
$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
|
||||
@@ -234,182 +237,78 @@
|
||||
$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';
|
||||
|
||||
// Every colour class written out whole, so Tailwind compiles it.
|
||||
$activeInk = [
|
||||
'primary' => 'bg-primary', 'secondary' => 'bg-secondary', 'tertiary' => 'bg-tertiary', 'error' => 'bg-error',
|
||||
'success' => 'bg-success', 'warning' => 'bg-warning', 'info' => 'bg-info',
|
||||
];
|
||||
$inactiveInk = [
|
||||
'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, 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-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-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',
|
||||
'error' => 'noscript:accent-error', 'success' => 'noscript:accent-success', 'warning' => 'noscript:accent-warning', 'info' => 'noscript:accent-info',
|
||||
];
|
||||
|
||||
$trackHeight = ['xs' => 'h-4', 'sm' => 'h-6', 'md' => 'h-10', 'lg' => 'h-14', 'xl' => 'h-24'][$size];
|
||||
// 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-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: 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];
|
||||
// The measure across the slider — the handle's height, and so the slider's own. Standing up it
|
||||
// is the width instead, and the length comes from the wrapper the caller sized.
|
||||
$across = [
|
||||
'xs' => $vertical ? 'w-12' : 'h-12',
|
||||
'sm' => $vertical ? 'w-12' : 'h-12',
|
||||
'md' => $vertical ? 'w-13' : 'h-13',
|
||||
'lg' => $vertical ? 'w-17' : 'h-17',
|
||||
'xl' => $vertical ? 'w-27' : 'h-27',
|
||||
][$size];
|
||||
$thumbs = $range ? ['start', 'end'] : ['start'];
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized && ! $vertical, 'flex w-fit flex-col' => $vertical]) }}>
|
||||
<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" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</span>
|
||||
<span id="{{ $id }}-label" data-md-slider-label>{{ $label }}</span>
|
||||
@else
|
||||
<label for="{{ $id }}" id="{{ $id }}-label" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</label>
|
||||
<label for="{{ $id }}" id="{{ $id }}-label" data-md-slider-label>{{ $label }}</label>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div
|
||||
x-data="materialSlider"
|
||||
x-on:pointerdown="press($event)"
|
||||
data-slider
|
||||
data-size="{{ $size }}"
|
||||
@if ($vertical) data-orientation="vertical" @endif
|
||||
@if ($centered) data-centered @endif
|
||||
data-md-slider-control
|
||||
@if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif
|
||||
@class([
|
||||
'group/slider relative cursor-pointer select-none has-disabled:cursor-not-allowed',
|
||||
$across,
|
||||
'touch-pan-y' => ! $vertical,
|
||||
// Standing up: 192px long unless the wrapper says otherwise, and a size container,
|
||||
// which is what lets the drawing below be as long as this is tall.
|
||||
'touch-pan-x h-48 min-h-0 flex-auto [container-type:size]' => $vertical,
|
||||
// Room for a label that never hides: half the handle, 4px and the 44px pill — above the
|
||||
// track when the slider lies down, beside it when it stands up.
|
||||
'mt-12' => $valueLabel === 'always' && ! $vertical,
|
||||
'ms-12' => $valueLabel === 'always' && $vertical,
|
||||
'noscript:h-auto noscript:cursor-auto noscript:space-y-2',
|
||||
])
|
||||
>
|
||||
<div data-slider-drawing wire:ignore aria-hidden="true" @class([
|
||||
'pointer-events-none absolute noscript:hidden',
|
||||
'inset-y-0 inset-x-0.5 rtl:-scale-x-100' => ! $vertical,
|
||||
// The same drawing, laid out as long as the slider is tall and then turned a quarter
|
||||
// anticlockwise so the value grows upwards. Everything inside it — the segments, the
|
||||
// ticks, the handle and its label — is placed as if the slider were lying down.
|
||||
'top-1/2 left-1/2 h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90' => $vertical,
|
||||
])>
|
||||
<div @class(['absolute inset-x-0 top-1/2 -translate-y-1/2', $trackHeight])>
|
||||
@foreach (['start' => $inactiveInk[$color], 'active' => $activeInk[$color], 'end' => $inactiveInk[$color]] as $part => $ink)
|
||||
<div data-md-slider-drawing wire:ignore aria-hidden="true">
|
||||
<div data-md-slider-track>
|
||||
@foreach (['start', 'active', 'end'] as $part)
|
||||
<span
|
||||
data-segment="{{ $part }}"
|
||||
data-md-slider-segment="{{ $part }}"
|
||||
@if (! isset($segments[$part])) hidden @endif
|
||||
@class([
|
||||
'absolute inset-y-0',
|
||||
$ink,
|
||||
'group-has-disabled/slider:bg-on-surface/38' => $part === 'active',
|
||||
'group-has-disabled/slider:bg-on-surface/12' => $part !== 'active',
|
||||
])
|
||||
style="{{ $segmentStyle($segments[$part] ?? null) }}"
|
||||
></span>
|
||||
@endforeach
|
||||
|
||||
@foreach ($tickMarks as $mark)
|
||||
<span
|
||||
data-tick="{{ $format($mark['fraction']) }}"
|
||||
data-md-slider-tick="{{ $format($mark['fraction']) }}"
|
||||
@if ($mark['hidden']) hidden @endif
|
||||
@if ($mark['active']) data-active @endif
|
||||
@class(['absolute top-1/2 size-1 -translate-1/2 rounded-corner-full', $tickInk[$color], 'group-has-disabled/slider:bg-on-surface/38 group-has-disabled/slider:data-active:bg-on-surface/12'])
|
||||
@if ($mark['active']) data-md-active @endif
|
||||
style="left: {{ $calc($mark['at']) }}"
|
||||
></span>
|
||||
@endforeach
|
||||
|
||||
@foreach (['start', 'end'] as $part)
|
||||
<span
|
||||
data-stop="{{ $part }}"
|
||||
data-md-slider-stop="{{ $part }}"
|
||||
@if (! isset($stops[$part])) hidden @endif
|
||||
@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
|
||||
|
||||
@if ($icon !== null)
|
||||
<span
|
||||
data-track-icon
|
||||
data-md-slider-icon
|
||||
@if (! $iconInActive && ! $iconInInactive) hidden @endif
|
||||
@if ($iconInActive) data-active @endif
|
||||
@class([
|
||||
'absolute top-1/2 flex -translate-y-1/2',
|
||||
'rtl:-scale-x-100' => ! $vertical,
|
||||
// Turned back the quarter the drawing turns, so the glyph stands up.
|
||||
'rotate-90' => $vertical,
|
||||
$iconInk[$color],
|
||||
'group-has-disabled/slider:text-on-surface/38',
|
||||
])
|
||||
@if ($iconInActive) data-md-active @endif
|
||||
style="left: {{ $calc($iconAt) }}"
|
||||
>
|
||||
<x-livewire-material::icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
|
||||
<x-livewire-material::icon :name="$icon" :size="$iconSize" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@foreach ($thumbs as $index => $thumb)
|
||||
<span data-handle="{{ $thumb }}" class="group/thumb absolute inset-y-0 w-0" style="left: {{ $calc($place($fractions[$index])) }}">
|
||||
<span @class([
|
||||
'absolute top-1/2 left-0 w-1 -translate-1/2 rounded-corner-full',
|
||||
'transition-[width,height] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'group-data-pressed/thumb:w-0.5 group-data-focused/thumb:w-0.5',
|
||||
'outline-offset-2 outline-secondary group-data-focused/thumb:outline-3',
|
||||
$handleHeight,
|
||||
$activeInk[$color],
|
||||
'group-has-disabled/slider:bg-on-surface/38',
|
||||
])></span>
|
||||
<span data-md-slider-handle="{{ $thumb }}" style="left: {{ $calc($place($fractions[$index])) }}">
|
||||
<span data-md-slider-thumb></span>
|
||||
|
||||
@if ($valueLabel !== 'never')
|
||||
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center', 'rtl:-scale-x-100' => ! $vertical, $labelBottom])>
|
||||
<span
|
||||
data-value-label
|
||||
@class([
|
||||
'flex h-11 min-w-12 items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
|
||||
'origin-bottom' => ! $vertical,
|
||||
// Turned back the quarter the drawing turns: the pill lands
|
||||
// beside the handle rather than above it, and reads across.
|
||||
'rotate-90' => $vertical,
|
||||
'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',
|
||||
])
|
||||
>{{ $format($values[$index]) }}</span>
|
||||
<span data-md-slider-value-anchor>
|
||||
<span data-md-slider-value>{{ $format($values[$index]) }}</span>
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
@@ -425,7 +324,7 @@
|
||||
max="{{ $format($maximum) }}"
|
||||
step="{{ $continuous ? 'any' : $format($interval) }}"
|
||||
value="{{ $format($values[$index]) }}"
|
||||
data-thumb="{{ $thumb }}"
|
||||
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
|
||||
@@ -433,22 +332,17 @@
|
||||
@disabled($disabled)
|
||||
{{ new \Illuminate\View\ComponentAttributeBag($bindingsFor($index)) }}
|
||||
{{ $inputAttributes }}
|
||||
@class([
|
||||
'pointer-events-none absolute inset-0 m-0 size-full appearance-none bg-transparent opacity-0',
|
||||
'noscript:pointer-events-auto noscript:static noscript:block noscript:h-6 noscript:w-full noscript:appearance-auto noscript:opacity-100',
|
||||
$accent[$color],
|
||||
])
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($messages !== [])
|
||||
<div id="{{ $id }}-hint">
|
||||
<div id="{{ $id }}-hint" data-md-slider-errors>
|
||||
@foreach ($messages as $message)
|
||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||
<p>{{ $message }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif (filled($hint))
|
||||
<p id="{{ $id }}-hint" class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
||||
<p id="{{ $id }}-hint" data-md-slider-hint>{{ $hint }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user