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
+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.
*/
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 drawing = root.querySelector('[data-slider-drawing]')
const drawing = root.querySelector('[data-md-slider-drawing]')
const range = inputs.length > 1
const cleanups = []
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. */
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. */
const trackLength = () => Math.max((vertical() ? root.clientHeight : root.clientWidth) - HANDLE_WIDTH, 0)
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 { min, max } = bounds()
return sliderGeometry({
width,
size: root.dataset.size,
size: host.dataset.mdSize,
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,
tickFractions: tickFractions(),
})
@@ -257,13 +259,13 @@ function slider(root) {
}
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 part = (selector) => drawing.querySelector(selector)
const px = (number) => `${Math.round(number * 100) / 100}px`
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]
if (element) {
@@ -277,7 +279,7 @@ function slider(root) {
}
for (const name of ['start', 'end']) {
const element = part(`[data-stop="${name}"]`)
const element = part(`[data-md-slider-stop="${name}"]`)
if (element) {
element.hidden = stops[name] === undefined
@@ -288,20 +290,20 @@ function slider(root) {
const fractions = tickFractions()
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]
element.hidden = !tick || spacing < TICK_MIN_SPACING
if (tick) {
element.style.left = px(tick.x)
element.toggleAttribute('data-active', tick.active)
element.toggleAttribute('data-md-active', tick.active)
}
})
handles.forEach((x, i) => {
const thumb = part(`[data-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-value-label]')
const thumb = part(`[data-md-slider-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-md-slider-value]')
if (thumb) {
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.
const icon = part('[data-track-icon]')
const icon = part('[data-md-slider-icon]')
if (icon) {
const room = size.icon + ICON_PADDING * 2
@@ -321,7 +323,7 @@ function slider(root) {
const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room
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)
}
}
@@ -371,10 +373,10 @@ function slider(root) {
}
/** 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. */
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) => {
// 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)
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)
resizes.observe(root)