melt
Version:
The next generation of Melt UI. Built for Svelte 5.
146 lines (145 loc) • 5.05 kB
JavaScript
import { dataAttr, styleAttr } from "../utils/attribute";
import { extract } from "../utils/extract";
import { clamp } from "../utils/number";
import { useEventListener } from "runed";
import { Synced } from "../Synced.svelte";
import { createBuilderMetadata } from "../utils/identifiers";
import { isHtmlElement } from "../utils/is";
const { createIds, dataAttrs } = createBuilderMetadata("slider", ["root", "thumb"]);
export class Slider {
/* Props */
#props;
min = $derived(extract(this.#props.min, 0));
max = $derived(extract(this.#props.max, 100));
orientation = $derived(extract(this.#props.orientation, "horizontal"));
step = $derived(extract(this.#props.step, 1));
/* State */
#value;
ids = createIds();
#mouseDown = false;
#dragging = false;
#mouseDownAt = null;
constructor(props = {}) {
this.#props = props;
this.#value = new Synced({
value: props.value,
onChange: props.onValueChange,
defaultValue: 0,
});
}
/** The value of the slider. */
get value() {
return this.#value.current;
}
set value(value) {
this.#value.current = clamp(this.min, value, this.max);
}
get #percentage() {
const v = (this.value - this.min) / (this.max - this.min);
return this.orientation === "vertical" ? 1 - v : v;
}
#commit(e) {
this.#dragging = typeof this.#mouseDownAt === "number" && e.timeStamp - this.#mouseDownAt > 50;
const el = document.getElementById(this.ids.root);
if (!isHtmlElement(el))
return;
e.preventDefault();
const elRect = el.getBoundingClientRect();
let percentage;
if (this.orientation === "vertical") {
percentage = 1 - clamp(0, e.clientY - elRect.top, elRect.height) / elRect.height;
}
else {
percentage = clamp(0, e.clientX - elRect.left, elRect.width) / elRect.width;
}
this.value = getValueFixedToStep(this.min + percentage * (this.max - this.min), this.step);
}
get #sharedProps() {
return {
"data-dragging": dataAttr(this.#dragging),
"data-value": dataAttr(this.value),
"data-orientation": dataAttr(this.orientation),
};
}
/**
* The root of the slider.
* Any cursor interaction along this element will change the slider's values.
**/
get root() {
useEventListener(() => window, "pointermove", (e) => {
if (!this.#mouseDown)
return;
this.#commit(e);
});
useEventListener(() => window, "pointerup", () => {
this.#mouseDown = false;
this.#dragging = false;
});
return {
"aria-valuenow": this.value,
"aria-valuemin": this.min,
"aria-valuemax": this.max,
"aria-orientation": this.orientation,
style: styleAttr({
"--percentage": `${this.#percentage * 100}%`,
"--percentage-inv": `${(1 - this.#percentage) * 100}%`,
"touch-action": this.orientation === "vertical" ? "pan-x" : "pan-y",
}),
tabindex: 0,
role: "slider",
[dataAttrs.root]: "",
id: this.ids.root,
onpointerdown: (e) => {
this.#mouseDown = true;
this.#mouseDownAt = e.timeStamp;
this.#commit(e);
document.getElementById(this.ids.thumb)?.focus();
},
onkeydown: (e) => {
switch (e.key) {
case "ArrowDown":
case "ArrowLeft": {
if (e.metaKey)
this.value = this.min;
else
this.value -= this.step;
break;
}
case "ArrowUp":
case "ArrowRight": {
if (e.metaKey)
this.value = this.max;
else
this.value += this.step;
break;
}
case "Home": {
this.value = this.min;
break;
}
case "End": {
this.value = this.max;
break;
}
default: {
return;
}
}
e.preventDefault();
},
...this.#sharedProps,
};
}
/** The slider's thumb, positioned at the end of the range. */
get thumb() {
return {
[dataAttrs.thumb]: "",
id: this.ids.thumb,
tabindex: 0,
...this.#sharedProps,
};
}
}
function getValueFixedToStep(value, step) {
return Math.round(value / step) * step;
}