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 = [];
}
}