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
+99 -20
View File
@@ -75,7 +75,7 @@ function onSlider(string $label, string $body): string
{
return <<<JS
(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}')
root.scrollIntoView({ block: 'center' })
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 left = (selector) => Number.parseFloat(root.querySelector(selector).style.left)
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)
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'])
->assertScript(onSlider('Volume', <<<'JS'
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('end').from, width * 0.42 + 8)
&& root.querySelector('[data-handle="start"]').hasAttribute('data-focused')
&& root.querySelector('[data-value-label]').textContent === '42'
&& root.querySelector('[data-md-slider-handle="start"]').hasAttribute('data-md-focused')
&& root.querySelector('[data-md-slider-value]').textContent === '42'
JS));
$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')
->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')
->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'
await pause(450)
const thumb = root.querySelector('[data-handle="start"]')
const label = thumb.querySelector('[data-value-label]')
const thumb = root.querySelector('[data-md-slider-handle="start"]')
const label = thumb.querySelector('[data-md-slider-value]')
return inputs[0].value === '75'
&& label.textContent === '75'
&& getComputedStyle(label).opacity === '1'
&& thumb.hasAttribute('data-pressed')
&& thumb.hasAttribute('data-md-pressed')
&& 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]
JS));
@@ -145,13 +145,13 @@ it('follows a pointer drag with a narrowed handle and its value label', function
$page->assertScript(onSlider('Volume', <<<'JS'
await pause(450)
const thumb = root.querySelector('[data-handle="start"]')
const thumb = root.querySelector('[data-md-slider-handle="start"]')
return inputs[0].value === '75'
&& window.__changed === 1
&& ! thumb.hasAttribute('data-pressed')
&& ! thumb.hasAttribute('data-focused')
&& ! thumb.hasAttribute('data-md-pressed')
&& ! thumb.hasAttribute('data-md-focused')
&& thumb.firstElementChild.offsetWidth === 4
&& getComputedStyle(thumb.querySelector('[data-value-label]')).opacity === '0'
&& getComputedStyle(thumb.querySelector('[data-md-slider-value]')).opacity === '0'
JS));
});
@@ -163,7 +163,7 @@ it('keeps a range\'s handles from crossing, by pointer and by keyboard', functio
$page->assertScript(onSlider('Price', <<<'JS'
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
JS));
@@ -183,7 +183,7 @@ it('fills a centred slider from the middle', function () {
$page = sliderShowcase();
$page->assertScript(onSlider('Balance', <<<'JS'
const handle = left('[data-handle="start"]')
const handle = left('[data-md-slider-handle="start"]')
return inputs[0].value === '15'
&& near(handle, width * 0.65)
&& 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->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 () {
@@ -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.
$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))
->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")')
->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->keys(':focus', 'ArrowLeft')
->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 })()");
});