bootstrap-touchspin
Version:
A mobile and touch friendly input spinner component for Bootstrap 3, 4 & 5. jQuery plugin with IIFE/UMD builds for backward compatibility.
1,347 lines (1,345 loc) • 73.9 kB
JavaScript
"use strict";
var TouchSpinJQueryBootstrap4 = (() => {
// ../../core/dist/index.js
var TouchSpinCallableEvent = /* @__PURE__ */ ((TouchSpinCallableEvent2) => {
TouchSpinCallableEvent2["UPDATE_SETTINGS"] = "touchspin.updatesettings";
TouchSpinCallableEvent2["UP_ONCE"] = "touchspin.uponce";
TouchSpinCallableEvent2["DOWN_ONCE"] = "touchspin.downonce";
TouchSpinCallableEvent2["START_UP_SPIN"] = "touchspin.startupspin";
TouchSpinCallableEvent2["START_DOWN_SPIN"] = "touchspin.startdownspin";
TouchSpinCallableEvent2["STOP_SPIN"] = "touchspin.stopspin";
TouchSpinCallableEvent2["DESTROY"] = "touchspin.destroy";
return TouchSpinCallableEvent2;
})(TouchSpinCallableEvent || {});
var DEFAULTS = {
min: 0,
max: 100,
initval: "",
replacementval: "",
firstclickvalueifempty: null,
step: 1,
decimals: 0,
forcestepdivisibility: "round",
stepinterval: 100,
stepintervaldelay: 500,
verticalbuttons: false,
verticalup: "+",
verticaldown: "\u2212",
verticalupclass: null,
verticaldownclass: null,
focusablebuttons: false,
prefix: "",
postfix: "",
prefix_extraclass: "",
postfix_extraclass: "",
booster: true,
boostat: 10,
maxboostedstep: false,
mousewheel: true,
buttonup_class: null,
buttondown_class: null,
buttonup_txt: "+",
buttondown_txt: "−",
callback_before_calculation: (v) => v,
callback_after_calculation: (v) => v,
renderer: null
};
var INSTANCE_KEY = "_touchSpinCore";
var TouchSpinCore = class _TouchSpinCore {
/**
* @param inputEl The input element
* @param opts Partial settings
*/
constructor(inputEl, opts = {}) {
this._teardownCallbacks = [];
this._settingObservers = /* @__PURE__ */ new Map();
this._spinDelayTimeout = null;
this._spinIntervalTimer = null;
this._upButton = null;
this._originalAttributes = null;
this._downButton = null;
this._mutationObserver = null;
this._wrapper = null;
if (!inputEl || inputEl.nodeName !== "INPUT") {
throw new Error("TouchSpinCore requires an <input> element");
}
this.input = inputEl;
const dataAttrs = this._parseDataAttributes(inputEl);
const globalDefaults = typeof globalThis !== "undefined" && globalThis.TouchSpinDefaultOptions ? _TouchSpinCore.sanitizePartialSettings(
globalThis.TouchSpinDefaultOptions,
DEFAULTS
) : {};
this.settings = Object.assign({}, DEFAULTS, globalDefaults, dataAttrs, opts);
this._sanitizeSettings();
this._currentStepSize = this.settings.step || 1;
if (!this.settings.renderer) {
const g = globalThis;
if (g == null ? void 0 : g.TouchSpinDefaultRenderer) {
this.settings.renderer = g.TouchSpinDefaultRenderer;
} else {
console.warn(
"TouchSpin: No renderer specified (renderer: null). Only keyboard/wheel events will work. Consider using Bootstrap3/4/5Renderer or TailwindRenderer for UI."
);
}
}
this.spinning = false;
this.spincount = 0;
this.direction = false;
this._teardownCallbacks = [];
this._settingObservers = /* @__PURE__ */ new Map();
this._spinDelayTimeout = null;
this._spinIntervalTimer = null;
this._upButton = null;
this._downButton = null;
this._wrapper = null;
this._handleUpMouseDown = this._handleUpMouseDown.bind(this);
this._handleDownMouseDown = this._handleDownMouseDown.bind(this);
this._handleMouseUp = this._handleMouseUp.bind(this);
this._handleUpKeyDown = this._handleUpKeyDown.bind(this);
this._handleUpKeyUp = this._handleUpKeyUp.bind(this);
this._handleDownKeyDown = this._handleDownKeyDown.bind(this);
this._handleDownKeyUp = this._handleDownKeyUp.bind(this);
this._handleWindowChangeCapture = this._handleWindowChangeCapture.bind(this);
this._handleKeyDown = this._handleKeyDown.bind(this);
this._handleKeyUp = this._handleKeyUp.bind(this);
this._handleWheel = this._handleWheel.bind(this);
this._initializeInput();
if (this.settings.renderer) {
const Ctor = this.settings.renderer;
this.renderer = new Ctor(
inputEl,
this.settings,
this
);
this.renderer.init();
}
this._setupMutationObserver();
if (this.renderer) {
this.renderer.finalizeWrapperAttributes();
}
this.input.setAttribute("data-touchspin-injected", "input");
}
/**
* Sanitize a partial settings object BEFORE applying it.
* Returns a new object with only provided keys normalized.
* @param {Partial<TouchSpinCoreOptions>} partial
* @param {TouchSpinCoreOptions} current
* @returns {Partial<TouchSpinCoreOptions>}
*/
static sanitizePartialSettings(partial, _current) {
const out = { ...partial };
if (Object.hasOwn(partial, "step")) {
const n = Number(partial.step);
out.step = Number.isFinite(n) && n > 0 ? n : 1;
}
if (Object.hasOwn(partial, "decimals")) {
const n = Number(partial.decimals);
out.decimals = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
}
const hasMin = Object.hasOwn(partial, "min");
const hasMax = Object.hasOwn(partial, "max");
if (hasMin) {
if (partial.min === null || partial.min === void 0 || typeof partial.min === "string" && partial.min === "") {
out.min = null;
} else {
const n = Number(partial.min);
out.min = Number.isFinite(n) ? n : null;
}
}
if (hasMax) {
if (partial.max === null || partial.max === void 0 || typeof partial.max === "string" && partial.max === "") {
out.max = null;
} else {
const n = Number(partial.max);
out.max = Number.isFinite(n) ? n : null;
}
}
if (hasMin && hasMax && out.min != null && out.max != null && typeof out.min === "number" && typeof out.max === "number" && out.min > out.max) {
const tmp = out.min;
out.min = out.max;
out.max = tmp;
}
if (Object.hasOwn(partial, "stepinterval")) {
const n = Number(partial.stepinterval);
out.stepinterval = Number.isFinite(n) && n >= 0 ? n : DEFAULTS.stepinterval;
}
if (Object.hasOwn(partial, "stepintervaldelay")) {
const n = Number(partial.stepintervaldelay);
out.stepintervaldelay = Number.isFinite(n) && n >= 0 ? n : DEFAULTS.stepintervaldelay;
}
return out;
}
/**
* Initialize input element (core always handles this)
* @private
*/
_initializeInput() {
var _a;
this._captureOriginalAttributes();
const initVal = (_a = this.settings.initval) != null ? _a : "";
if (initVal !== "" && this.input.value === "") {
this.input.value = String(initVal);
}
this._updateAriaAttributes();
this._syncNativeAttributes();
this._checkValue(false);
}
/**
* Normalize and validate settings: coerce invalid values to safe defaults.
* - step: > 0 number, otherwise 1
* - decimals: integer >= 0, otherwise 0
* - min/max: finite numbers or null
* - stepinterval/stepintervaldelay: integers >= 0 (fallback to defaults if invalid)
* @private
*/
_sanitizeSettings() {
const stepNum = Number(this.settings.step);
if (!Number.isFinite(stepNum) || stepNum <= 0) {
this.settings.step = 1;
} else {
this.settings.step = stepNum;
}
const decNum = Number(this.settings.decimals);
if (!Number.isFinite(decNum) || decNum < 0) {
this.settings.decimals = 0;
} else {
this.settings.decimals = Math.floor(decNum);
}
if (this.settings.min === null || this.settings.min === void 0 || typeof this.settings.min === "string" && this.settings.min === "") {
this.settings.min = null;
} else {
const minNum = Number(this.settings.min);
this.settings.min = Number.isFinite(minNum) ? minNum : null;
}
if (this.settings.max === null || this.settings.max === void 0 || typeof this.settings.max === "string" && this.settings.max === "") {
this.settings.max = null;
} else {
const maxNum = Number(this.settings.max);
this.settings.max = Number.isFinite(maxNum) ? maxNum : null;
}
if (this.settings.min !== null && this.settings.max !== null && this.settings.min > this.settings.max) {
const tmp = this.settings.min;
this.settings.min = this.settings.max;
this.settings.max = tmp;
}
const si = Number(this.settings.stepinterval);
if (!Number.isFinite(si) || si < 0) this.settings.stepinterval = DEFAULTS.stepinterval;
const sid = Number(this.settings.stepintervaldelay);
if (!Number.isFinite(sid) || sid < 0)
this.settings.stepintervaldelay = DEFAULTS.stepintervaldelay;
this._validateCallbacks();
this._checkCallbackPairing();
}
/**
* Validate callbacks and automatically convert number inputs to text inputs
* when formatting callbacks that add non-numeric characters are detected.
* @private
*/
_validateCallbacks() {
var _a, _b, _c;
const currentType = this.input.getAttribute("type");
if (currentType !== "number") return;
const defaultCallback = (v) => v;
if (!this.settings.callback_after_calculation || this.settings.callback_after_calculation.toString() === defaultCallback.toString())
return;
const testValue = "123.45";
const afterResult = this.settings.callback_after_calculation(testValue);
if (!/^-?\d*\.?\d*$/.test(afterResult)) {
console.warn(
'TouchSpin: Detected formatting callback that adds non-numeric characters. Converting input from type="number" to type="text" to support formatting like "' + afterResult + '". This ensures compatibility with custom formatting while maintaining full TouchSpin functionality. The original type will be restored when TouchSpin is destroyed.'
);
this._captureOriginalAttributes();
this.input.setAttribute("type", "text");
const step = (_a = this.settings.step) != null ? _a : 1;
const decimalsSetting = (_b = this.settings.decimals) != null ? _b : 0;
const hasDecimals = decimalsSetting > 0 || step % 1 !== 0;
const minSetting = (_c = this.settings.min) != null ? _c : null;
const allowNegative = minSetting === null || typeof minSetting === "number" && minSetting < 0;
if (!this.input.hasAttribute("inputmode")) {
const inputMode = hasDecimals || allowNegative ? "decimal" : "numeric";
this.input.setAttribute("inputmode", inputMode);
}
["min", "max", "step"].forEach((attr) => {
var _a2, _b2;
const originalValue = (_b2 = (_a2 = this._originalAttributes) == null ? void 0 : _a2.attributes.get(attr)) != null ? _b2 : null;
if (originalValue === null) {
this.input.removeAttribute(attr);
}
});
}
}
/**
* Capture the original attributes of the input before TouchSpin modifies them.
* This ensures complete transparency - the input can be restored to its exact original state.
* @private
*/
_captureOriginalAttributes() {
if (this._originalAttributes !== null) return;
const attributesToTrack = [
"role",
"aria-valuemin",
"aria-valuemax",
"aria-valuenow",
"aria-valuetext",
"min",
"max",
"step",
"inputmode"
];
this._originalAttributes = {
type: this.input.getAttribute("type"),
attributes: /* @__PURE__ */ new Map()
};
attributesToTrack.forEach((attr) => {
var _a;
(_a = this._originalAttributes) == null ? void 0 : _a.attributes.set(attr, this.input.getAttribute(attr));
});
}
/**
* Restore the input to its original state by restoring all original attributes.
* This ensures complete transparency - the input returns to its exact original state.
* @private
*/
_restoreOriginalAttributes() {
if (this._originalAttributes === null) return;
const currentValue = this.input.value;
if (currentValue && this.settings.callback_before_calculation) {
const rawValue = this.settings.callback_before_calculation(currentValue);
this.input.value = rawValue;
}
if (this._originalAttributes.type) {
this.input.setAttribute("type", this._originalAttributes.type);
}
this._originalAttributes.attributes.forEach((originalValue, attrName) => {
if (originalValue === null) {
this.input.removeAttribute(attrName);
} else {
this.input.setAttribute(attrName, originalValue);
}
});
this._originalAttributes = null;
}
/**
* Parse data-bts-* attributes from the input element.
* @param {HTMLInputElement} inputEl
* @returns {Partial<TouchSpinCoreOptions>}
* @private
*/
_parseDataAttributes(inputEl) {
const attributeMap = {
min: "min",
max: "max",
initval: "init-val",
replacementval: "replacement-val",
firstclickvalueifempty: "first-click-value-if-empty",
step: "step",
decimals: "decimals",
stepinterval: "step-interval",
verticalbuttons: "vertical-buttons",
verticalup: "vertical-up",
verticaldown: "vertical-down",
verticalupclass: "vertical-up-class",
verticaldownclass: "vertical-down-class",
forcestepdivisibility: "force-step-divisibility",
stepintervaldelay: "step-interval-delay",
prefix: "prefix",
postfix: "postfix",
prefix_extraclass: "prefix-extra-class",
postfix_extraclass: "postfix-extra-class",
booster: "booster",
boostat: "boostat",
maxboostedstep: "max-boosted-step",
mousewheel: "mouse-wheel",
buttondown_class: "button-down-class",
buttonup_class: "button-up-class",
buttondown_txt: "button-down-txt",
buttonup_txt: "button-up-txt"
};
const parsed = {};
for (const [optionName, attrName] of Object.entries(attributeMap)) {
const fullAttrName = `data-bts-${attrName}`;
if (inputEl.hasAttribute(fullAttrName)) {
const rawValue = inputEl.getAttribute(fullAttrName);
parsed[optionName] = this._coerceAttributeValue(optionName, rawValue != null ? rawValue : "");
}
}
for (const nativeAttr of ["min", "max", "step"]) {
if (inputEl.hasAttribute(nativeAttr)) {
const rawValue = inputEl.getAttribute(nativeAttr);
if (parsed[nativeAttr] !== void 0) {
console.warn(
`Both "data-bts-${nativeAttr}" and "${nativeAttr}" attributes specified. Native attribute takes precedence.`,
inputEl
);
}
parsed[nativeAttr] = this._coerceAttributeValue(
nativeAttr,
rawValue != null ? rawValue : ""
);
}
}
return parsed;
}
/**
* Convert string attribute values to appropriate types.
* @param {string} optionName
* @param {string} rawValue
* @returns {any}
* @private
*/
_coerceAttributeValue(optionName, rawValue) {
if (rawValue === null || rawValue === void 0) {
return rawValue;
}
if (["booster", "mousewheel", "verticalbuttons", "focusablebuttons"].includes(optionName)) {
return rawValue === "true" || rawValue === "" || rawValue === optionName;
}
if ([
"min",
"max",
"step",
"decimals",
"stepinterval",
"stepintervaldelay",
"boostat",
"maxboostedstep",
"firstclickvalueifempty"
].includes(optionName)) {
const num = parseFloat(rawValue);
return Number.isNaN(num) ? rawValue : num;
}
return rawValue;
}
/** Increment once according to step */
upOnce() {
if (this.input.disabled || this.input.hasAttribute("readonly")) {
return;
}
const v = this.getValue();
const next = this._nextValue("up", v);
if (this.settings.max !== null && v === this.settings.max) {
this.emit("max");
if (this.spinning && this.direction === "up") {
this.stopSpin();
}
return;
}
if (this.settings.max !== null && next === this.settings.max) {
this.emit("max");
if (this.spinning && this.direction === "up") {
this.stopSpin();
}
}
this._setDisplay(next, true);
}
/** Decrement once according to step */
downOnce() {
if (this.input.disabled || this.input.hasAttribute("readonly")) {
return;
}
const v = this.getValue();
const next = this._nextValue("down", v);
if (this.settings.min !== null && v === this.settings.min) {
this.emit("min");
if (this.spinning && this.direction === "down") {
this.stopSpin();
}
return;
}
if (this.settings.min !== null && next === this.settings.min) {
this.emit("min");
if (this.spinning && this.direction === "down") {
this.stopSpin();
}
}
this._setDisplay(next, true);
}
/** Start increasing repeatedly; no immediate step here. */
startUpSpin() {
const currentValue = this.getValue();
if (this.settings.max !== null && currentValue === this.settings.max) {
this.emit("max");
return;
}
this._startSpin("up");
}
/** Start decreasing repeatedly; no immediate step here. */
startDownSpin() {
const currentValue = this.getValue();
if (this.settings.min !== null && currentValue === this.settings.min) {
this.emit("min");
return;
}
this._startSpin("down");
}
/** Stop spinning (placeholder) */
stopSpin() {
this._clearSpinTimers();
if (this.spinning) {
if (this.direction === "up") {
this.emit("stopupspin");
this.emit("stopspin");
} else if (this.direction === "down") {
this.emit("stopdownspin");
this.emit("stopspin");
}
}
this.spinning = false;
this.direction = false;
this.spincount = 0;
this._currentStepSize = this.settings.step || 1;
}
updateSettings(opts) {
const oldSettings = { ...this.settings };
const newSettings = opts || {};
const sanitizedPartial = _TouchSpinCore.sanitizePartialSettings(newSettings, oldSettings);
Object.assign(this.settings, sanitizedPartial);
this._sanitizeSettings();
const step = Number(this.settings.step || 1);
if ((sanitizedPartial.step !== void 0 || sanitizedPartial.min !== void 0 || sanitizedPartial.max !== void 0) && step !== 1) {
if (this.settings.max !== null) {
this.settings.max = this._alignToStep(Number(this.settings.max), step, "down");
}
if (this.settings.min !== null) {
this.settings.min = this._alignToStep(Number(this.settings.min), step, "up");
}
}
Object.keys(this.settings).forEach((key) => {
if (oldSettings[key] !== this.settings[key]) {
const observers = this._settingObservers.get(String(key));
if (observers) {
observers.forEach((callback) => {
try {
callback(this.settings[key], oldSettings[key]);
} catch (error) {
console.error("TouchSpin: Error in setting observer callback:", error);
}
});
}
}
});
this._updateAriaAttributes();
this._syncNativeAttributes();
this._checkValue(true);
this._currentStepSize = this.settings.step || 1;
this._checkCallbackPairing();
}
getValue() {
var _a;
let raw = this.input.value;
const repl = (_a = this.settings.replacementval) != null ? _a : "";
if (raw === "" && repl !== "") {
raw = String(repl);
}
if (raw === "") return NaN;
const before = this.settings.callback_before_calculation || ((v) => v);
const num = parseFloat(before(String(raw)));
return Number.isNaN(num) ? NaN : num;
}
setValue(v) {
if (this.input.disabled || this.input.hasAttribute("readonly")) return;
const parsed = Number(v);
if (!Number.isFinite(parsed)) return;
const adjusted = this._applyConstraints(parsed);
const wasSanitized = parsed !== adjusted;
this._setDisplay(adjusted, true, wasSanitized, false);
}
/**
* Initialize DOM event handling by finding elements and attaching listeners.
* Must be called after the renderer has created the DOM structure.
*/
initDOMEventHandling() {
this._findDOMElements();
this._attachDOMEventListeners();
}
/**
* Register a teardown callback that will be called when the instance is destroyed.
* This allows wrapper libraries to register cleanup logic.
* @param {Function} callback - Function to call on destroy
* @returns {Function} - Unregister function
*/
registerTeardown(callback) {
if (typeof callback !== "function") {
throw new Error("Teardown callback must be a function");
}
this._teardownCallbacks.push(callback);
return () => {
const index = this._teardownCallbacks.indexOf(callback);
if (index > -1) {
this._teardownCallbacks.splice(index, 1);
}
};
}
/** Cleanup and destroy the TouchSpin instance */
destroy() {
var _a;
this.input.removeAttribute("data-touchspin-injected");
this.stopSpin();
if ((_a = this.renderer) == null ? void 0 : _a.teardown) {
this.renderer.teardown();
}
this._detachDOMEventListeners();
this._teardownCallbacks.forEach((callback) => {
try {
callback();
} catch (error) {
console.error("TouchSpin teardown callback error:", error);
}
});
this._teardownCallbacks.length = 0;
this._settingObservers.clear();
if (this._mutationObserver) {
this._mutationObserver.disconnect();
this._mutationObserver = null;
}
this._upButton = null;
this._downButton = null;
this._restoreOriginalAttributes();
const inst = this.input[INSTANCE_KEY];
if (inst && inst === this) {
delete this.input[INSTANCE_KEY];
}
}
/**
* Create a plain public API object with bound methods for wrappers.
* @returns {TouchSpinCorePublicAPI}
*/
toPublicApi() {
return {
upOnce: this.upOnce.bind(this),
downOnce: this.downOnce.bind(this),
startUpSpin: this.startUpSpin.bind(this),
startDownSpin: this.startDownSpin.bind(this),
stopSpin: this.stopSpin.bind(this),
updateSettings: this.updateSettings.bind(this),
getValue: this.getValue.bind(this),
setValue: this.setValue.bind(this),
destroy: this.destroy.bind(this),
initDOMEventHandling: this.initDOMEventHandling.bind(this),
registerTeardown: this.registerTeardown.bind(this),
attachUpEvents: this.attachUpEvents.bind(this),
attachDownEvents: this.attachDownEvents.bind(this),
observeSetting: this.observeSetting.bind(this)
};
}
// --- Renderer Event Attachment Methods ---
/**
* Attach up button events to an element
* Called by renderers after creating up button
* @param {HTMLElement|null} element - The element to attach events to
*/
attachUpEvents(element) {
if (!element) {
console.warn("TouchSpin: attachUpEvents called with null element");
return;
}
this._upButton = element;
element.addEventListener("mousedown", this._handleUpMouseDown);
element.addEventListener("touchstart", this._handleUpMouseDown, { passive: false });
if (this.settings.focusablebuttons) {
element.addEventListener("keydown", this._handleUpKeyDown);
element.addEventListener("keyup", this._handleUpKeyUp);
}
this._updateButtonDisabledState();
}
/**
* Attach down button events to an element
* Called by renderers after creating down button
* @param {HTMLElement|null} element - The element to attach events to
*/
attachDownEvents(element) {
if (!element) {
console.warn("TouchSpin: attachDownEvents called with null element");
return;
}
this._downButton = element;
element.addEventListener("mousedown", this._handleDownMouseDown);
element.addEventListener("touchstart", this._handleDownMouseDown, { passive: false });
if (this.settings.focusablebuttons) {
element.addEventListener("keydown", this._handleDownKeyDown);
element.addEventListener("keyup", this._handleDownKeyUp);
}
this._updateButtonDisabledState();
}
// --- Settings Observer Pattern ---
/**
* Allow renderers to observe setting changes
* @param {string} settingName - Name of setting to observe
* @param {Function} callback - Function to call when setting changes (newValue, oldValue)
* @returns {Function} Unsubscribe function
*/
observeSetting(settingName, callback) {
const key = String(settingName);
if (!this._settingObservers.has(key)) {
this._settingObservers.set(key, /* @__PURE__ */ new Set());
}
const observers = this._settingObservers.get(key);
observers.add(callback);
return () => observers.delete(callback);
}
// --- Minimal internal emitter API ---
/**
* Emit a core event as DOM CustomEvent (matching original jQuery plugin behavior)
* @param event - Event name
* @param detail - Event detail data (can be modified for speedchange events)
* @param cancelable - Whether the event can be canceled (default: false)
* @returns Whether the event was prevented (only meaningful for cancelable events)
*
* Cancelable events include:
* - 'startupspin': emitted before starting upward spinning, can be prevented
* - 'startdownspin': emitted before starting downward spinning, can be prevented
* - 'speedchange': emitted before step size increases, can be prevented or modified
*/
emit(event, detail, cancelable = false) {
const domEventName = `touchspin.on.${event}`;
const customEvent = new CustomEvent(domEventName, {
detail,
bubbles: true,
cancelable
});
const wasPrevented = !this.input.dispatchEvent(customEvent);
return wasPrevented;
}
/**
* Internal: start timed spin in a direction with initial step, delay, then interval.
* @param {'up'|'down'} dir
*/
_startSpin(dir) {
if (this.input.disabled || this.input.hasAttribute("readonly")) return;
if (this.spinning && this.direction === dir) {
return;
}
if (this.spinning && this.direction !== dir) {
this.stopSpin();
}
const direction_changed = !this.spinning || this.direction !== dir;
if (direction_changed) {
this.spinning = true;
this.direction = dir;
this.spincount = 0;
this._currentStepSize = this.settings.step || 1;
this.emit("startspin");
if (dir === "up") {
if (this.emit("startupspin", void 0, true)) {
this.spinning = false;
this.direction = false;
return;
}
} else {
if (this.emit("startdownspin", void 0, true)) {
this.spinning = false;
this.direction = false;
return;
}
}
}
if (dir === "up") this.upOnce();
else this.downOnce();
const v = this.getValue();
if (dir === "up" && this.settings.max !== null && v === this.settings.max) {
return;
}
if (dir === "down" && this.settings.min !== null && v === this.settings.min) {
return;
}
this._clearSpinTimers();
const delay = this.settings.stepintervaldelay || 500;
const interval = this.settings.stepinterval || 100;
this._spinDelayTimeout = setTimeout(() => {
this._spinDelayTimeout = null;
this._spinIntervalTimer = setInterval(() => {
if (!this.spinning || this.direction !== dir) return;
this._spinStep(dir);
}, interval);
}, delay);
}
_clearSpinTimers() {
try {
if (this._spinDelayTimeout) {
clearTimeout(this._spinDelayTimeout);
}
} catch (e) {
}
try {
if (this._spinIntervalTimer) {
clearInterval(this._spinIntervalTimer);
}
} catch (e) {
}
this._spinDelayTimeout = null;
this._spinIntervalTimer = null;
}
/**
* Compute the next numeric value for a direction, respecting step, booster and bounds.
* @param {'up'|'down'} dir
* @param {number} current
*/
_nextValue(dir, current) {
var _a;
let v = current;
if (Number.isNaN(v)) {
v = this._valueIfIsNaN();
} else {
const base = this.settings.step || 1;
const mbs = this.settings.maxboostedstep;
let stepCandidate = base;
if (this.settings.booster) {
const boostat = Math.max(1, parseInt(String(this.settings.boostat || 10), 10));
stepCandidate = 2 ** Math.floor(this.spincount / boostat) * base;
}
let step = stepCandidate;
if (mbs && Number.isFinite(mbs) && stepCandidate > Number(mbs)) {
step = Number(mbs);
v = Math.round(v / step) * step;
}
step = Math.max(base, step);
const currentSize = (_a = this._currentStepSize) != null ? _a : base;
if (step < currentSize) {
step = currentSize;
}
if (step > this._currentStepSize) {
const currentLevel = Math.floor(Math.log2(this._currentStepSize / base));
const newLevel = Math.floor(Math.log2(step / base));
const eventData = {
currentLevel,
newLevel,
currentStep: this._currentStepSize,
newStep: step,
direction: dir
};
if (this.emit("speedchange", eventData, true)) {
step = this._currentStepSize;
} else {
const modifiedStep = Number(eventData.newStep);
if (Number.isFinite(modifiedStep) && modifiedStep >= this.settings.step) {
step = Math.min(modifiedStep, this.settings.maxboostedstep || Number.MAX_SAFE_INTEGER);
} else {
step = this._currentStepSize;
}
}
}
this._currentStepSize = step;
v = dir === "up" ? v + step : v - step;
}
return this._applyConstraints(v);
}
/** Returns a reasonable value to use when current is NaN. */
_valueIfIsNaN() {
if (typeof this.settings.firstclickvalueifempty === "number") {
return this.settings.firstclickvalueifempty;
}
const min = typeof this.settings.min === "number" ? this.settings.min : 0;
const max = typeof this.settings.max === "number" ? this.settings.max : min;
return (min + max) / 2;
}
/** Apply step divisibility and clamp to min/max. */
_applyConstraints(v) {
var _a, _b;
const aligned = this._forcestepdivisibility(v);
const min = (_a = this.settings.min) != null ? _a : null;
const max = (_b = this.settings.max) != null ? _b : null;
let clamped = aligned;
if (typeof min === "number" && clamped < min) clamped = min;
if (typeof max === "number" && clamped > max) clamped = max;
return clamped;
}
/** Determine the effective step with booster if enabled. */
_getBoostedStep() {
const base = this.settings.step || 1;
if (!this.settings.booster) return base;
const boostat = Math.max(1, parseInt(String(this.settings.boostat || 10), 10));
let boosted = 2 ** Math.floor(this.spincount / boostat) * base;
const mbs = this.settings.maxboostedstep;
if (mbs && Number.isFinite(mbs)) {
const cap = Number(mbs);
if (boosted > cap) boosted = cap;
}
return Math.max(base, boosted);
}
/** Aligns value to step per forcestepdivisibility. */
_forcestepdivisibility(val) {
const mode = this.settings.forcestepdivisibility || "round";
const step = this.settings.step || 1;
const dec = this.settings.decimals || 0;
let out;
switch (mode) {
case "floor":
out = Math.floor(val / step) * step;
break;
case "ceil":
out = Math.ceil(val / step) * step;
break;
case "none":
out = val;
break;
default:
out = Math.round(val / step) * step;
break;
}
const result = Number(out.toFixed(dec));
return result;
}
/** Aligns a value to nearest step boundary using integer arithmetic. */
_alignToStep(val, step, dir) {
if (step === 0) return val;
let k = 1;
const s = step;
while (s * k % 1 !== 0 && k < 1e6) k *= 10;
const V = Math.round(val * k);
const S = Math.round(step * k);
const r = V % S;
if (r === 0) return val;
return (dir === "down" ? V - r : V + (S - r)) / k;
}
/** Format and write to input, optionally emit change if different. */
_setDisplay(num, mayTriggerChange, forceTrigger = false, onlyTriggerIfSanitized = false) {
var _a;
const prev = String((_a = this.input.value) != null ? _a : "");
const next = this._formatDisplay(num);
this.input.value = next;
this._updateAriaAttributes();
if (mayTriggerChange && (onlyTriggerIfSanitized ? forceTrigger : forceTrigger || prev !== next)) {
this.input.dispatchEvent(new Event("change", { bubbles: true }));
}
return next;
}
_formatDisplay(num) {
const dec = this.settings.decimals || 0;
const after = this.settings.callback_after_calculation || ((v) => v);
const s = Number(num).toFixed(dec);
return after(s);
}
/**
* Perform one spin step in a direction while tracking spincount for booster.
* @param {'up'|'down'} dir
*/
_spinStep(dir) {
this.spincount++;
if (dir === "up") this.upOnce();
else this.downOnce();
}
/** Sanitize current input value and update display; optionally emits change. */
_checkValue(mayTriggerChange) {
const v = this.getValue();
if (!Number.isFinite(v)) return;
const adjusted = this._applyConstraints(v);
const wasSanitized = v !== adjusted;
this._setDisplay(adjusted, !!mayTriggerChange, wasSanitized);
}
_updateAriaAttributes() {
var _a, _b;
const el = this.input;
if (el.getAttribute("role") !== "spinbutton") {
el.setAttribute("role", "spinbutton");
}
const min = (_a = this.settings.min) != null ? _a : null;
const max = (_b = this.settings.max) != null ? _b : null;
if (typeof min === "number") el.setAttribute("aria-valuemin", String(min));
else el.removeAttribute("aria-valuemin");
if (typeof max === "number") el.setAttribute("aria-valuemax", String(max));
else el.removeAttribute("aria-valuemax");
const raw = el.value;
const before = this.settings.callback_before_calculation || ((v) => v);
const num = parseFloat(before(String(raw)));
if (Number.isFinite(num)) el.setAttribute("aria-valuenow", String(num));
else el.removeAttribute("aria-valuenow");
el.setAttribute("aria-valuetext", String(raw));
}
/**
* Synchronize TouchSpin settings to native input attributes.
* Only applies to type="number" inputs to maintain browser consistency.
* @private
*/
_syncNativeAttributes() {
var _a, _b;
if (this.input.getAttribute("type") === "number") {
const min = (_a = this.settings.min) != null ? _a : null;
if (typeof min === "number" && Number.isFinite(min)) {
this.input.setAttribute("min", String(min));
} else {
this.input.removeAttribute("min");
}
const max = (_b = this.settings.max) != null ? _b : null;
if (typeof max === "number" && Number.isFinite(max)) {
this.input.setAttribute("max", String(max));
} else {
this.input.removeAttribute("max");
}
const step = this.settings.step;
if (typeof step === "number" && Number.isFinite(step) && step > 0) {
this.input.setAttribute("step", String(step));
} else {
this.input.removeAttribute("step");
}
}
}
/**
* Update TouchSpin settings from native attribute changes.
* Called by mutation observer when min/max/step attributes change.
* @private
*/
_syncSettingsFromNativeAttributes() {
const nativeMin = this.input.getAttribute("min");
const nativeMax = this.input.getAttribute("max");
const nativeStep = this.input.getAttribute("step");
let needsUpdate = false;
const newSettings = {};
if (nativeMin !== null) {
const parsedMin = nativeMin === "" ? null : parseFloat(nativeMin);
const minNum = parsedMin !== null && Number.isFinite(parsedMin) ? parsedMin : null;
if (minNum !== this.settings.min) {
newSettings.min = minNum;
needsUpdate = true;
}
} else if (this.settings.min !== null) {
newSettings.min = null;
needsUpdate = true;
}
if (nativeMax !== null) {
const parsedMax = nativeMax === "" ? null : parseFloat(nativeMax);
const maxNum = parsedMax !== null && Number.isFinite(parsedMax) ? parsedMax : null;
if (maxNum !== this.settings.max) {
newSettings.max = maxNum;
needsUpdate = true;
}
} else if (this.settings.max !== null) {
newSettings.max = null;
needsUpdate = true;
}
if (nativeStep !== null) {
const parsedStep = nativeStep === "" ? void 0 : parseFloat(nativeStep);
const stepNum = parsedStep !== void 0 && Number.isFinite(parsedStep) && parsedStep > 0 ? parsedStep : void 0;
if (stepNum !== this.settings.step) {
newSettings.step = stepNum != null ? stepNum : 1;
needsUpdate = true;
}
} else if (this.settings.step !== 1) {
newSettings.step = 1;
needsUpdate = true;
}
if (needsUpdate) {
this.updateSettings(newSettings);
}
}
// --- DOM Event Handling Methods ---
/**
* Find and store references to DOM elements using data-touchspin-injected attributes.
* @private
*/
_findDOMElements() {
let wrapper = this.input.parentElement;
while (wrapper && !wrapper.hasAttribute("data-touchspin-injected")) {
wrapper = wrapper.parentElement;
}
this._wrapper = wrapper;
}
/**
* Attach DOM event listeners to elements.
* @private
*/
_attachDOMEventListeners() {
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("mouseleave", this._handleMouseUp);
document.addEventListener("touchend", this._handleMouseUp);
window.addEventListener("change", this._handleWindowChangeCapture, true);
this.input.addEventListener("keydown", this._handleKeyDown);
this.input.addEventListener("keyup", this._handleKeyUp);
this.input.addEventListener("wheel", this._handleWheel);
}
/**
* Remove DOM event listeners.
* @private
*/
_detachDOMEventListeners() {
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("mouseleave", this._handleMouseUp);
document.removeEventListener("touchend", this._handleMouseUp);
window.removeEventListener("change", this._handleWindowChangeCapture, true);
this.input.removeEventListener("keydown", this._handleKeyDown);
this.input.removeEventListener("keyup", this._handleKeyUp);
this.input.removeEventListener("wheel", this._handleWheel);
}
// --- DOM Event Handlers ---
/**
* Handle mousedown/touchstart on up button.
* @private
*/
_handleUpMouseDown(e) {
e.preventDefault();
this.startUpSpin();
}
/**
* Handle mousedown/touchstart on down button.
* @private
*/
_handleDownMouseDown(e) {
e.preventDefault();
this.startDownSpin();
}
/**
* Handle mouseup/touchend/mouseleave to stop spinning.
* @private
*/
_handleMouseUp(_e) {
this.stopSpin();
}
/**
* Handle keydown events on up button.
* @private
*/
_handleUpKeyDown(e) {
if (e.keyCode === 13 || e.keyCode === 32) {
e.preventDefault();
if (e.repeat) return;
this.startUpSpin();
}
}
/**
* Handle keyup events on up button.
* @private
*/
_handleUpKeyUp(e) {
if (e.keyCode === 13 || e.keyCode === 32) {
this.stopSpin();
}
}
/**
* Handle keydown events on down button.
* @private
*/
_handleDownKeyDown(e) {
if (e.keyCode === 13 || e.keyCode === 32) {
e.preventDefault();
if (e.repeat) return;
this.startDownSpin();
}
}
/**
* Handle keyup events on down button.
* @private
*/
_handleDownKeyUp(e) {
if (e.keyCode === 13 || e.keyCode === 32) {
this.stopSpin();
}
}
/**
* Sanitize value before other capture listeners observe unsanitized input.
* @private
*/
_handleWindowChangeCapture(e) {
const target = e.target;
if (!target || target !== this.input) return;
const currentValue = this.getValue();
if (!Number.isFinite(currentValue)) return;
const sanitized = this._applyConstraints(currentValue);
if (sanitized !== currentValue) {
this._setDisplay(sanitized, false);
}
}
/**
* Handle keydown events on the input element.
* @private
*/
_handleKeyDown(e) {
switch (e.key) {
case "ArrowUp":
e.preventDefault();
if (e.repeat) return;
this.startUpSpin();
break;
case "ArrowDown":
e.preventDefault();
if (e.repeat) return;
this.startDownSpin();
break;
case "Enter":
this._checkValue(false);
break;
}
}
/**
* Handle keyup events on the input element.
* @private
*/
_handleKeyUp(e) {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
this.stopSpin();
}
}
/**
* Handle wheel events on the input element.
* @private
*/
_handleWheel(e) {
if (!this.settings.mousewheel) {
return;
}
if (document.activeElement === this.input) {
e.preventDefault();
if (e.deltaY < 0) {
this.upOnce();
} else if (e.deltaY > 0) {
this.downOnce();
}
}
}
/**
* Set up mutation observer to watch for disabled/readonly attribute changes
* @private
*/
_setupMutationObserver() {
if (typeof MutationObserver !== "undefined") {
this._mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes") {
if (mutation.attributeName === "disabled" || mutation.attributeName === "readonly") {
this._updateButtonDisabledState();
} else if (mutation.attributeName === "min" || mutation.attributeName === "max" || mutation.attributeName === "step") {
this._syncSettingsFromNativeAttributes();
}
}
});
});
this._mutationObserver.observe(this.input, {
attributes: true,
attributeFilter: ["disabled", "readonly", "min", "max", "step"]
});
}
}
/**
* Update button disabled state based on input disabled/readonly state
* @private
*/
_updateButtonDisabledState() {
const isDisabled = this.input.disabled || this.input.hasAttribute("readonly");
if (this._upButton) {
this._upButton.disabled = isDisabled;
}
if (this._downButton) {
this._downButton.disabled = isDisabled;
}
if (isDisabled) {
this.stopSpin();
}
}
/**
* Check if callbacks are properly paired and warn if not
* @private
*/
_checkCallbackPairing() {
var _a, _b, _c, _d;
const defCb = (v) => v;
const hasBefore = this.settings.callback_before_calculation && this.settings.callback_before_calculation.toString() !== defCb.toString();
const hasAfter = this.settings.callback_after_calculation && this.settings.callback_after_calculation.toString() !== defCb.toString();
if (hasBefore && !hasAfter) {
console.warn(
"TouchSpin: callback_before_calculation is defined but callback_after_calculation is missing. These callbacks should be used together - one removes formatting, the other adds it back."
);
} else if (!hasBefore && hasAfter) {
console.warn(
"TouchSpin: callback_after_calculation is defined but callback_before_calculation is missing. These callbacks should be used together - one removes formatting, the other adds it back."
);
}
if (hasBefore && hasAfter) {
const testValues = [];
const decimals = this.settings.decimals || 0;
const formatValue = (n) => n.toFixed(decimals);
const hasMin = this.settings.min !== null && this.settings.min !== void 0;
const hasMax = this.settings.max !== null && this.settings.max !== void 0;
if (hasMin && hasMax) {
testValues.push(formatValue(this.settings.min), formatValue(this.settings.max));
} else if (hasMin) {
testValues.push(formatValue(this.settings.min));
} else if (hasMax) {
testValues.push(formatValue(this.settings.max));
} else {
testValues.push(decimals > 0 ? "50.55" : "50");
}
const failures = [];
for (const testValue of testValues) {
const afterResult = (_b = (_a = this.settings).callback_after_calculation) == null ? void 0 : _b.call(_a, testValue);
if (afterResult === void 0) continue;
const beforeResult = (_d = (_c = this.settings).callback_before_calculation) == null ? void 0 : _d.call(_c, afterResult);
if (beforeResult === void 0) continue;
if (beforeResult !== testValue) {
failures.push(
`"${testValue}" \u2192 after \u2192 "${afterResult}" \u2192 before \u2192 "${beforeResult}" (expected "${testValue}")`
);
}
}
if (failures.length > 0) {
console.warn(
"TouchSpin: Callbacks are not properly paired - round-trip test failed:\n" + failures.join("\n") + '\ncallback_before_calculation must reverse what callback_after_calculation does. For example, if after adds " USD", before must strip " USD".'
);
}
}
}
};
function TouchSpin(inputEl, opts) {
var _a;
if (!inputEl || inputEl.nodeName !== "INPUT") {
console.warn("Must be an input.");
return null;
}
if (opts !== void 0) {
if (inputEl[INSTANCE_KEY]) {
console.warn(
"TouchSpin: Destroying existing instance and reinitializing. Consider using updateSettings() instead."
);
(_a = inputEl[INSTANCE_KEY]) == null ? void 0 : _a.destroy();
}
const core = new TouchSpinCore(inputEl, opts);
inputEl[INSTANCE_KEY] = core;
core.initDOMEventHandling();
return core.toPublicApi();
}
if (!inputEl[INSTANCE_KEY]) {
const core = new TouchSpinCore(inputEl, {});
inputEl[INSTANCE_KEY] = core;
core.initDOMEventHandling();
return core.toPublicApi();
}
return inputEl[INSTANCE_KEY].toPublicApi();
}
var CORE_EVENTS = Object.freeze({
MIN: "min",
MAX: "max",
START_SPIN: "startspin",
START_UP: "startupspin",
START_DOWN: "startdownspin",
STOP_SPIN: "stopspin",
STOP_UP: "stopupspin",
STOP_DOWN: "stopdownspin"
});
// ../standalone/dist/chunk-SHREFLHY.js
function mount(host, opts, renderer) {
const element = typeof host === "string" ? document.querySelector(host) : host;
if (!element) {
throw new Error(
`TouchSpin mount failed: element not found${typeof host === "string" ? ` (selector: "${host}")` : ""}`
);
}
if (!(element instanceof HTMLInputElement)) {
throw new Error("TouchSpin mount failed: element must be an HTMLInputElement");
}
return TouchSpin(element, {
...opts,
renderer
});
}
// ../../core/dist/renderer.js
var TEST_ID_ATTRIBUTE = "data-testid";
var TOUCHSPIN_ATTRIBUTE = "data-touchspin-injected";
var AbstractRendererBase = class {
constructor(input, settings, core) {
this.wrapper = null;
this.wrapperType = "wrapper";
this.input = input;
this.settings = settings;
this.core = core;
}
/**
* Finalize wrapper attributes as the last step of rendering.
*
* This method is called by Core after the renderer has completed all DOM