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
@@ -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 })()");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user