import { SLIDER_CHANGE_EVENT, type SliderChangeDetail, type SliderOptions, type SliderOrientation, } from './slider.types'; const SELECTORS = { track: '[data-c42-slider-track]', thumb: '[data-c42-slider-thumb]', } as const; /** * Headless slider/range controller. Supports a single thumb or a two-thumb * range (inferred from the number of `[data-c42-slider-thumb]` elements), * full keyboard control, and pointer dragging. It manages ARIA `slider` * semantics and exposes thumb positions as `--c42-slider-start-percent` / * `--c42-slider-end-percent` custom properties so CSS can position thumbs and * the filled range. It applies no visual styles itself. * * Markup (single): * ```html *
*
*
*
*
*
* ``` * * Markup (range): add a second thumb with `data-thumb="start"` / `"end"`. */ export class Slider { private readonly root: HTMLElement; private readonly track: HTMLElement; private readonly thumbs: HTMLElement[]; private readonly isRange: boolean; private readonly min: number; private readonly max: number; private readonly step: number; private readonly orientation: SliderOrientation; private values: number[]; private activeThumb = -1; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: SliderOptions = {}) { const track = root.querySelector(SELECTORS.track); if (!track) { throw new Error('[42/slider] Needs a [data-c42-slider-track] element.'); } const thumbs = Array.from(track.querySelectorAll(SELECTORS.thumb)); if (thumbs.length === 0) { throw new Error('[42/slider] Needs at least one [data-c42-slider-thumb] element.'); } this.root = root; this.track = track; this.thumbs = thumbs.slice(0, 2); this.isRange = this.thumbs.length === 2; this.min = options.min ?? 0; this.max = options.max ?? 100; if (this.max <= this.min) { this.max = this.min + 1; } this.step = options.step && options.step > 0 ? options.step : 1; this.orientation = options.orientation ?? 'horizontal'; this.values = this.initialValues(options.value); this.init(options.label); } private initialValues(value: SliderOptions['value']): number[] { if (this.isRange) { const pair = Array.isArray(value) ? value : [this.min, this.max]; const start = this.snap(pair[0] ?? this.min); const end = this.snap(pair[1] ?? this.max); return [Math.min(start, end), Math.max(start, end)]; } const single = typeof value === 'number' ? value : Array.isArray(value) ? value[0] : this.min; return [this.snap(single ?? this.min)]; } private init(label: SliderOptions['label']): void { this.root.dataset.orientation = this.orientation; if (this.isRange) { this.root.dataset.range = ''; } this.thumbs.forEach((thumb, index) => { thumb.setAttribute('role', 'slider'); thumb.setAttribute('aria-orientation', this.orientation); thumb.setAttribute('aria-valuemin', String(this.min)); thumb.setAttribute('aria-valuemax', String(this.max)); if (!thumb.hasAttribute('tabindex')) { thumb.setAttribute('tabindex', '0'); } if (this.isRange && !thumb.dataset.thumb) { thumb.dataset.thumb = index === 0 ? 'start' : 'end'; } const aria = Array.isArray(label) ? label[index] : index === 0 ? label : undefined; if (aria) { thumb.setAttribute('aria-label', aria); } const onKeydown = (event: Event): void => this.onKeydown(index, event as KeyboardEvent); thumb.addEventListener('keydown', onKeydown); this.cleanups.push(() => thumb.removeEventListener('keydown', onKeydown)); }); const onPointerDown = (event: Event): void => this.onTrackPointerDown(event as PointerEvent); this.track.addEventListener('pointerdown', onPointerDown); this.cleanups.push(() => this.track.removeEventListener('pointerdown', onPointerDown)); this.render(); } private snap(value: number): number { if (Number.isNaN(value)) { return this.min; } const steps = Math.round((value - this.min) / this.step); const snapped = this.min + steps * this.step; return Math.min(this.max, Math.max(this.min, Number(snapped.toFixed(6)))); } private percent(value: number): number { return ((value - this.min) / (this.max - this.min)) * 100; } private render(): void { const startPct = this.isRange ? this.percent(this.values[0]!) : 0; const endPct = this.percent(this.isRange ? this.values[1]! : this.values[0]!); this.root.style.setProperty('--c42-slider-start-percent', `${startPct}%`); this.root.style.setProperty('--c42-slider-end-percent', `${endPct}%`); this.thumbs.forEach((thumb, index) => { thumb.setAttribute('aria-valuenow', String(this.values[index])); }); } private emit(): void { const detail: SliderChangeDetail = { value: this.isRange ? [this.values[0]!, this.values[1]!] : this.values[0]!, values: [...this.values], }; this.root.dispatchEvent(new CustomEvent(SLIDER_CHANGE_EVENT, { detail, bubbles: true })); } private setThumbValue(index: number, raw: number): void { let next = this.snap(raw); if (this.isRange) { next = index === 0 ? Math.min(next, this.values[1]!) : Math.max(next, this.values[0]!); } if (next === this.values[index]) { return; } this.values[index] = next; this.render(); this.emit(); } private onKeydown(index: number, event: KeyboardEvent): void { const current = this.values[index]!; const big = this.step * 10; let next: number | null = null; switch (event.key) { case 'ArrowRight': case 'ArrowUp': next = current + this.step; break; case 'ArrowLeft': case 'ArrowDown': next = current - this.step; break; case 'PageUp': next = current + big; break; case 'PageDown': next = current - big; break; case 'Home': next = this.min; break; case 'End': next = this.max; break; default: return; } event.preventDefault(); this.setThumbValue(index, next); } private valueFromPointer(event: PointerEvent): number | null { const rect = this.track.getBoundingClientRect(); const size = this.orientation === 'horizontal' ? rect.width : rect.height; if (!size) { return null; } const ratio = this.orientation === 'horizontal' ? (event.clientX - rect.left) / rect.width : (rect.bottom - event.clientY) / rect.height; return this.min + Math.min(1, Math.max(0, ratio)) * (this.max - this.min); } private nearestThumb(value: number): number { if (!this.isRange) { return 0; } return Math.abs(value - this.values[0]!) <= Math.abs(value - this.values[1]!) ? 0 : 1; } private readonly onPointerMove = (event: PointerEvent): void => { if (this.activeThumb < 0) { return; } const value = this.valueFromPointer(event); if (value !== null) { this.setThumbValue(this.activeThumb, value); } }; private readonly onPointerUp = (): void => { this.activeThumb = -1; document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); }; private onTrackPointerDown(event: PointerEvent): void { const thumbEl = (event.target as HTMLElement).closest(SELECTORS.thumb); const value = this.valueFromPointer(event); let index: number; if (thumbEl) { index = this.thumbs.indexOf(thumbEl); } else { index = value !== null ? this.nearestThumb(value) : 0; } if (index < 0) { return; } event.preventDefault(); this.activeThumb = index; this.thumbs[index]!.focus(); if (!thumbEl && value !== null) { this.setThumbValue(index, value); } document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('pointerup', this.onPointerUp); } /** Programmatically set the value (single number or `[start, end]`). */ setValue(value: number | [number, number]): void { if (this.isRange && Array.isArray(value)) { const start = this.snap(value[0]); const end = this.snap(value[1]); this.values = [Math.min(start, end), Math.max(start, end)]; } else if (!this.isRange && typeof value === 'number') { this.values = [this.snap(value)]; } else { return; } this.render(); this.emit(); } /** Current value: a number for single, `[start, end]` for range. */ getValue(): number | [number, number] { return this.isRange ? [this.values[0]!, this.values[1]!] : this.values[0]!; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.onPointerUp(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }