UNPKG

@zag-js/slider

Version:

Core logic for the slider widget implemented as a state machine

356 lines (355 loc) • 11.7 kB
// src/slider.machine.ts import { createMachine, memo } from "@zag-js/core"; import { raf, resizeObserverBorderBox, setElementValue, trackFormControl, trackPointerMove } from "@zag-js/dom-query"; import { callAll, clampValue, getValuePercent, getValueRanges, isEqual, isValueWithinRange, pick, setValueAtIndex, snapValueToStep } from "@zag-js/utils"; import * as dom from "./slider.dom.mjs"; import { constrainValue, decrement, getClosestIndex, getRangeAtIndex, increment, normalizeValues, resolveThumbCollision, selectMovableThumb } from "./slider.utils.mjs"; var isEqualSize = (a, b) => { return a?.width === b?.width && a?.height === b?.height; }; var normalize = (value, min, max, step, minStepsBetweenThumbs) => { const ranges = getValueRanges(value, min, max, minStepsBetweenThumbs * step); return ranges.map((range) => { const snapValue = snapValueToStep(range.value, range.min, range.max, step); const rangeValue = clampValue(snapValue, range.min, range.max); if (!isValueWithinRange(rangeValue, min, max)) { throw new Error( "[zag-js/slider] The configured `min`, `max`, `step` or `minStepsBetweenThumbs` values are invalid" ); } return rangeValue; }); }; var machine = createMachine({ props({ props }) { const min = props.min ?? 0; const max = props.max ?? 100; const step = props.step ?? 1; const defaultValue = props.defaultValue ?? [min]; const minStepsBetweenThumbs = props.minStepsBetweenThumbs ?? 0; return { dir: "ltr", thumbAlignment: "contain", origin: "start", orientation: "horizontal", thumbCollisionBehavior: "none", minStepsBetweenThumbs, ...props, largeStep: props.largeStep ?? 10 * step, defaultValue: normalize(defaultValue, min, max, step, minStepsBetweenThumbs), value: props.value ? normalize(props.value, min, max, step, minStepsBetweenThumbs) : void 0, max, step, min }; }, initialState() { return "idle"; }, context({ prop, bindable, getContext }) { return { thumbSize: bindable(() => ({ defaultValue: prop("thumbSize") || null })), value: bindable(() => ({ defaultValue: prop("defaultValue"), value: prop("value"), isEqual, hash(a) { return a.join(","); }, onChange(value) { prop("onValueChange")?.({ value }); } })), focusedIndex: bindable(() => ({ defaultValue: -1, onChange(value) { const ctx = getContext(); prop("onFocusChange")?.({ focusedIndex: value, value: ctx.get("value") }); } })), fieldsetDisabled: bindable(() => ({ defaultValue: false })) }; }, refs() { return { thumbDragOffset: null, thumbDragStartValue: null }; }, computed: { isHorizontal: ({ prop }) => prop("orientation") === "horizontal", isVertical: ({ prop }) => prop("orientation") === "vertical", isRtl: ({ prop }) => prop("orientation") === "horizontal" && prop("dir") === "rtl", isDisabled: ({ context, prop }) => !!prop("disabled") || context.get("fieldsetDisabled"), isInteractive: ({ prop, computed }) => !(prop("readOnly") || computed("isDisabled")), hasMeasuredThumbSize: ({ context }) => context.get("thumbSize") != null, valuePercent: memo( ({ context, prop }) => [context.get("value"), prop("min"), prop("max")], ([value, min, max]) => value.map((value2) => 100 * getValuePercent(value2, min, max)) ) }, watch({ track, action, context, computed, send }) { track([() => context.hash("value")], () => { action(["syncInputElements", "dispatchChangeEvent"]); }); track([() => computed("isDisabled")], () => { if (computed("isDisabled")) { send({ type: "POINTER_CANCEL" }); } }); }, effects: ["trackFormControlState", "trackThumbSize"], on: { SET_VALUE: [ { guard: "hasIndex", actions: ["setValueAtIndex", "invokeOnChangeEnd"] }, { actions: ["setValue", "invokeOnChangeEnd"] } ], INCREMENT: { actions: ["incrementThumbAtIndex", "invokeOnChangeEnd"] }, DECREMENT: { actions: ["decrementThumbAtIndex", "invokeOnChangeEnd"] } }, states: { idle: { on: { POINTER_DOWN: { target: "dragging", actions: ["setClosestThumbIndex", "setThumbDragStartValue", "setPointerValue", "focusActiveThumb"] }, FOCUS: { target: "focus", actions: ["setFocusedIndex"] }, THUMB_POINTER_DOWN: { target: "dragging", actions: ["setFocusedIndex", "setThumbDragOffset", "setThumbDragStartValue", "focusActiveThumb"] } } }, focus: { entry: ["focusActiveThumb"], on: { POINTER_DOWN: { target: "dragging", actions: ["setClosestThumbIndex", "setThumbDragStartValue", "setPointerValue", "focusActiveThumb"] }, THUMB_POINTER_DOWN: { target: "dragging", actions: ["setFocusedIndex", "setThumbDragOffset", "setThumbDragStartValue", "focusActiveThumb"] }, ARROW_DEC: { actions: ["decrementThumbAtIndex", "invokeOnChangeEnd"] }, ARROW_INC: { actions: ["incrementThumbAtIndex", "invokeOnChangeEnd"] }, HOME: { actions: ["setFocusedThumbToMin", "invokeOnChangeEnd"] }, END: { actions: ["setFocusedThumbToMax", "invokeOnChangeEnd"] }, BLUR: { target: "idle", actions: ["clearFocusedIndex"] } } }, dragging: { entry: ["focusActiveThumb"], effects: ["trackPointerMove"], on: { POINTER_UP: { target: "focus", actions: ["invokeOnChangeEnd", "clearThumbDragOffset", "clearThumbDragStartValue"] }, POINTER_MOVE: { actions: ["setPointerValue"] }, POINTER_CANCEL: { target: "idle", actions: ["clearFocusedIndex", "clearThumbDragOffset", "clearThumbDragStartValue"] } } } }, implementations: { guards: { hasIndex: ({ event }) => event.index != null }, effects: { trackFormControlState({ context, scope }) { return trackFormControl(dom.getRootEl(scope), { onFieldsetDisabledChange(disabled) { context.set("fieldsetDisabled", disabled); }, onFormReset() { context.set("value", context.initial("value")); } }); }, trackPointerMove({ scope, send }) { return trackPointerMove(scope.getDoc(), { onPointerMove(info) { send({ type: "POINTER_MOVE", point: info.point }); }, onPointerUp() { send({ type: "POINTER_UP" }); } }); }, trackThumbSize({ context, scope, prop }) { if (prop("thumbAlignment") !== "contain" || prop("thumbSize")) return; const exec = (el) => { const rect = dom.getOffsetRect(el); const size = pick(rect, ["width", "height"]); if (isEqualSize(context.get("thumbSize"), size)) return; context.set("thumbSize", size); }; const thumbEls = dom.getThumbEls(scope); thumbEls.forEach(exec); const cleanups = thumbEls.map((el) => resizeObserverBorderBox.observe(el, () => exec(el))); return callAll(...cleanups); } }, actions: { dispatchChangeEvent({ context, scope }) { dom.dispatchChangeEvent(scope, context.get("value")); }, syncInputElements({ context, scope }) { context.get("value").forEach((value, index) => { const inputEl = dom.getHiddenInputEl(scope, index); setElementValue(inputEl, value.toString()); }); }, invokeOnChangeEnd({ prop, context }) { queueMicrotask(() => { prop("onValueChangeEnd")?.({ value: context.get("value") }); }); }, setClosestThumbIndex(params) { const { context, event } = params; const pointValue = dom.getPointValue(params, event.point); if (pointValue == null) return; const focusedIndex = getClosestIndex(params, pointValue); context.set("focusedIndex", focusedIndex); }, setFocusedIndex(params) { const { context, event } = params; const movableIndex = selectMovableThumb(params, event.index); context.set("focusedIndex", movableIndex); }, clearFocusedIndex({ context }) { context.set("focusedIndex", -1); }, setThumbDragOffset(params) { const { refs, event } = params; refs.set("thumbDragOffset", event.offset ?? null); }, clearThumbDragOffset({ refs }) { refs.set("thumbDragOffset", null); }, setThumbDragStartValue({ refs, context }) { refs.set("thumbDragStartValue", context.get("value").slice()); }, clearThumbDragStartValue({ refs }) { refs.set("thumbDragStartValue", null); }, setPointerValue(params) { queueMicrotask(() => { const { context, event, prop, refs } = params; const pointValue = dom.getPointValue(params, event.point); if (pointValue == null) return; const focusedIndex = context.get("focusedIndex"); const startValues = refs.get("thumbDragStartValue"); const result = resolveThumbCollision( prop("thumbCollisionBehavior"), focusedIndex, pointValue, context.get("value"), prop("min"), prop("max"), prop("step"), prop("minStepsBetweenThumbs"), startValues?.[focusedIndex] ); if (result.swapped) { context.set("focusedIndex", result.index); } context.set("value", result.values); }); }, focusActiveThumb({ scope, context }) { raf(() => { const thumbEl = dom.getThumbEl(scope, context.get("focusedIndex")); thumbEl?.focus({ preventScroll: true }); }); }, decrementThumbAtIndex(params) { const { context, event } = params; const value = decrement(params, event.index, event.step); context.set("value", value); }, incrementThumbAtIndex(params) { const { context, event } = params; const value = increment(params, event.index, event.step); context.set("value", value); }, setFocusedThumbToMin(params) { const { context } = params; const index = context.get("focusedIndex"); const { min } = getRangeAtIndex(params, index); context.set("value", (prev) => setValueAtIndex(prev, index, min)); }, setFocusedThumbToMax(params) { const { context } = params; const index = context.get("focusedIndex"); const { max } = getRangeAtIndex(params, index); context.set("value", (prev) => setValueAtIndex(prev, index, max)); }, setValueAtIndex(params) { const { context, event } = params; const value = constrainValue(params, event.value, event.index); context.set("value", (prev) => setValueAtIndex(prev, event.index, value)); }, setValue(params) { const { context, event } = params; const value = normalizeValues(params, event.value); context.set("value", value); } } } }); export { machine };