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:
Andreas Reinhold / reini
2026-09-14 15:01:19 +02:00
co-authored by Claude Opus 5
parent 1bb5adab0e
commit 281c21b0a4
6 changed files with 660 additions and 247 deletions
+1
View File
@@ -27,6 +27,7 @@
@import './components/chip.css'; @import './components/chip.css';
@import './components/chip-set.css'; @import './components/chip-set.css';
@import './components/choices.css'; @import './components/choices.css';
@import './components/slider.css';
/* Containment */ /* Containment */
+403
View File
@@ -0,0 +1,403 @@
/*
* <x-slider>: M3 Expressive's slider (resources/views/components/slider.blade.php), drawn over native
* range inputs by resources/js/slider.js, which places every part with inline `left`, `width` and
* `border-radius`; this file gives the parts their size, colour and states.
*
* From androidx Compose Material 3 at commit 27cf9a7 (Slider.kt, SliderTokens.kt, Apache-2.0) and
* M3's sliders/specs table: a 4px handle, 2px while pressed or focused and 6px shorter while its
* focus ring shows (Material Components' m3_slider_focus_ring_thumb_height_decrease); 4px stop
* indicators and ticks; the active track and the handle in the colour, the inactive track in its
* container, and a stop, a tick or the inset icon in the "on" colour of the part of the track it
* sits on. Disabled, the active parts are on-surface at 38% and the inactive track, and the ticks
* over the active track, at 12% (tokens/state.css). The value label is M3's label container: 44px
* tall and at least 48px wide in inverse-surface with label-large, 4px above the handle, growing in
* on the fast effects spring while the handle is pressed or focused (`data-md-value-label="drag"`).
*
* Expressive's sizes, by `data-md-size`, as custom properties on the root:
*
* size track handle (and the slider across) inset icon
* xs 16px 44px (the slider 48px) —
* sm 24px 44px (48px) —
* md 40px 52px 24px
* lg 56px 68px 24px
* xl 96px 108px 32px
*
* `data-md-orientation="vertical"` turns the same drawing a quarter inside a size container: the
* slider is as wide as a horizontal one is tall and 192px long unless the root's height says
* otherwise; the value label turns back so it reads across, beside the handle. A right-to-left page
* mirrors a horizontal drawing and turns its label and icon back. Without script the inputs show as
* native ranges in the colour.
*
* [data-md-slider] the root; data-md-size, data-md-color, data-md-value-label,
* data-md-orientation, data-md-centered
* [data-md-slider-label]
* [data-md-slider-control] the element the pointer presses; holds the inputs
* [data-md-slider-drawing]
* [data-md-slider-track]
* [data-md-slider-segment="start|active|end"], [data-md-slider-tick] (data-md-active),
* [data-md-slider-stop="start|end"], [data-md-slider-icon] (data-md-active)
* [data-md-slider-handle="start|end"] (data-md-pressed, data-md-focused)
* [data-md-slider-thumb], [data-md-slider-value-anchor] > [data-md-slider-value]
* [data-md-slider-input="start|end"]
* [data-md-slider-hint] | [data-md-slider-errors]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-slider] {
--slider-active: var(--md-sys-color-primary);
--slider-inactive: var(--md-sys-color-secondary-container);
--slider-on-active: var(--md-sys-color-on-primary);
--slider-on-inactive: var(--md-sys-color-on-secondary-container);
--slider-disabled-active: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
--slider-disabled-inactive: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
--slider-across: 48px;
--slider-track: 16px;
--slider-handle: 44px;
width: 100%;
min-width: 0;
}
[data-md-slider][data-md-size='sm'] {
--slider-track: 24px;
}
[data-md-slider][data-md-size='md'] {
--slider-across: 52px;
--slider-track: 40px;
--slider-handle: 52px;
}
[data-md-slider][data-md-size='lg'] {
--slider-across: 68px;
--slider-track: 56px;
--slider-handle: 68px;
}
[data-md-slider][data-md-size='xl'] {
--slider-across: 108px;
--slider-track: 96px;
--slider-handle: 108px;
}
[data-md-slider][data-md-color='secondary'] {
--slider-active: var(--md-sys-color-secondary);
--slider-on-active: var(--md-sys-color-on-secondary);
}
[data-md-slider][data-md-color='tertiary'] {
--slider-active: var(--md-sys-color-tertiary);
--slider-inactive: var(--md-sys-color-tertiary-container);
--slider-on-active: var(--md-sys-color-on-tertiary);
--slider-on-inactive: var(--md-sys-color-on-tertiary-container);
}
[data-md-slider][data-md-color='error'] {
--slider-active: var(--md-sys-color-error);
--slider-inactive: var(--md-sys-color-error-container);
--slider-on-active: var(--md-sys-color-on-error);
--slider-on-inactive: var(--md-sys-color-on-error-container);
}
[data-md-slider][data-md-color='success'] {
--slider-active: var(--md-sys-color-success);
--slider-inactive: var(--md-sys-color-success-container);
--slider-on-active: var(--md-sys-color-on-success);
--slider-on-inactive: var(--md-sys-color-on-success-container);
}
[data-md-slider][data-md-color='warning'] {
--slider-active: var(--md-sys-color-warning);
--slider-inactive: var(--md-sys-color-warning-container);
--slider-on-active: var(--md-sys-color-on-warning);
--slider-on-inactive: var(--md-sys-color-on-warning-container);
}
[data-md-slider][data-md-color='info'] {
--slider-active: var(--md-sys-color-info);
--slider-inactive: var(--md-sys-color-info-container);
--slider-on-active: var(--md-sys-color-on-info);
--slider-on-inactive: var(--md-sys-color-on-info-container);
}
/* Disabled: the colours give way to on-surface at M3's disabled opacities. */
[data-md-slider]:has([data-md-slider-input]:disabled) {
--slider-active: var(--slider-disabled-active);
--slider-inactive: var(--slider-disabled-inactive);
--slider-on-active: var(--slider-disabled-inactive);
--slider-on-inactive: var(--slider-disabled-active);
}
[data-md-slider][data-md-orientation='vertical'] {
display: flex;
flex-direction: column;
width: fit-content;
}
[data-md-slider-label] {
display: block;
margin-bottom: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-slider-control] {
position: relative;
height: var(--slider-across);
cursor: pointer;
touch-action: pan-y;
-webkit-user-select: none;
user-select: none;
}
[data-md-slider-control]:has(:disabled) {
cursor: not-allowed;
}
/* Standing up: as wide as a lying slider is tall, 192px long unless the root says otherwise, and
a size container, which lets the drawing be as long as this is tall. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-control] {
flex: auto;
width: var(--slider-across);
height: 192px;
min-height: 0;
container-type: size;
touch-action: pan-x;
}
/* Room for a label that never hides: half the handle, 4px and the 44px pill — above the track
lying down, beside it standing up. */
[data-md-slider][data-md-value-label='always'] [data-md-slider-control] {
margin-top: var(--md-sys-measurement-space600);
}
[data-md-slider][data-md-value-label='always'][data-md-orientation='vertical'] [data-md-slider-control] {
margin-top: 0;
margin-inline-start: var(--md-sys-measurement-space600);
}
[data-md-slider-drawing] {
position: absolute;
inset-block: 0;
inset-inline: var(--md-sys-measurement-space25);
pointer-events: none;
&:dir(rtl) {
scale: -1 1;
}
}
/* The same drawing, laid out as long as the slider is tall and turned a quarter anticlockwise so
the value grows upwards. Everything inside it is placed as if the slider were lying down. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-drawing] {
inset: auto;
top: 50%;
left: 50%;
width: calc(100cqh - var(--md-sys-measurement-space50));
height: 100cqw;
translate: -50% -50%;
rotate: -90deg;
scale: none;
}
[data-md-slider-track] {
position: absolute;
inset-inline: 0;
top: 50%;
height: var(--slider-track);
translate: 0 -50%;
}
[data-md-slider-segment] {
position: absolute;
inset-block: 0;
background-color: var(--slider-inactive);
}
[data-md-slider-segment='active'] {
background-color: var(--slider-active);
}
:is([data-md-slider-tick], [data-md-slider-stop]) {
position: absolute;
top: 50%;
width: 4px;
height: 4px;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--slider-on-inactive);
}
[data-md-slider-tick][data-md-active] {
background-color: var(--slider-on-active);
}
/* A disabled icon is on-surface at 38% wherever it sits. */
[data-md-slider]:has([data-md-slider-input]:disabled) [data-md-slider-icon] {
color: var(--slider-disabled-active);
}
[data-md-slider-icon] {
position: absolute;
top: 50%;
display: flex;
translate: 0 -50%;
color: var(--slider-on-inactive);
&[data-md-active] {
color: var(--slider-on-active);
}
&:dir(rtl) {
scale: -1 1;
}
}
/* Turned back the quarter the drawing turns, so the glyph stands up. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-icon] {
rotate: 90deg;
scale: none;
}
[data-md-slider-handle] {
position: absolute;
inset-block: 0;
width: 0;
}
[data-md-slider-thumb] {
position: absolute;
top: 50%;
left: 0;
width: 4px;
height: var(--slider-handle);
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--slider-active);
outline-offset: 2px;
transition-property: width, height;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
:is([data-md-pressed], [data-md-focused]) > [data-md-slider-thumb] {
width: 2px;
}
[data-md-focused] > [data-md-slider-thumb] {
height: calc(var(--slider-handle) - 6px);
outline: 3px solid var(--md-sys-color-secondary);
}
[data-md-slider-value-anchor] {
position: absolute;
bottom: calc(50% + var(--slider-handle) / 2 + var(--md-sys-measurement-space50));
left: 0;
z-index: 10;
display: flex;
justify-content: center;
translate: -50% 0;
&:dir(rtl) {
scale: -1 1;
}
}
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-value-anchor] {
scale: none;
}
[data-md-slider-value] {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--md-sys-measurement-space600);
height: 44px;
padding-inline: var(--md-sys-measurement-space125);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
white-space: nowrap;
transform-origin: bottom;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
/* Turned back the quarter the drawing turns: the pill lands beside the handle and reads across. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-value] {
transform-origin: center;
rotate: 90deg;
}
[data-md-slider][data-md-value-label='drag'] [data-md-slider-value] {
opacity: 0;
scale: 0.75;
}
[data-md-slider][data-md-value-label='drag'] :is([data-md-pressed], [data-md-focused]) [data-md-slider-value] {
opacity: 1;
scale: 1;
}
[data-md-slider-input] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
appearance: none;
background: transparent;
opacity: 0;
pointer-events: none;
accent-color: var(--slider-active);
}
[data-md-slider-hint],
[data-md-slider-errors] > p {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-slider-errors] > p {
color: var(--md-sys-color-error);
}
/* Without script there is no drawing: the inputs show as native ranges, one under the other. */
@media (scripting: none) {
:is([data-md-slider], [data-md-slider][data-md-orientation]) [data-md-slider-control] {
height: auto;
cursor: auto;
}
[data-md-slider-control] > :not(:last-child) {
margin-block-end: var(--md-sys-measurement-space100);
}
[data-md-slider-drawing] {
display: none;
}
[data-md-slider-input] {
position: static;
display: block;
width: 100%;
height: 24px;
appearance: auto;
opacity: 1;
pointer-events: auto;
}
}
}
+20 -18
View File
@@ -200,8 +200,10 @@ function sliderGeometry({ width, size, fractions, centered = false, range = fals
* effect, and anything reactive read or written on the way to the drawing would re-run it. * effect, and anything reactive read or written on the way to the drawing would re-run it.
*/ */
function slider(root) { function slider(root) {
// The component's root carries the props; `root` is the control inside it the pointer presses.
const host = root.closest('[data-md-slider]') ?? root
const inputs = [...root.querySelectorAll(':scope > input[type="range"]')] const inputs = [...root.querySelectorAll(':scope > input[type="range"]')]
const drawing = root.querySelector('[data-slider-drawing]') const drawing = root.querySelector('[data-md-slider-drawing]')
const range = inputs.length > 1 const range = inputs.length > 1
const cleanups = [] const cleanups = []
let queued = false let queued = false
@@ -228,24 +230,24 @@ function slider(root) {
} }
/** M3 Expressive's second orientation: the drawing is turned a quarter, the geometry is not. */ /** M3 Expressive's second orientation: the drawing is turned a quarter, the geometry is not. */
const vertical = () => root.dataset.orientation === 'vertical' const vertical = () => host.dataset.mdOrientation === 'vertical'
/** The track's length: the slider's height while it stands up, its width while it lies down. */ /** The track's length: the slider's height while it stands up, its width while it lies down. */
const trackLength = () => Math.max((vertical() ? root.clientHeight : root.clientWidth) - HANDLE_WIDTH, 0) const trackLength = () => Math.max((vertical() ? root.clientHeight : root.clientWidth) - HANDLE_WIDTH, 0)
const values = () => inputs.map((input) => Number.parseFloat(input.value)) const values = () => inputs.map((input) => Number.parseFloat(input.value))
const thumbOf = (input) => drawing?.querySelector(`[data-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`) const thumbOf = (input) => drawing?.querySelector(`[data-md-slider-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`)
const tickFractions = () => [...(drawing?.querySelectorAll('[data-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.tick)) const tickFractions = () => [...(drawing?.querySelectorAll('[data-md-slider-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.mdSliderTick))
const geometry = (width) => { const geometry = (width) => {
const { min, max } = bounds() const { min, max } = bounds()
return sliderGeometry({ return sliderGeometry({
width, width,
size: root.dataset.size, size: host.dataset.mdSize,
fractions: values().map((value) => (Number.isFinite(value) ? clamp((value - min) / (max - min), 0, 1) : 0)), fractions: values().map((value) => (Number.isFinite(value) ? clamp((value - min) / (max - min), 0, 1) : 0)),
centered: root.hasAttribute('data-centered'), centered: host.hasAttribute('data-md-centered'),
range, range,
tickFractions: tickFractions(), tickFractions: tickFractions(),
}) })
@@ -257,13 +259,13 @@ function slider(root) {
} }
const width = trackLength() const width = trackLength()
const size = SIZES[root.dataset.size] ?? SIZES.xs const size = SIZES[host.dataset.mdSize] ?? SIZES.xs
const { segments, stops, ticks, handles, activeTrack, endTrackStart } = geometry(width) const { segments, stops, ticks, handles, activeTrack, endTrackStart } = geometry(width)
const part = (selector) => drawing.querySelector(selector) const part = (selector) => drawing.querySelector(selector)
const px = (number) => `${Math.round(number * 100) / 100}px` const px = (number) => `${Math.round(number * 100) / 100}px`
for (const name of ['start', 'active', 'end']) { for (const name of ['start', 'active', 'end']) {
const element = part(`[data-segment="${name}"]`) const element = part(`[data-md-slider-segment="${name}"]`)
const segment = segments[name] const segment = segments[name]
if (element) { if (element) {
@@ -277,7 +279,7 @@ function slider(root) {
} }
for (const name of ['start', 'end']) { for (const name of ['start', 'end']) {
const element = part(`[data-stop="${name}"]`) const element = part(`[data-md-slider-stop="${name}"]`)
if (element) { if (element) {
element.hidden = stops[name] === undefined element.hidden = stops[name] === undefined
@@ -288,20 +290,20 @@ function slider(root) {
const fractions = tickFractions() const fractions = tickFractions()
const spacing = fractions.length > 1 ? (width - size.corner * 2) * Math.abs(fractions[1] - fractions[0]) : Infinity const spacing = fractions.length > 1 ? (width - size.corner * 2) * Math.abs(fractions[1] - fractions[0]) : Infinity
drawing.querySelectorAll('[data-tick]').forEach((element, i) => { drawing.querySelectorAll('[data-md-slider-tick]').forEach((element, i) => {
const tick = ticks[i] const tick = ticks[i]
element.hidden = !tick || spacing < TICK_MIN_SPACING element.hidden = !tick || spacing < TICK_MIN_SPACING
if (tick) { if (tick) {
element.style.left = px(tick.x) element.style.left = px(tick.x)
element.toggleAttribute('data-active', tick.active) element.toggleAttribute('data-md-active', tick.active)
} }
}) })
handles.forEach((x, i) => { handles.forEach((x, i) => {
const thumb = part(`[data-handle="${i === 1 ? 'end' : 'start'}"]`) const thumb = part(`[data-md-slider-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-value-label]') const label = thumb?.querySelector('[data-md-slider-value]')
if (thumb) { if (thumb) {
thumb.style.left = px(x) thumb.style.left = px(x)
@@ -313,7 +315,7 @@ function slider(root) {
}) })
// The inset icon: at the start of the active track while it fits, else of the inactive one. // The inset icon: at the start of the active track while it fits, else of the inactive one.
const icon = part('[data-track-icon]') const icon = part('[data-md-slider-icon]')
if (icon) { if (icon) {
const room = size.icon + ICON_PADDING * 2 const room = size.icon + ICON_PADDING * 2
@@ -321,7 +323,7 @@ function slider(root) {
const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room
icon.hidden = !inActive && !inInactive icon.hidden = !inActive && !inInactive
icon.toggleAttribute('data-active', inActive) icon.toggleAttribute('data-md-active', inActive)
icon.style.left = px((inActive ? activeTrack[0] : (endTrackStart ?? 0)) + ICON_PADDING) icon.style.left = px((inActive ? activeTrack[0] : (endTrackStart ?? 0)) + ICON_PADDING)
} }
} }
@@ -371,10 +373,10 @@ function slider(root) {
} }
/** Whether a handle shows keyboard focus: narrowed, ringed and labelled. */ /** Whether a handle shows keyboard focus: narrowed, ringed and labelled. */
const showFocus = (input, shown) => thumbOf(input)?.toggleAttribute('data-focused', shown) const showFocus = (input, shown) => thumbOf(input)?.toggleAttribute('data-md-focused', shown)
/** Compose narrows a handle and shows its label while it is pressed or dragged. */ /** Compose narrows a handle and shows its label while it is pressed or dragged. */
const pressed = (index) => drawing?.querySelectorAll('[data-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-pressed', i === index)) const pressed = (index) => drawing?.querySelectorAll('[data-md-slider-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-md-pressed', i === index))
inputs.forEach((input) => { inputs.forEach((input) => {
// A value written by script fires no event: catch it on the input's own setters. // A value written by script fires no event: catch it on the input's own setters.
@@ -493,7 +495,7 @@ function slider(root) {
const mutations = new MutationObserver(schedule) const mutations = new MutationObserver(schedule)
inputs.forEach((input) => mutations.observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step', 'value', 'disabled'] })) inputs.forEach((input) => mutations.observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step', 'value', 'disabled'] }))
mutations.observe(root, { attributes: true, attributeFilter: ['data-size', 'data-centered', 'data-orientation', 'dir'] }) mutations.observe(host, { attributes: true, attributeFilter: ['data-md-size', 'data-md-centered', 'data-md-orientation', 'dir'] })
const resizes = new ResizeObserver(schedule) const resizes = new ResizeObserver(schedule)
resizes.observe(root) resizes.observe(root)
+37 -143
View File
@@ -30,8 +30,8 @@
drawing, turned a quarter so the value grows upwards, with the value label beside the handle 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 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 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 — one is tall and takes its length from the wrapper, so give it an explicit height — a height
`class="h-64"`, which the label and the hint share; without one it is 192px. M3 keeps range 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 sliders horizontal ("never use range sliders vertically — too much cognitive load"), so
`range` wins over `orientation`. `range` wins over `orientation`.
@@ -63,7 +63,11 @@
The server draws the first frame; the drawing is `wire:ignore`, so a Livewire render never 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 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([ @props([
'label' => null, '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); $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; $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']); $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 // 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]]) $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'; .'; 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']; $thumbs = $range ? ['start', 'end'] : ['start'];
@endphp @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 (filled($label))
@if ($range) @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 @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
@endif @endif
<div <div
x-data="materialSlider" x-data="materialSlider"
x-on:pointerdown="press($event)" x-on:pointerdown="press($event)"
data-slider data-md-slider-control
data-size="{{ $size }}"
@if ($vertical) data-orientation="vertical" @endif
@if ($centered) data-centered @endif
@if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif @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([ <div data-md-slider-drawing wire:ignore aria-hidden="true">
'pointer-events-none absolute noscript:hidden', <div data-md-slider-track>
'inset-y-0 inset-x-0.5 rtl:-scale-x-100' => ! $vertical, @foreach (['start', 'active', 'end'] as $part)
// 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)
<span <span
data-segment="{{ $part }}" data-md-slider-segment="{{ $part }}"
@if (! isset($segments[$part])) hidden @endif @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) }}" style="{{ $segmentStyle($segments[$part] ?? null) }}"
></span> ></span>
@endforeach @endforeach
@foreach ($tickMarks as $mark) @foreach ($tickMarks as $mark)
<span <span
data-tick="{{ $format($mark['fraction']) }}" data-md-slider-tick="{{ $format($mark['fraction']) }}"
@if ($mark['hidden']) hidden @endif @if ($mark['hidden']) hidden @endif
@if ($mark['active']) data-active @endif @if ($mark['active']) data-md-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'])
style="left: {{ $calc($mark['at']) }}" style="left: {{ $calc($mark['at']) }}"
></span> ></span>
@endforeach @endforeach
@foreach (['start', 'end'] as $part) @foreach (['start', 'end'] as $part)
<span <span
data-stop="{{ $part }}" data-md-slider-stop="{{ $part }}"
@if (! isset($stops[$part])) hidden @endif @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]) : '' }}" style="{{ isset($stops[$part]) ? 'left: '.$calc($stops[$part]) : '' }}"
></span> ></span>
@endforeach @endforeach
@if ($icon !== null) @if ($icon !== null)
<span <span
data-track-icon data-md-slider-icon
@if (! $iconInActive && ! $iconInInactive) hidden @endif @if (! $iconInActive && ! $iconInInactive) hidden @endif
@if ($iconInActive) data-active @endif @if ($iconInActive) data-md-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',
])
style="left: {{ $calc($iconAt) }}" 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> </span>
@endif @endif
</div> </div>
@foreach ($thumbs as $index => $thumb) @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 data-md-slider-handle="{{ $thumb }}" style="left: {{ $calc($place($fractions[$index])) }}">
<span @class([ <span data-md-slider-thumb></span>
'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>
@if ($valueLabel !== 'never') @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-md-slider-value-anchor>
<span <span data-md-slider-value>{{ $format($values[$index]) }}</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> </span>
@endif @endif
</span> </span>
@@ -425,7 +324,7 @@
max="{{ $format($maximum) }}" max="{{ $format($maximum) }}"
step="{{ $continuous ? 'any' : $format($interval) }}" step="{{ $continuous ? 'any' : $format($interval) }}"
value="{{ $format($values[$index]) }}" value="{{ $format($values[$index]) }}"
data-thumb="{{ $thumb }}" data-md-slider-input="{{ $thumb }}"
@if ($vertical) aria-orientation="vertical" @endif @if ($vertical) aria-orientation="vertical" @endif
@if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif @if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif
@if ($described) aria-describedby="{{ $described }}" @endif @if ($described) aria-describedby="{{ $described }}" @endif
@@ -433,22 +332,17 @@
@disabled($disabled) @disabled($disabled)
{{ new \Illuminate\View\ComponentAttributeBag($bindingsFor($index)) }} {{ new \Illuminate\View\ComponentAttributeBag($bindingsFor($index)) }}
{{ $inputAttributes }} {{ $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 @endforeach
</div> </div>
@if ($messages !== []) @if ($messages !== [])
<div id="{{ $id }}-hint"> <div id="{{ $id }}-hint" data-md-slider-errors>
@foreach ($messages as $message) @foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p> <p>{{ $message }}</p>
@endforeach @endforeach
</div> </div>
@elseif (filled($hint)) @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 @endif
</div> </div>
+99 -20
View File
@@ -75,7 +75,7 @@ function onSlider(string $label, string $body): string
{ {
return <<<JS return <<<JS
(async () => { (async () => {
const root = [...document.querySelectorAll('[data-slider]')] const root = [...document.querySelectorAll('[data-md-slider-control]')]
.find((slider) => slider.parentElement.querySelector(':scope > label, :scope > span')?.textContent.trim() === '{$label}') .find((slider) => slider.parentElement.querySelector(':scope > label, :scope > span')?.textContent.trim() === '{$label}')
root.scrollIntoView({ block: 'center' }) root.scrollIntoView({ block: 'center' })
const inputs = [...root.querySelectorAll('input[type="range"]')] const inputs = [...root.querySelectorAll('input[type="range"]')]
@@ -84,7 +84,7 @@ function onSlider(string $label, string $body): string
const near = (a, b, tolerance = 1) => Math.abs(a - b) <= tolerance const near = (a, b, tolerance = 1) => Math.abs(a - b) <= tolerance
const left = (selector) => Number.parseFloat(root.querySelector(selector).style.left) const left = (selector) => Number.parseFloat(root.querySelector(selector).style.left)
const segment = (name) => { const segment = (name) => {
const element = root.querySelector(`[data-segment="\${name}"]`) const element = root.querySelector(`[data-md-slider-segment="\${name}"]`)
const from = Number.parseFloat(element.style.left) const from = Number.parseFloat(element.style.left)
return element.hidden ? null : { from, to: from + Number.parseFloat(element.style.width) } return element.hidden ? null : { from, to: from + Number.parseFloat(element.style.width) }
} }
@@ -106,18 +106,18 @@ it('moves with the keyboard, and the drawing follows', function () {
$page->keys(':focus', ['ArrowRight', 'ArrowRight']) $page->keys(':focus', ['ArrowRight', 'ArrowRight'])
->assertScript(onSlider('Volume', <<<'JS' ->assertScript(onSlider('Volume', <<<'JS'
return inputs[0].value === '42' return inputs[0].value === '42'
&& near(left('[data-handle="start"]'), width * 0.42) && near(left('[data-md-slider-handle="start"]'), width * 0.42)
&& near(segment('active').to, width * 0.42 - 8) && near(segment('active').to, width * 0.42 - 8)
&& near(segment('end').from, width * 0.42 + 8) && near(segment('end').from, width * 0.42 + 8)
&& root.querySelector('[data-handle="start"]').hasAttribute('data-focused') && root.querySelector('[data-md-slider-handle="start"]').hasAttribute('data-md-focused')
&& root.querySelector('[data-value-label]').textContent === '42' && root.querySelector('[data-md-slider-value]').textContent === '42'
JS)); JS));
$page->keys(':focus', 'PageUp') $page->keys(':focus', 'PageUp')
->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-handle=\"start\"]'), width * 0.52)")); ->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.52)"));
$page->keys(':focus', 'End') $page->keys(':focus', 'End')
->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-stop=\"end\"]').checkVisibility()")); ->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-md-slider-stop=\"end\"]').checkVisibility()"));
$page->keys(':focus', 'Home') $page->keys(':focus', 'Home')
->assertScript(onSlider('Volume', "return inputs[0].value === '0' && segment('active') === null && near(segment('end').from, 8)")); ->assertScript(onSlider('Volume', "return inputs[0].value === '0' && segment('active') === null && near(segment('end').from, 8)"));
@@ -130,14 +130,14 @@ it('follows a pointer drag with a narrowed handle and its value label', function
$page->assertScript(onSlider('Volume', <<<'JS' $page->assertScript(onSlider('Volume', <<<'JS'
await pause(450) await pause(450)
const thumb = root.querySelector('[data-handle="start"]') const thumb = root.querySelector('[data-md-slider-handle="start"]')
const label = thumb.querySelector('[data-value-label]') const label = thumb.querySelector('[data-md-slider-value]')
return inputs[0].value === '75' return inputs[0].value === '75'
&& label.textContent === '75' && label.textContent === '75'
&& getComputedStyle(label).opacity === '1' && getComputedStyle(label).opacity === '1'
&& thumb.hasAttribute('data-pressed') && thumb.hasAttribute('data-md-pressed')
&& thumb.firstElementChild.offsetWidth === 2 && thumb.firstElementChild.offsetWidth === 2
&& near(left('[data-handle="start"]'), width * 0.75) && near(left('[data-md-slider-handle="start"]'), width * 0.75)
&& document.activeElement === inputs[0] && document.activeElement === inputs[0]
JS)); JS));
@@ -145,13 +145,13 @@ it('follows a pointer drag with a narrowed handle and its value label', function
$page->assertScript(onSlider('Volume', <<<'JS' $page->assertScript(onSlider('Volume', <<<'JS'
await pause(450) await pause(450)
const thumb = root.querySelector('[data-handle="start"]') const thumb = root.querySelector('[data-md-slider-handle="start"]')
return inputs[0].value === '75' return inputs[0].value === '75'
&& window.__changed === 1 && window.__changed === 1
&& ! thumb.hasAttribute('data-pressed') && ! thumb.hasAttribute('data-md-pressed')
&& ! thumb.hasAttribute('data-focused') && ! thumb.hasAttribute('data-md-focused')
&& thumb.firstElementChild.offsetWidth === 4 && thumb.firstElementChild.offsetWidth === 4
&& getComputedStyle(thumb.querySelector('[data-value-label]')).opacity === '0' && getComputedStyle(thumb.querySelector('[data-md-slider-value]')).opacity === '0'
JS)); JS));
}); });
@@ -163,7 +163,7 @@ it('keeps a range\'s handles from crossing, by pointer and by keyboard', functio
$page->assertScript(onSlider('Price', <<<'JS' $page->assertScript(onSlider('Price', <<<'JS'
return inputs[0].value === '60' && inputs[1].value === '60' return inputs[0].value === '60' && inputs[1].value === '60'
&& near(left('[data-handle="start"]'), left('[data-handle="end"]')) && near(left('[data-md-slider-handle="start"]'), left('[data-md-slider-handle="end"]'))
&& segment('active') === null && segment('active') === null
JS)); JS));
@@ -183,7 +183,7 @@ it('fills a centred slider from the middle', function () {
$page = sliderShowcase(); $page = sliderShowcase();
$page->assertScript(onSlider('Balance', <<<'JS' $page->assertScript(onSlider('Balance', <<<'JS'
const handle = left('[data-handle="start"]') const handle = left('[data-md-slider-handle="start"]')
return inputs[0].value === '15' return inputs[0].value === '15'
&& near(handle, width * 0.65) && near(handle, width * 0.65)
&& near(segment('start').to, width / 2 - 6) && near(segment('start').to, width / 2 - 6)
@@ -209,7 +209,7 @@ it('follows a value x-model sets from outside', function () {
$page->script("[...document.querySelectorAll('#sliders button')].find((button) => button.textContent.trim() === 'Set 80').click()"); $page->script("[...document.querySelectorAll('#sliders button')].find((button) => button.textContent.trim() === 'Set 80').click()");
$page->assertScript(onSlider('Bound volume', "return inputs[0].value === '80' && near(left('[data-handle=\"start\"]'), width * 0.8)")); $page->assertScript(onSlider('Bound volume', "return inputs[0].value === '80' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.8)"));
}); });
it('sends live values to Livewire, is not moved back by a render mid-drag, and moves when the server sets a value', function () { it('sends live values to Livewire, is not moved back by a render mid-drag, and moves when the server sets a value', function () {
@@ -237,7 +237,7 @@ it('sends live values to Livewire, is not moved back by a render mid-drag, and m
// Both round trips back: the handle is where it was dragged, and so is the property. // Both round trips back: the handle is where it was dragged, and so is the property.
$page->assertScript(onSlider('Volume', <<<'JS' $page->assertScript(onSlider('Volume', <<<'JS'
return window.__renders >= 2 && inputs[0].value === '70' && near(left('[data-handle="start"]'), width * 0.7) return window.__renders >= 2 && inputs[0].value === '70' && near(left('[data-md-slider-handle="start"]'), width * 0.7)
JS)) JS))
->assertSeeIn('#volume', '70'); ->assertSeeIn('#volume', '70');
@@ -245,10 +245,89 @@ it('sends live values to Livewire, is not moved back by a render mid-drag, and m
$page->click('button:has-text("Maximise")') $page->click('button:has-text("Maximise")')
->assertSeeIn('#volume', '90') ->assertSeeIn('#volume', '90')
->assertScript(onSlider('Volume', "return inputs[0].value === '90' && near(left('[data-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-value-label]').textContent === '90'")); ->assertScript(onSlider('Volume', "return inputs[0].value === '90' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-md-slider-value]').textContent === '90'"));
$page->script(onSlider('Price', 'inputs[1].focus()')); $page->script(onSlider('Price', 'inputs[1].focus()'));
$page->keys(':focus', 'ArrowLeft') $page->keys(':focus', 'ArrowLeft')
->assertSeeIn('#price', '20,79'); ->assertSeeIn('#price', '20,79');
}); });
it('centres the handle on the track at every size', function () {
$page = sliderShowcase();
foreach (['Volume', 'Small (24px)', 'Medium (40px)', 'Extra large (96px)'] as $label) {
$page->assertScript(onSlider($label, <<<'JS'
const thumb = root.querySelector('[data-md-slider-thumb]').getBoundingClientRect()
const track = root.querySelector('[data-md-slider-track]').getBoundingClientRect()
const box = root.getBoundingClientRect()
return near(thumb.top + thumb.height / 2, track.top + track.height / 2)
&& near(track.top + track.height / 2, box.top + box.height / 2)
&& thumb.height === box.height - (box.height === 48 ? 4 : 0)
JS));
}
});
it('moves by the large interval with an arrow while Space is held', function () {
$page = sliderShowcase();
$page->script(onSlider('Volume', "inputs[0].focus(); inputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true, cancelable: true }))"));
$page->keys(':focus', 'ArrowRight')
->assertScript(onSlider('Volume', "return inputs[0].value === '50' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.5)"));
$page->script(onSlider('Volume', "inputs[0].dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }))"));
$page->keys(':focus', 'ArrowRight')
->assertScript(onSlider('Volume', "return inputs[0].value === '51'"));
});
it('stands a slider up: dragged along Y, moved by Up, Down, Home and End, its label beside the handle', function () {
$page = sliderShowcase();
// Where the handle sits along a vertical slider, measured up from its bottom edge.
$vertical = <<<'JS'
const controls = [...document.querySelectorAll('[data-md-slider][data-md-orientation="vertical"] [data-md-slider-control]')]
const slider = controls.find((control) => control.parentElement.querySelector(':scope > label')?.textContent.trim() === 'Warmth')
slider.scrollIntoView({ block: 'center' })
const input = slider.querySelector('input[type="range"]')
const length = slider.clientHeight - 4
const box = () => slider.getBoundingClientRect()
const thumb = () => slider.querySelector('[data-md-slider-thumb]').getBoundingClientRect()
const up = () => box().bottom - 2 - (thumb().top + thumb().height / 2)
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const pointer = (type, fraction) => (type === 'pointerdown' ? slider : window).dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, clientX: box().left + box().width / 2, clientY: box().bottom - 2 - length * fraction, button: 0, pointerId: 1, pointerType: 'mouse' }))
JS;
$page->assertScript("(async () => { {$vertical}; return input.value === '6' && input.getAttribute('aria-orientation') === 'vertical' && box().height > box().width })()");
// Warmth has ten steps on ticks, so a drag to 30% of the way up lands on 3.
$page->script("(async () => { {$vertical}; pointer('pointerdown', 0.9); pointer('pointermove', 0.3) })()");
$page->assertScript("(async () => { {$vertical}; await pause(400);
const label = slider.querySelector('[data-md-slider-value]').getBoundingClientRect()
const handle = thumb()
return input.value === '3'
&& slider.querySelector('[data-md-slider-value]').textContent === '3'
// Beside the handle, not above it: level with it, and off to one side.
&& Math.abs((label.top + label.height / 2) - (handle.top + handle.height / 2)) < 2
&& (label.right <= handle.left || label.left >= handle.right)
})()");
$page->script("(async () => { {$vertical}; pointer('pointerup', 0.3); input.focus() })()");
$page->keys(':focus', 'ArrowUp')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '4' })()");
$page->keys(':focus', 'ArrowDown')
->keys(':focus', 'ArrowDown')
->assertScript("(async () => { {$vertical}; return input.value === '2' })()");
$page->keys(':focus', 'End')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '10' && up() > length - 2 })()");
$page->keys(':focus', 'Home')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '0' && up() < 2 })()");
});
+100 -66
View File
@@ -19,8 +19,10 @@ it('is a labelled native range input under a drawing only the script touches', f
->toContain('value="40"') ->toContain('value="40"')
->toContain('x-data="materialSlider"') ->toContain('x-data="materialSlider"')
->toContain('x-on:pointerdown="press($event)"') ->toContain('x-on:pointerdown="press($event)"')
->toContain('data-slider-drawing wire:ignore aria-hidden="true"') ->toContain('data-md-slider-control')
->toContain('noscript:appearance-auto') ->toContain('<div data-md-slider-drawing wire:ignore aria-hidden="true">')
->toContain('data-md-slider-input="start"')
->not->toContain('class=')
->not->toContain('role="group"'); ->not->toContain('role="group"');
}); });
@@ -28,19 +30,24 @@ it('draws the first frame on the server: the track split around the handle, and
$html = (string) $this->blade('<x-slider value="40" />'); $html = (string) $this->blade('<x-slider value="40" />');
expect($html) expect($html)
->toMatch('/data-segment="active"\s+class="[^"]*bg-primary[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/') ->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
->toMatch('/data-segment="end"\s+class="[^"]*bg-secondary-container[^"]*"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/') ->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
->toMatch('/data-segment="start"\s+hidden/') ->toMatch('/data-md-slider-segment="start"\s+hidden/')
->toContain('style="left: calc(100% - 8px)"') ->toContain('style="left: calc(100% - 8px)"')
->toContain('<span data-handle="start" class="group/thumb absolute inset-y-0 w-0" style="left: calc(40% + 0px)">') ->toContain('<span data-md-slider-handle="start" style="left: calc(40% + 0px)">')
->toMatch('/data-value-label\s+class="[^"]*opacity-0[^"]*"\s*>40<\/span>/'); ->toContain('<span data-md-slider-value>40</span>')
->toMatch('/<div\s+data-md-slider\s+data-md-size="xs"\s+data-md-color="primary"\s+data-md-value-label="drag"/');
}); });
it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () { it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () {
expect((string) $this->blade('<x-slider value="40" />')) $css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
->toContain('flex h-11 min-w-12 items-center')
->toMatch('/data-value-label[^>]*class="[^"]*origin-bottom/') expect((string) $this->blade('<x-slider value="40" />'))->toMatch('/data-md-slider-stop="end"\s+style="left: calc\(100% - 8px\)"/')
->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/'); ->and($css)
->toMatch('/\[data-md-slider-value\] \{[^}]*min-width: var\(--md-sys-measurement-space600\);[^}]*height: 44px;[^}]*background-color: var\(--md-sys-color-inverse-surface\);[^}]*transform-origin: bottom;/')
->toMatch('/:is\(\[data-md-slider-tick\], \[data-md-slider-stop\]\) \{[^}]*background-color: var\(--slider-on-inactive\);/')
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/slider.css';");
}); });
it('snaps and clamps the value to the step grid', function (string $template, string $value) { it('snaps and clamps the value to the step grid', function (string $template, string $value) {
@@ -77,7 +84,8 @@ it('gives a range two inputs bound to the ends of an array, in a named group', f
->toContain('aria-label="Range start"') ->toContain('aria-label="Range start"')
->toContain('aria-label="Range end"') ->toContain('aria-label="Range end"')
->toContain('id="price-end"') ->toContain('id="price-end"')
->toContain('data-handle="end"') ->toContain('data-md-slider-handle="end"')
->toContain('data-md-slider-input="end"')
->not->toContain('<label for='); ->not->toContain('<label for=');
expect(substr_count($html, 'name="price[]"'))->toBe(2) expect(substr_count($html, 'name="price[]"'))->toBe(2)
@@ -123,7 +131,7 @@ it('replaces the hint with the validation message and marks the input invalid',
expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />')) expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />'))
->toContain('Too loud.') ->toContain('Too loud.')
->toContain('text-error') ->toContain('<div id="volume-hint" data-md-slider-errors>')
->toContain('aria-invalid="true"') ->toContain('aria-invalid="true"')
->toContain('aria-describedby="volume-hint"') ->toContain('aria-describedby="volume-hint"')
->not->toContain('Quietly'); ->not->toContain('Quietly');
@@ -133,105 +141,131 @@ it('replaces the hint with the validation message and marks the input invalid',
->not->toContain('Per night'); ->not->toContain('Per night');
expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />')) expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />'))
->toContain('Centre is even') ->toMatch('/data-md-slider-hint>Centre is even<\/p>/')
->not->toContain('aria-invalid'); ->not->toContain('aria-invalid');
}); });
it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $body) { it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $across) {
expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size])) expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size]))
->toContain("data-size=\"{$size}\"") ->toContain("data-md-size=\"{$size}\"");
->toContain("-translate-y-1/2 {$track}\"")
->toContain($handle) $css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
->toMatch("/group\\/slider [^\"]* {$body} /"); $block = $size === 'xs'
? '/\[data-md-slider\] \{[^}]*/'
: '/\[data-md-slider\]\[data-md-size=\''.$size.'\'\] \{[^}]*/';
preg_match($block, $css, $rule);
foreach (['--slider-across' => $across, '--slider-track' => $track, '--slider-handle' => $handle] as $property => $value) {
if ($size === 'sm' && $property !== '--slider-track') {
// sm keeps xs's handle and height.
continue;
}
expect($rule[0])->toContain("{$property}: {$value};");
}
// A focused handle is 6px shorter, so its ring stays clear of the label.
expect($css)->toContain('height: calc(var(--slider-handle) - 6px);');
})->with([ })->with([
'xs' => ['xs', 'h-4', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'], 'xs' => ['xs', '16px', '44px', '48px'],
'sm' => ['sm', 'h-6', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'], 'sm' => ['sm', '24px', '44px', '48px'],
'md' => ['md', 'h-10', 'h-13 group-data-focused/thumb:h-11.5', 'h-13'], 'md' => ['md', '40px', '52px', '52px'],
'lg' => ['lg', 'h-14', 'h-17 group-data-focused/thumb:h-15.5', 'h-17'], 'lg' => ['lg', '56px', '68px', '68px'],
'xl' => ['xl', 'h-24', 'h-27 group-data-focused/thumb:h-25.5', 'h-27'], 'xl' => ['xl', '96px', '108px', '108px'],
]); ]);
it('insets an icon in the track from md, but not on a small, range or centred slider', function () { it('insets an icon in the track from md, but not on a small, range or centred slider', function () {
expect((string) $this->blade('<x-slider size="md" icon="volume_up" value="60" />')) expect((string) $this->blade('<x-slider size="md" icon="volume_up" value="60" />'))
->toContain('data-track-icon') ->toMatch('/data-md-slider-icon\s+data-md-active/')
->toMatch('/data-track-icon\s+data-active/') ->toContain('--md-icon-size: 24px');
->toContain('text-on-secondary-container data-active:text-on-primary');
expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />')) expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />'))
->toMatch('/data-track-icon\s+class/') ->toMatch('/data-md-slider-icon\s+style=/')
->toContain('size-8'); ->toContain('--md-icon-size: 32px');
foreach (['<x-slider size="sm" icon="volume_up" />', '<x-slider size="lg" icon="volume_up" range />', '<x-slider size="lg" icon="volume_up" centered />'] as $template) { foreach (['<x-slider size="sm" icon="volume_up" />', '<x-slider size="lg" icon="volume_up" range />', '<x-slider size="lg" icon="volume_up" centered />'] as $template) {
expect((string) $this->blade($template))->not->toContain('data-track-icon'); expect((string) $this->blade($template))->not->toContain('data-md-slider-icon');
} }
}); });
it('fills a centred slider from the middle', function () { it('fills a centred slider from the middle', function () {
expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />')) expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />'))
->toContain('data-centered') ->toContain('data-md-centered')
->toMatch('/data-segment="start"\s+class="[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/') ->toMatch('/data-md-slider-segment="start"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
->toMatch('/data-segment="active"\s+class="[^"]*"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/') ->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
->toMatch('/data-segment="end"\s+class="[^"]*"\s+style="left: calc\(75% \+ 8px\)/'); ->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(75% \+ 8px\)/');
}); });
it('marks every step when asked, and no more than 200 of them', function () { it('marks every step when asked, and no more than 200 of them', function () {
$html = (string) $this->blade('<x-slider ticks :max="10" value="5" />'); $html = (string) $this->blade('<x-slider ticks :max="10" value="5" />');
expect(substr_count($html, 'data-tick="'))->toBe(11) expect(substr_count($html, 'data-md-slider-tick="'))->toBe(11)
->and($html)->toContain('data-tick="0.5"') ->and($html)->toContain('data-md-slider-tick="0.5"')
->and($html)->toContain('bg-on-secondary-container data-active:bg-on-primary'); ->and($html)->toMatch('/data-md-slider-tick="0.2"\s+data-md-active/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
->toMatch('/\[data-md-slider-tick\]\[data-md-active\] \{\s*background-color: var\(--slider-on-active\);/');
expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-tick=') expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-md-slider-tick=')
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-tick=') ->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-md-slider-tick=')
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-tick='); ->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-md-slider-tick=');
}); });
it('shows the value label on drag, always, or never', function () { it('shows the value label on drag, always, or never', function () {
expect((string) $this->blade('<x-slider />'))->toContain('opacity-0 scale-75 group-data-pressed/thumb:opacity-100'); expect((string) $this->blade('<x-slider />'))->toContain('data-md-value-label="drag"')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='drag'\\] \\[data-md-slider-value\\] \\{\\s*opacity: 0;\\s*scale: 0.75;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\] \\[data-md-slider-control\\] \\{\\s*margin-top: var\\(--md-sys-measurement-space600\\);/");
expect((string) $this->blade('<x-slider value-label="always" value="30" />')) expect((string) $this->blade('<x-slider value-label="always" value="30" />'))
->toContain('data-value-label') ->toContain('data-md-value-label="always"')
->toContain('mt-12') ->toContain('<span data-md-slider-value>30</span>');
->not->toContain('opacity-0 scale-75');
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-value-label'); expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-md-slider-value');
}); });
it('stands a slider up on request, turning the drawing a quarter', function () { it('stands a slider up on request, turning the drawing a quarter', function () {
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />'); $html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />');
expect($html) expect($html)
->toContain('data-orientation="vertical"') ->toContain('data-md-orientation="vertical"')
->toContain('aria-orientation="vertical"') ->toContain('aria-orientation="vertical"')
// The slider is as wide as a horizontal one is tall, and as long as its wrapper. ->toContain('class="h-64"');
->toContain('w-12')
->toContain('touch-pan-x h-48 min-h-0 flex-auto [container-type:size]')
->toContain('h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90')
// The value label is turned back, so it lands beside the handle and reads across.
->toMatch('/data-value-label[^>]*class="[^"]*rotate-90/')
->not->toContain('origin-bottom')
->not->toContain('rtl:-scale-x-100');
// The sizes are the horizontal ones, across instead of along. $css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
expect((string) $this->blade('<x-slider orientation="vertical" size="xl" />'))->toContain('w-27')
->and((string) $this->blade('<x-slider orientation="vertical" value-label="always" />'))->toContain('ms-12'); expect($css)
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*width: var\\(--slider-across\\);[^}]*height: 192px;[^}]*container-type: size;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-drawing\\] \\{[^}]*width: calc\\(100cqh - var\\(--md-sys-measurement-space50\\)\\);[^}]*height: 100cqw;[^}]*rotate: -90deg;/")
// The value label is turned back, so it lands beside the handle and reads across.
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-value\\] \\{[^}]*rotate: 90deg;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*margin-inline-start: var\\(--md-sys-measurement-space600\\);/");
}); });
it('keeps a range slider horizontal, as M3 asks', function () { it('keeps a range slider horizontal, as M3 asks', function () {
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />')) expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
->not->toContain('data-orientation') ->not->toContain('data-md-orientation')
->not->toContain('aria-orientation') ->not->toContain('aria-orientation');
->toContain('h-12');
}); });
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) { it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color])) expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
->toContain("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38") ->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
->toContain("absolute inset-y-0 {$inactive} group-has-disabled/slider:bg-on-surface/12")
->toMatch('/<input[^>]*\sdisabled[\s>]/'); ->toMatch('/<input[^>]*\sdisabled[\s>]/');
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
if ($color === 'primary' || $color === 'pink') {
expect($css)->toMatch("/\\[data-md-slider\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
} else {
expect($css)->toMatch("/\\[data-md-slider\\]\\[data-md-color='{$color}'\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
}
expect($css)->toMatch('/\[data-md-slider\]:has\(\[data-md-slider-input\]:disabled\) \{[^}]*--slider-active: var\(--slider-disabled-active\);[^}]*--slider-inactive: var\(--slider-disabled-inactive\);/');
})->with([ })->with([
'primary' => ['primary', 'bg-primary', 'bg-secondary-container'], 'primary' => ['primary', 'primary', 'secondary-container'],
'tertiary' => ['tertiary', 'bg-tertiary', 'bg-tertiary-container'], 'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
'error' => ['error', 'bg-error', 'bg-error-container'], 'error' => ['error', 'error', 'error-container'],
'unknown' => ['pink', 'bg-primary', 'bg-secondary-container'], 'unknown' => ['pink', 'primary', 'secondary-container'],
]); ]);