nmask
Version:
A lightweight and flexible number input masking library with zero dependencies. Supports vanilla JS, jQuery, React, Vue, Next.js, and any modern framework. Perfect for currency, percentage, and numeric formatting.
556 lines (474 loc) • 20.7 kB
JavaScript
(function ($) {
// Store original jQuery val method
const originalVal = $.fn.val;
// Override jQuery .val() method
$.fn.val = function (value) {
// If setting value
if (arguments.length > 0) {
return this.each(function () {
const $this = $(this);
// Check if element has nmask data
if ($this.data("nmask-active")) {
if ($this.is("input")) {
// For input elements, use original behavior
originalVal.call($this, value);
} else {
// For non-input elements, use custom setValue method
const setValue = $this.data("nmask-setValue");
if (setValue) {
setValue(value);
}
}
} else {
// Use original jQuery val method
originalVal.call($this, value);
}
});
}
// If getting value
else {
// Prefer the original element that has nmask active when a selection
// contains both the visual and the original input. This prevents
// returning the formatted visual value (with thousands separators).
let $first = $(this.first());
// If first element is not the nmask-bound original and the set
// contains more than one element, try to find the original or
// the hidden input that stores the clean value. Prefer elements
// with data('nmask-active') first, then elements marked with
// data('nmask-hidden') (the hidden input wrapper for non-inputs).
if (this.length > 1) {
// Prefer explicit original marker first
const $originalMarked = this.filter(function () {
return $(this).data && $(this).data("nmask-original");
}).first();
if ($originalMarked && $originalMarked.length) {
$first = $originalMarked;
} else {
const $active = this.filter(function () {
return $(this).data && $(this).data("nmask-active");
}).first();
if ($active && $active.length) {
$first = $active;
} else {
const $hidden = this.filter(function () {
return $(this).data && $(this).data("nmask-hidden");
}).first();
if ($hidden && $hidden.length) {
$first = $hidden;
}
}
}
}
const $this = $first;
// Check if element has nmask data
if ($this.data("nmask-active")) {
if ($this.is("input")) {
// For input elements, return the underlying/original input value
// (which the plugin keeps as the clean numeric value) instead of
// the visual formatted input.
return originalVal.call($this);
} else {
// For non-input elements, use custom getValue method
const getValue = $this.data("nmask-getValue");
return getValue ? getValue() : "";
}
} else {
// Use original jQuery val method
return originalVal.call($this);
}
}
};
$.fn.nmask = function (optionsOrMethod) {
const defaultSettings = {
thousandsSeparator: ".",
decimalSeparator: ",",
decimalDigits: 0,
prefix: "",
suffix: "",
allowNegative: false,
};
const escapeRegExp = (string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
if (optionsOrMethod === "destroy") {
return this.each(function () {
const $original = $(this);
const $visual = $original.data("nmask-visual");
const $hiddenInput = $original.data("nmask-hidden");
// Restore position & remove visual
if ($visual && $visual.length) {
// Kembalikan original ke posisi visual input
$original.insertBefore($visual);
// Hapus visual input
$visual.off(".nmask").remove();
}
// Remove hidden input jika ada
if ($hiddenInput && $hiddenInput.length) {
$hiddenInput.off(".nmask").remove();
}
// Cleanup data & events
$original
.removeData([
"nmask-active",
"nmask-setValue",
"nmask-getValue",
"nmask-visual",
"nmask-hidden",
"nmask-original",
])
.removeAttr("data-nmask-original")
.off(".nmask");
// Restore styles
if ($original.is("input")) {
$original.show().css({
opacity: "",
width: "",
height: "",
border: "",
padding: "",
margin: "",
minWidth: "",
minHeight: "",
});
}
});
}
const settings = $.extend({}, defaultSettings, optionsOrMethod);
const formatNumber = (value, preserveDecimalSeparator = false) => {
if (!value && value !== "0") return "";
// Input value is always in internal format (with dot as decimal separator)
let num = value.toString().replace(/[^0-9\-\.]/g, "");
let isNegative = value.toString().startsWith("-");
if (isNegative) num = num.substring(1);
// Special handling for decimal point
const endsWithDecimal = preserveDecimalSeparator && value.toString().endsWith(".");
let [intPart, decPart] = num.split(".");
intPart = intPart || "0"; // Use "0" if intPart is empty
intPart = intPart.replace(/^0+(?=\d)/, ""); // remove leading zeros
if (intPart === "") intPart = "0"; // Ensure at least "0" is shown
intPart = intPart.replace(
/\B(?=(\d{3})+(?!\d))/g,
settings.thousandsSeparator
);
let result = (isNegative ? "-" : "") + settings.prefix + intPart;
if (settings.decimalDigits > 0) {
if (preserveDecimalSeparator && value.toString().endsWith(".")) {
result += settings.decimalSeparator;
} else if (decPart !== undefined) {
decPart = (decPart || "").slice(0, settings.decimalDigits);
if (decPart.length > 0) {
result += settings.decimalSeparator + decPart;
}
}
}
return result + settings.suffix;
};
const cleanNumber = (val) => {
if (!val) return "";
// Remove prefix and suffix first
if (settings.prefix) {
val = val.replace(new RegExp(escapeRegExp(settings.prefix), "g"), "");
}
if (settings.suffix) {
val = val.replace(new RegExp(escapeRegExp(settings.suffix), "g"), "");
}
// Remove thousand separators (only if different from decimal separator)
if (settings.thousandsSeparator && settings.thousandsSeparator !== settings.decimalSeparator) {
val = val.replace(
new RegExp(escapeRegExp(settings.thousandsSeparator), "g"),
""
);
}
// First remove any prefix/suffix to avoid interfering with decimal validation
if (settings.prefix) {
val = val.replace(new RegExp("^" + escapeRegExp(settings.prefix)), "");
}
if (settings.suffix) {
val = val.replace(new RegExp(escapeRegExp(settings.suffix) + "$"), "");
}
// Create a regex that allows decimal separator (whether it's . or ,)
const decimalRegex = new RegExp(
`[^0-9${settings.allowNegative ? "\\-" : ""}${escapeRegExp(
settings.decimalSeparator
)}]`,
"g"
);
// Clean the value but preserve the decimal separator
val = val.replace(decimalRegex, "");
// Handle multiple decimal separators - keep only the first one
const parts = val.split(settings.decimalSeparator);
if (parts.length > 1) {
val = parts[0] + settings.decimalSeparator + parts.slice(1).join("");
}
// Convert decimal separator to dot for internal processing
if (settings.decimalSeparator !== ".") {
val = val.replace(settings.decimalSeparator, ".");
}
return val;
};
return this.each(function () {
const $original = $(this);
const isInput = $original.is("input");
// Check if already initialized by looking for nmask-active data
if ($original.data("nmask-active")) {
return; // Skip if already initialized
}
// Mark element as having nmask active
$original.data("nmask-active", true);
// For input elements (original behavior with visual input)
if (isInput) {
// Auto-generate ID only if needed for input-group functionality
let originalId = $original.attr("id");
if (!originalId && $original.parent().hasClass("input-group")) {
originalId = "nmask_" + Math.floor(Math.random() * 10000);
$original.attr("id", originalId);
}
const inputMode = settings.decimalDigits > 0 ? "decimal" : "numeric";
const $visual = $('<input type="text" autocomplete="off">')
.addClass($original.attr("class") || "")
.attr({ inputmode: inputMode });
// Set ID for visual input only if original has ID
if (originalId) {
$visual.attr("id", originalId + "_visual");
}
if ($original.attr("placeholder")) {
$visual.attr("placeholder", $original.attr("placeholder"));
}
["readonly", "disabled", "required", "style"].forEach((prop) => {
if ($original.prop(prop)) $visual.prop(prop, $original.prop(prop));
});
// Copy all data from original to visual (including custom data-*).
// We'll remove internal nmask keys from the visual afterwards so
// visual doesn't accidentally appear as the nmask source.
$.each($original.data(), function (key, value) {
$visual.data(key, value);
});
// After copying, explicitly remove internal nmask keys from the visual
// so it won't be treated as the primary source later.
try {
$visual.removeData("nmask-active");
$visual.removeData("nmask-visual");
$visual.removeData("nmask-hidden");
$visual.removeData("nmask-setValue");
$visual.removeData("nmask-getValue");
} catch (e) {
// ignore
}
// Mark the original as the original/source so selection logic can
// prefer it. Also set a dataset attribute for easier DOM inspection.
$original.data("nmask-original", true);
try {
$original.attr("data-nmask-original", "true");
} catch (e) {}
$original.each(function () {
const el = this;
el.style.setProperty("opacity", "0", "important");
el.style.setProperty("width", "0", "important");
el.style.setProperty("height", "0", "important");
el.style.setProperty("border", "none", "important");
el.style.setProperty("padding", "0", "important");
el.style.setProperty("margin", "0", "important");
el.style.setProperty("min-width", "0", "important");
el.style.setProperty("min-height", "0", "important");
});
$original.attr("tabindex", -1);
// Add mouseup event to handle selection
$visual.on("mouseup.nmask", function(e) {
const selectionStart = this.selectionStart;
const selectionEnd = this.selectionEnd;
const val = $(this).val();
const prefixLen = settings.prefix ? settings.prefix.length : 0;
const suffixLen = settings.suffix ? settings.suffix.length : 0;
const valueEndPos = val.length - suffixLen;
// If selection includes prefix or suffix, adjust it
if (selectionStart < prefixLen || selectionEnd > valueEndPos) {
const newStart = Math.max(selectionStart, prefixLen);
const newEnd = Math.min(selectionEnd, valueEndPos);
this.setSelectionRange(newStart, newEnd);
}
});
if ($original.parent().hasClass("input-group")) {
$visual.insertAfter($original);
$original.insertAfter($original.parent());
} else {
$original.after($visual);
}
// Store reference to visual input in data
$original.data("nmask-visual", $visual);
$visual.val(formatNumber($original.val()));
$original.attr("step", "any");
const restrictCursorPosition = function(input) {
const val = input.value;
const cursorPos = input.selectionStart;
const prefixLen = settings.prefix ? settings.prefix.length : 0;
const suffixLen = settings.suffix ? settings.suffix.length : 0;
const valueEndPos = val.length - suffixLen;
// If cursor is in prefix area
if (cursorPos < prefixLen) {
input.setSelectionRange(prefixLen, prefixLen);
}
// If cursor is in suffix area
else if (cursorPos > valueEndPos) {
input.setSelectionRange(valueEndPos, valueEndPos);
}
};
// Add click and keyup handlers to restrict cursor
$visual.on("click.nmask keyup.nmask", function() {
restrictCursorPosition(this);
});
$visual.on("input.nmask", function (e) {
let val = $(this).val();
const cursorPos = this.selectionStart;
// Detect decimal separator related conditions early
const justTypedDecimal = val.charAt(cursorPos - 1) === settings.decimalSeparator;
const isTypingAfterDecimal = val.charAt(cursorPos - 2) === settings.decimalSeparator;
let cleanVal = cleanNumber(val);
// Handle empty input
if (!cleanVal && !val) {
$original.val("");
$(this).val("");
return;
}
// Special handling for empty input with only prefix/suffix
if (!cleanVal && (
(settings.suffix && val === settings.prefix + settings.suffix) ||
(settings.prefix && val === settings.prefix)
)) {
$(this).val("");
$original.val("");
return;
}
if (cleanVal || cleanVal === "0") {
let parts = cleanVal.split(".");
let intPart = parts[0].replace(/^(-)?0+(?=\d)/, "$1") || "0";
let decPart = parts[1] || "";
// Save cursor position relative to the number
const beforeCursor = val.slice(0, cursorPos);
const cleanBefore = cleanNumber(beforeCursor);
const relativePos = cleanBefore.length;
let originalVal, visualVal;
if (settings.decimalDigits > 0) {
decPart = decPart.slice(0, settings.decimalDigits);
// Handle the case where we just typed a decimal separator
if (val.charAt(cursorPos - 1) === settings.decimalSeparator) {
originalVal = intPart + ".";
visualVal = formatNumber(originalVal, true);
} else {
originalVal = decPart.length > 0 ? intPart + "." + decPart : intPart;
// Preserve decimal separator if it exists in the input
const hasDecimal = val.includes(settings.decimalSeparator);
visualVal = formatNumber(originalVal, hasDecimal);
}
} else {
originalVal = intPart;
visualVal = formatNumber(intPart);
}
// Set values
$original.val(originalVal);
$(this).val(visualVal);
// Calculate new cursor position
const newVal = $(this).val();
const numberEndPos =
newVal.length - (settings.suffix ? settings.suffix.length : 0);
const prefixLen = settings.prefix ? settings.prefix.length : 0;
const decimalIndex = newVal.indexOf(settings.decimalSeparator);
// Detect if we just typed or are right after decimal separator
const justTypedDecimalSeparator = val.charAt(cursorPos - 1) === settings.decimalSeparator;
const cursorAtDecimalPosition = decimalIndex !== -1 && cursorPos === decimalIndex + 1;
// If we just typed decimal or are right after it, preserve that position
if (justTypedDecimalSeparator || cursorAtDecimalPosition) {
const newDecimalIndex = newVal.indexOf(settings.decimalSeparator);
if (newDecimalIndex !== -1) {
this.setSelectionRange(newDecimalIndex + 1, newDecimalIndex + 1);
return;
}
}
// For other cases, calculate cursor position
const isAfterDecimal = decimalIndex !== -1 && cursorPos > decimalIndex;
// Ensure cursor stays within the number part
let newPos;
if (isAfterDecimal) {
// Keep cursor position for decimal part
newPos = cursorPos;
} else {
// Adjust position for thousands separators
newPos = Math.min(
prefixLen + relativePos + Math.floor(relativePos / 3),
numberEndPos
);
}
// Set cursor position
this.setSelectionRange(newPos, newPos);
$original.trigger("input");
} else {
$original.val("");
$visual.val("");
$original.trigger("input");
}
$original.trigger("change");
});
const syncFromOriginal = () => {
const originalVal = $original.val();
const visualVal = $visual.val();
const endsWithDecimalOnly =
settings.decimalDigits > 0 &&
visualVal &&
visualVal.endsWith(settings.decimalSeparator);
if (!endsWithDecimalOnly) {
$visual.val(formatNumber(originalVal));
}
};
$original.on("input.nmask change.nmask", syncFromOriginal);
}
// For non-input elements (display only - no ID required)
else {
// Create hidden input WITHOUT requiring ID
const $hiddenInput = $('<input type="hidden">');
// Set name attribute from data-name or generate unique name
const nameAttr =
$original.attr("data-name") ||
$original.attr("name") ||
"nmask_field_" + Math.floor(Math.random() * 10000);
$hiddenInput.attr("name", nameAttr);
$original.after($hiddenInput);
// Store reference to hidden input in data
$original.data("nmask-hidden", $hiddenInput);
// Mark the hidden input itself so selection logic can find it if needed
$hiddenInput.data("nmask-hidden", true);
// Set initial value from element's text, data-value, or empty
const initialValue =
$original.text() || $original.attr("data-value") || "";
const cleanInitial = cleanNumber(initialValue);
$hiddenInput.val(cleanInitial);
$original.text(formatNumber(cleanInitial));
// Create setValue and getValue methods for .val() override
const setValue = function (value) {
const cleanVal = cleanNumber(value.toString());
$hiddenInput.val(cleanVal);
$original.text(formatNumber(cleanVal));
$original.trigger("change");
$original.trigger("nmask:change", [cleanVal]);
};
const getValue = function () {
return $hiddenInput.val();
};
// Store methods in jQuery data for .val() override
$original.data("nmask-setValue", setValue);
$original.data("nmask-getValue", getValue);
}
// Form submit handler
const form = $original.closest("form");
if (form.length) {
form.off("submit.nmask").on("submit.nmask", function () {
if (isInput) {
const $visual = $original.data("nmask-visual");
if ($visual && $visual.length) {
$original.val(cleanNumber($visual.val()));
}
}
// For non-input elements, hidden input already contains clean value
});
}
});
};
})(jQuery);