@zag-js/range-slider
Version:
Core logic for the range-slider widget implemented as a state machine
742 lines (735 loc) • 24 kB
JavaScript
// src/range-slider.anatomy.ts
import { createAnatomy } from "@zag-js/anatomy";
var anatomy = createAnatomy("range-slider").parts(
"root",
"label",
"thumb",
"output",
"track",
"range",
"control",
"markerGroup",
"marker"
);
var parts = anatomy.build();
// src/range-slider.connect.ts
import {
getEventKey,
getEventPoint,
getEventStep,
getNativeEvent,
isLeftClick,
isModifiedEvent
} from "@zag-js/dom-event";
import { ariaAttr, dataAttr } from "@zag-js/dom-query";
import { getPercentValue as getPercentValue2, getValuePercent } from "@zag-js/numeric-range";
// src/range-slider.dom.ts
import { getRelativePoint } from "@zag-js/dom-event";
import { createScope, queryAll } from "@zag-js/dom-query";
import { dispatchInputValueEvent } from "@zag-js/form-utils";
import { getPercentValue } from "@zag-js/numeric-range";
// src/range-slider.style.ts
import { unstable__dom as sliderDom } from "@zag-js/slider";
function getBounds(value) {
const firstValue = value[0];
const lastThumb = value[value.length - 1];
return [firstValue, lastThumb];
}
function getRangeOffsets(ctx) {
const [firstPercent, lastPercent] = getBounds(ctx.valuePercent);
return { start: `${firstPercent}%`, end: `${100 - lastPercent}%` };
}
function getVisibility(ctx) {
let visibility = "visible";
if (ctx.thumbAlignment === "contain" && !ctx.hasMeasuredThumbSize) {
visibility = "hidden";
}
return visibility;
}
function getThumbStyle(ctx, index) {
const placementProp = ctx.isVertical ? "bottom" : "insetInlineStart";
return {
visibility: getVisibility(ctx),
position: "absolute",
transform: "var(--slider-thumb-transform)",
[placementProp]: `var(--slider-thumb-offset-${index})`
};
}
function getRootStyle(ctx) {
const range = getRangeOffsets(ctx);
const offsetStyles = ctx.value.reduce((styles, value, index) => {
const offset = sliderDom.getThumbOffset({ ...ctx, value });
return { ...styles, [`--slider-thumb-offset-${index}`]: offset };
}, {});
return {
...offsetStyles,
"--slider-thumb-transform": ctx.isVertical ? "translateY(50%)" : ctx.isRtl ? "translateX(50%)" : "translateX(-50%)",
"--slider-range-start": range.start,
"--slider-range-end": range.end
};
}
var styleGetterFns = {
getRootStyle,
getControlStyle: sliderDom.getControlStyle,
getThumbStyle,
getRangeStyle: sliderDom.getRangeStyle,
getMarkerStyle: sliderDom.getMarkerStyle,
getMarkerGroupStyle: sliderDom.getMarkerGroupStyle
};
// src/range-slider.dom.ts
var dom = createScope({
...styleGetterFns,
getRootId: (ctx) => ctx.ids?.root ?? `slider:${ctx.id}`,
getThumbId: (ctx, index) => ctx.ids?.thumb?.(index) ?? `slider:${ctx.id}:thumb:${index}`,
getHiddenInputId: (ctx, index) => `slider:${ctx.id}:input:${index}`,
getControlId: (ctx) => ctx.ids?.control ?? `slider:${ctx.id}:control`,
getTrackId: (ctx) => ctx.ids?.track ?? `slider:${ctx.id}:track`,
getRangeId: (ctx) => ctx.ids?.range ?? `slider:${ctx.id}:range`,
getLabelId: (ctx) => ctx.ids?.label ?? `slider:${ctx.id}:label`,
getOutputId: (ctx) => ctx.ids?.output ?? `slider:${ctx.id}:output`,
getMarkerId: (ctx, value) => ctx.ids?.marker?.(value) ?? `slider:${ctx.id}:marker:${value}`,
getRootEl: (ctx) => dom.getById(ctx, dom.getRootId(ctx)),
getThumbEl: (ctx, index) => dom.getById(ctx, dom.getThumbId(ctx, index)),
getHiddenInputEl: (ctx, index) => dom.getById(ctx, dom.getHiddenInputId(ctx, index)),
getControlEl: (ctx) => dom.getById(ctx, dom.getControlId(ctx)),
getElements: (ctx) => queryAll(dom.getControlEl(ctx), "[role=slider]"),
getFirstEl: (ctx) => dom.getElements(ctx)[0],
getRangeEl: (ctx) => dom.getById(ctx, dom.getRangeId(ctx)),
getValueFromPoint(ctx, point) {
const controlEl = dom.getControlEl(ctx);
if (!controlEl)
return;
const relativePoint = getRelativePoint(point, controlEl);
const percent = relativePoint.getPercentValue({
orientation: ctx.orientation,
dir: ctx.dir,
inverted: { y: true }
});
return getPercentValue(percent, ctx.min, ctx.max, ctx.step);
},
dispatchChangeEvent(ctx) {
const valueArray = Array.from(ctx.value);
valueArray.forEach((value, index) => {
const inputEl = dom.getHiddenInputEl(ctx, index);
if (!inputEl)
return;
dispatchInputValueEvent(inputEl, { value });
});
}
});
// src/range-slider.utils.ts
import {
clampValue,
getClosestValueIndex,
getNextStepValue,
getPreviousStepValue,
getValueRanges,
snapValueToStep
} from "@zag-js/numeric-range";
function normalizeValues(ctx, nextValues) {
return nextValues.map((value, index, values) => {
return constrainValue({ ...ctx, value: values }, value, index);
});
}
function getRangeAtIndex(ctx, index) {
return getValueRanges(ctx.value, ctx.min, ctx.max, ctx.minStepsBetweenThumbs)[index];
}
function constrainValue(ctx, value, index) {
const range = getRangeAtIndex(ctx, index);
const snapValue = snapValueToStep(value, ctx.min, ctx.max, ctx.step);
return clampValue(snapValue, range.min, range.max);
}
function decrement(ctx, index, step) {
const idx = index ?? ctx.focusedIndex;
const range = getRangeAtIndex(ctx, idx);
const nextValues = getPreviousStepValue(idx, {
...range,
step: step ?? ctx.step,
values: ctx.value
});
nextValues[idx] = clampValue(nextValues[idx], range.min, range.max);
return nextValues;
}
function increment(ctx, index, step) {
const idx = index ?? ctx.focusedIndex;
const range = getRangeAtIndex(ctx, idx);
const nextValues = getNextStepValue(idx, {
...range,
step: step ?? ctx.step,
values: ctx.value
});
nextValues[idx] = clampValue(nextValues[idx], range.min, range.max);
return nextValues;
}
function getClosestIndex(ctx, pointValue) {
return getClosestValueIndex(ctx.value, pointValue);
}
function assignArray(current, next) {
for (let i = 0; i < next.length; i++) {
const value = next[i];
current[i] = value;
}
}
// src/range-slider.connect.ts
function connect(state, send, normalize) {
const ariaLabel = state.context["aria-label"];
const ariaLabelledBy = state.context["aria-labelledby"];
const sliderValue = state.context.value;
const isFocused = state.matches("focus");
const isDragging = state.matches("dragging");
const isDisabled = state.context.isDisabled;
const isInvalid = state.context.invalid;
const isInteractive = state.context.isInteractive;
function getValuePercentFn(value) {
return getValuePercent(value, state.context.min, state.context.max);
}
function getPercentValueFn(percent) {
return getPercentValue2(percent, state.context.min, state.context.max, state.context.step);
}
return {
value: state.context.value,
isDragging,
isFocused,
setValue(value) {
send({ type: "SET_VALUE", value });
},
getThumbValue(index) {
return sliderValue[index];
},
setThumbValue(index, value) {
send({ type: "SET_VALUE", index, value });
},
getValuePercent: getValuePercentFn,
getPercentValue: getPercentValueFn,
getThumbPercent(index) {
return getValuePercentFn(sliderValue[index]);
},
setThumbPercent(index, percent) {
const value = getPercentValueFn(percent);
send({ type: "SET_VALUE", index, value });
},
getThumbMin(index) {
return getRangeAtIndex(state.context, index).min;
},
getThumbMax(index) {
return getRangeAtIndex(state.context, index).max;
},
increment(index) {
send({ type: "INCREMENT", index });
},
decrement(index) {
send({ type: "DECREMENT", index });
},
focus() {
if (!isInteractive)
return;
send({ type: "FOCUS", index: 0 });
},
labelProps: normalize.label({
...parts.label.attrs,
dir: state.context.dir,
"data-disabled": dataAttr(isDisabled),
"data-orientation": state.context.orientation,
"data-invalid": dataAttr(isInvalid),
"data-focus": dataAttr(isFocused),
id: dom.getLabelId(state.context),
htmlFor: dom.getHiddenInputId(state.context, 0),
onClick(event) {
if (!isInteractive)
return;
event.preventDefault();
dom.getFirstEl(state.context)?.focus();
},
style: {
userSelect: "none"
}
}),
rootProps: normalize.element({
...parts.root.attrs,
"data-disabled": dataAttr(isDisabled),
"data-orientation": state.context.orientation,
"data-invalid": dataAttr(isInvalid),
"data-focus": dataAttr(isFocused),
id: dom.getRootId(state.context),
dir: state.context.dir,
style: dom.getRootStyle(state.context)
}),
outputProps: normalize.output({
...parts.output.attrs,
dir: state.context.dir,
"data-disabled": dataAttr(isDisabled),
"data-orientation": state.context.orientation,
"data-invalid": dataAttr(isInvalid),
"data-focus": dataAttr(isFocused),
id: dom.getOutputId(state.context),
htmlFor: sliderValue.map((_v, i) => dom.getHiddenInputId(state.context, i)).join(" "),
"aria-live": "off"
}),
trackProps: normalize.element({
...parts.track.attrs,
dir: state.context.dir,
id: dom.getTrackId(state.context),
"data-disabled": dataAttr(isDisabled),
"data-invalid": dataAttr(isInvalid),
"data-orientation": state.context.orientation,
"data-focus": dataAttr(isFocused),
style: { position: "relative" }
}),
getThumbProps(index) {
const value = sliderValue[index];
const range = getRangeAtIndex(state.context, index);
const ariaValueText = state.context.getAriaValueText?.(value, index);
const _ariaLabel = Array.isArray(ariaLabel) ? ariaLabel[index] : ariaLabel;
const _ariaLabelledBy = Array.isArray(ariaLabelledBy) ? ariaLabelledBy[index] : ariaLabelledBy;
return normalize.element({
...parts.thumb.attrs,
dir: state.context.dir,
"data-index": index,
id: dom.getThumbId(state.context, index),
"data-disabled": dataAttr(isDisabled),
"data-orientation": state.context.orientation,
"data-focus": dataAttr(isFocused && state.context.focusedIndex === index),
draggable: false,
"aria-disabled": ariaAttr(isDisabled),
"aria-label": _ariaLabel,
"aria-labelledby": _ariaLabelledBy ?? dom.getLabelId(state.context),
"aria-orientation": state.context.orientation,
"aria-valuemax": range.max,
"aria-valuemin": range.min,
"aria-valuenow": sliderValue[index],
"aria-valuetext": ariaValueText,
role: "slider",
tabIndex: isDisabled ? void 0 : 0,
style: dom.getThumbStyle(state.context, index),
onPointerDown(event) {
if (!isInteractive)
return;
send({ type: "THUMB_POINTER_DOWN", index });
event.stopPropagation();
},
onBlur() {
if (!isInteractive)
return;
send("BLUR");
},
onFocus() {
if (!isInteractive)
return;
send({ type: "FOCUS", index });
},
onKeyDown(event) {
if (!isInteractive)
return;
const step = getEventStep(event) * state.context.step;
let prevent = true;
const keyMap = {
ArrowUp() {
send({ type: "ARROW_UP", step });
prevent = state.context.isVertical;
},
ArrowDown() {
send({ type: "ARROW_DOWN", step });
prevent = state.context.isVertical;
},
ArrowLeft() {
send({ type: "ARROW_LEFT", step });
prevent = state.context.isHorizontal;
},
ArrowRight() {
send({ type: "ARROW_RIGHT", step });
prevent = state.context.isHorizontal;
},
PageUp() {
send({ type: "PAGE_UP", step });
},
PageDown() {
send({ type: "PAGE_DOWN", step });
},
Home() {
send("HOME");
},
End() {
send("END");
}
};
const key = getEventKey(event, state.context);
const exec = keyMap[key];
if (!exec)
return;
exec(event);
if (prevent) {
event.preventDefault();
event.stopPropagation();
}
}
});
},
getHiddenInputProps(index) {
return normalize.input({
name: `${state.context.name}[${index}]`,
form: state.context.form,
type: "text",
hidden: true,
defaultValue: state.context.value[index],
id: dom.getHiddenInputId(state.context, index)
});
},
rangeProps: normalize.element({
id: dom.getRangeId(state.context),
...parts.range.attrs,
dir: state.context.dir,
"data-focus": dataAttr(isFocused),
"data-invalid": dataAttr(isInvalid),
"data-disabled": dataAttr(isDisabled),
"data-orientation": state.context.orientation,
style: dom.getRangeStyle(state.context)
}),
controlProps: normalize.element({
...parts.control.attrs,
dir: state.context.dir,
id: dom.getControlId(state.context),
"data-disabled": dataAttr(isDisabled),
"data-orientation": state.context.orientation,
"data-invalid": dataAttr(isInvalid),
"data-focus": dataAttr(isFocused),
style: dom.getControlStyle(),
onPointerDown(event) {
if (!isInteractive)
return;
const evt = getNativeEvent(event);
if (!isLeftClick(evt) || isModifiedEvent(evt))
return;
const point = getEventPoint(evt);
send({ type: "POINTER_DOWN", point });
event.preventDefault();
event.stopPropagation();
}
}),
markerGroupProps: normalize.element({
...parts.markerGroup.attrs,
role: "presentation",
dir: state.context.dir,
"aria-hidden": true,
"data-orientation": state.context.orientation,
style: dom.getMarkerGroupStyle()
}),
getMarkerProps({ value }) {
const style = dom.getMarkerStyle(state.context, value);
let markerState;
const first = state.context.value[0];
const last = state.context.value[state.context.value.length - 1];
if (value < first) {
markerState = "under-value";
} else if (value > last) {
markerState = "over-value";
} else {
markerState = "at-value";
}
return normalize.element({
...parts.marker.attrs,
id: dom.getMarkerId(state.context, value),
role: "presentation",
dir: state.context.dir,
"data-orientation": state.context.orientation,
"data-value": value,
"data-disabled": dataAttr(isDisabled),
"data-state": markerState,
style
});
}
};
}
// src/range-slider.machine.ts
import { createMachine } from "@zag-js/core";
import { trackPointerMove } from "@zag-js/dom-event";
import { raf } from "@zag-js/dom-query";
import { trackElementsSize } from "@zag-js/element-size";
import { trackFormControl } from "@zag-js/form-utils";
import { getValuePercent as getValuePercent2 } from "@zag-js/numeric-range";
import { compact, isEqual } from "@zag-js/utils";
var isEqualSize = (a, b) => {
return a?.width === b?.width && a?.height === b?.height;
};
function machine(userContext) {
const ctx = compact(userContext);
return createMachine(
{
id: "range-slider",
initial: "idle",
context: {
thumbSize: null,
thumbAlignment: "contain",
threshold: 5,
focusedIndex: -1,
min: 0,
max: 100,
step: 1,
value: [0, 100],
orientation: "horizontal",
dir: "ltr",
minStepsBetweenThumbs: 0,
disabled: false,
...ctx,
fieldsetDisabled: false
},
computed: {
isHorizontal: (ctx2) => ctx2.orientation === "horizontal",
isVertical: (ctx2) => ctx2.orientation === "vertical",
isRtl: (ctx2) => ctx2.orientation === "horizontal" && ctx2.dir === "rtl",
isDisabled: (ctx2) => !!ctx2.disabled || ctx2.fieldsetDisabled,
isInteractive: (ctx2) => !(ctx2.readOnly || ctx2.isDisabled),
spacing: (ctx2) => ctx2.minStepsBetweenThumbs * ctx2.step,
hasMeasuredThumbSize: (ctx2) => ctx2.thumbSize != null,
valuePercent(ctx2) {
return ctx2.value.map((value) => 100 * getValuePercent2(value, ctx2.min, ctx2.max));
}
},
watch: {
value: ["syncInputElements"]
},
entry: ["coarseValue"],
activities: ["trackFormControlState", "trackThumbsSize"],
on: {
SET_VALUE: [
{
guard: "hasIndex",
actions: "setValueAtIndex"
},
{ actions: "setValue" }
],
INCREMENT: {
actions: "incrementAtIndex"
},
DECREMENT: {
actions: "decrementAtIndex"
}
},
states: {
idle: {
on: {
POINTER_DOWN: {
target: "dragging",
actions: ["setClosestThumbIndex", "setPointerValue", "invokeOnChangeStart", "focusActiveThumb"]
},
FOCUS: {
target: "focus",
actions: "setFocusedIndex"
},
THUMB_POINTER_DOWN: {
target: "dragging",
actions: ["setFocusedIndex", "invokeOnChangeStart", "focusActiveThumb"]
}
}
},
focus: {
entry: "focusActiveThumb",
on: {
POINTER_DOWN: {
target: "dragging",
actions: ["setClosestThumbIndex", "setPointerValue", "invokeOnChangeStart", "focusActiveThumb"]
},
THUMB_POINTER_DOWN: {
target: "dragging",
actions: ["setFocusedIndex", "invokeOnChangeStart", "focusActiveThumb"]
},
ARROW_LEFT: {
guard: "isHorizontal",
actions: "decrementAtIndex"
},
ARROW_RIGHT: {
guard: "isHorizontal",
actions: "incrementAtIndex"
},
ARROW_UP: {
guard: "isVertical",
actions: "incrementAtIndex"
},
ARROW_DOWN: {
guard: "isVertical",
actions: "decrementAtIndex"
},
PAGE_UP: {
actions: "incrementAtIndex"
},
PAGE_DOWN: {
actions: "decrementAtIndex"
},
HOME: {
actions: "setActiveThumbToMin"
},
END: {
actions: "setActiveThumbToMax"
},
BLUR: {
target: "idle",
actions: "clearFocusedIndex"
}
}
},
dragging: {
entry: "focusActiveThumb",
activities: "trackPointerMove",
on: {
POINTER_UP: {
target: "focus",
actions: "invokeOnChangeEnd"
},
POINTER_MOVE: {
actions: "setPointerValue"
}
}
}
}
},
{
guards: {
isHorizontal: (ctx2) => ctx2.isHorizontal,
isVertical: (ctx2) => ctx2.isVertical,
hasIndex: (_ctx, evt) => evt.index != null
},
activities: {
trackFormControlState(ctx2, _evt, { initialContext }) {
return trackFormControl(dom.getRootEl(ctx2), {
onFieldsetDisabledChange(disabled) {
ctx2.fieldsetDisabled = disabled;
},
onFormReset() {
set.value(ctx2, initialContext.value);
}
});
},
trackPointerMove(ctx2, _evt, { send }) {
return trackPointerMove(dom.getDoc(ctx2), {
onPointerMove(info) {
send({ type: "POINTER_MOVE", point: info.point });
},
onPointerUp() {
send("POINTER_UP");
}
});
},
trackThumbsSize(ctx2) {
if (ctx2.thumbAlignment !== "contain" || ctx2.thumbSize)
return;
return trackElementsSize({
getNodes: () => dom.getElements(ctx2),
observeMutation: true,
callback(size) {
if (!size || isEqualSize(ctx2.thumbSize, size))
return;
ctx2.thumbSize = size;
}
});
}
},
actions: {
syncInputElements(ctx2) {
ctx2.value.forEach((value, index) => {
const inputEl = dom.getHiddenInputEl(ctx2, index);
dom.setValue(inputEl, value);
});
},
invokeOnChangeStart(ctx2) {
ctx2.onValueChangeStart?.({ value: ctx2.value });
},
invokeOnChangeEnd(ctx2) {
ctx2.onValueChangeEnd?.({ value: ctx2.value });
},
setClosestThumbIndex(ctx2, evt) {
const pointValue = dom.getValueFromPoint(ctx2, evt.point);
if (pointValue == null)
return;
const focusedIndex = getClosestIndex(ctx2, pointValue);
set.focusedIndex(ctx2, focusedIndex);
},
setFocusedIndex(ctx2, evt) {
set.focusedIndex(ctx2, evt.index);
},
clearFocusedIndex(ctx2) {
set.focusedIndex(ctx2, -1);
},
setPointerValue(ctx2, evt) {
const pointerValue = dom.getValueFromPoint(ctx2, evt.point);
if (pointerValue == null)
return;
const value = constrainValue(ctx2, pointerValue, ctx2.focusedIndex);
set.valueAtIndex(ctx2, ctx2.focusedIndex, value);
},
focusActiveThumb(ctx2) {
raf(() => {
const thumbEl = dom.getThumbEl(ctx2, ctx2.focusedIndex);
thumbEl?.focus({ preventScroll: true });
});
},
decrementAtIndex(ctx2, evt) {
const value = decrement(ctx2, evt.index, evt.step);
set.value(ctx2, value);
},
incrementAtIndex(ctx2, evt) {
const value = increment(ctx2, evt.index, evt.step);
set.value(ctx2, value);
},
setActiveThumbToMin(ctx2) {
const { min } = getRangeAtIndex(ctx2, ctx2.focusedIndex);
set.valueAtIndex(ctx2, ctx2.focusedIndex, min);
},
setActiveThumbToMax(ctx2) {
const { max } = getRangeAtIndex(ctx2, ctx2.focusedIndex);
set.valueAtIndex(ctx2, ctx2.focusedIndex, max);
},
coarseValue(ctx2) {
const value = normalizeValues(ctx2, ctx2.value);
set.value(ctx2, value);
},
setValueAtIndex(ctx2, evt) {
const value = constrainValue(ctx2, evt.value, evt.index);
set.valueAtIndex(ctx2, evt.index, value);
},
setValue(ctx2, evt) {
const value = normalizeValues(ctx2, evt.value);
set.value(ctx2, value);
}
}
}
);
}
var invoke = {
change: (ctx) => {
ctx.onValueChange?.({
value: Array.from(ctx.value)
});
dom.dispatchChangeEvent(ctx);
},
focusChange: (ctx) => {
ctx.onFocusChange?.({
value: Array.from(ctx.value),
focusedIndex: ctx.focusedIndex
});
}
};
var set = {
valueAtIndex: (ctx, index, value) => {
if (isEqual(ctx.value[index], value))
return;
ctx.value[index] = value;
invoke.change(ctx);
},
value: (ctx, value) => {
if (isEqual(ctx.value, value))
return;
assignArray(ctx.value, value);
invoke.change(ctx);
},
focusedIndex: (ctx, index) => {
if (isEqual(ctx.focusedIndex, index))
return;
ctx.focusedIndex = index;
invoke.focusChange(ctx);
}
};
export {
anatomy,
connect,
machine
};
//# sourceMappingURL=index.mjs.map