UNPKG

@zag-js/range-slider

Version:

Core logic for the range-slider widget implemented as a state machine

742 lines (735 loc) • 24 kB
// 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