From 3d769459107733cad2e1ba91bab9a6f3da4acac5 Mon Sep 17 00:00:00 2001 From: Georgii Dolzhykov Date: Fri, 10 Apr 2026 14:33:16 +0300 Subject: [PATCH] Fix decimal alias negative values with prefix/groupSeparator collision #2262 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes fixed in the numeric extensions: 1. Register the groupSeparator definition before autoEscape(prefix) so that a shared character (e.g. space in prefix "€ " with groupSeparator " ") is escaped as a literal in the mask instead of being treated as a dynamic placeholder by the lexer. 2. In onBeforeWrite's strip-negation-on-zero path, match against the raw buffer instead of the stripped buffer so that prefix/suffix characters coinciding with the groupSeparator are not accidentally removed. Extract the shared regex into a reusable matchNumberInWrapper() helper. 3. Guard the radixPoint replacement with a truthiness check to prevent "".replace("", ".") from injecting a stray "." when radixPoint is empty (radix-less masks with empty placeholder). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../inputmask.numeric.extensions.js | 91 ++--- qunit/tests_formatvalidate.js | 313 ++++++++++++++++++ 2 files changed, 363 insertions(+), 41 deletions(-) diff --git a/lib/extensions/inputmask.numeric.extensions.js b/lib/extensions/inputmask.numeric.extensions.js index 1a80f505d..f2f431351 100755 --- a/lib/extensions/inputmask.numeric.extensions.js +++ b/lib/extensions/inputmask.numeric.extensions.js @@ -185,19 +185,26 @@ function genMask(opts) { opts.numericInput = true; } + // Register the groupSeparator as a definition *before* escaping prefix/suffix + // so that a groupSeparator character appearing inside the prefix or suffix + // (e.g. prefix: "€ " with groupSeparator: " ") is escaped as a literal instead + // of being picked up by the lexer as a dynamic placeholder. Fixes #2262. + if ( + opts.groupSeparator !== "" && + opts.definitions[opts.groupSeparator] === undefined + ) { + opts.definitions[opts.groupSeparator] = { + validator: "[" + opts.groupSeparator + "]", + placeholder: opts.groupSeparator, + static: true, + generated: true // forced marker as generated input + }; + } + let mask = "[+]", altMask; mask += autoEscape(opts.prefix, opts); if (opts.groupSeparator !== "") { - if (opts.definitions[opts.groupSeparator] === undefined) { - // update separator definition - opts.definitions[opts.groupSeparator] = {}; - opts.definitions[opts.groupSeparator].validator = - "[" + opts.groupSeparator + "]"; - opts.definitions[opts.groupSeparator].placeholder = opts.groupSeparator; - opts.definitions[opts.groupSeparator].static = true; - opts.definitions[opts.groupSeparator].generated = true; // forced marker as generated input - } mask += opts._mask(opts); } else { mask += "9{+}"; @@ -274,22 +281,27 @@ function decimalValidator(chrs, maskset, pos, strict, opts) { return result; } +// Match the numeric body of the (reversed) buffer against the prefix/suffix +// wrapper, returning the captured "number" group or null if the match fails. +function matchNumberInWrapper(buffer, opts) { + const match = new RegExp( + "(^" + + (opts.negationSymbol.front !== "" + ? escapeRegex(opts.negationSymbol.front) + "?" + : "") + + escapeRegex(opts.prefix) + + ")(.*)(" + + escapeRegex(opts.suffix) + + (opts.negationSymbol.back !== "" + ? escapeRegex(opts.negationSymbol.back) + "?" + : "") + + "$)" + ).exec(buffer.slice().reverse().join("")); + return match ? match[2] : null; +} + function checkForLeadingZeroes(buffer, opts) { - // check leading zeros - let numberMatches = new RegExp( - "(^" + - (opts.negationSymbol.front !== "" - ? escapeRegex(opts.negationSymbol.front) + "?" - : "") + - escapeRegex(opts.prefix) + - ")(.*)(" + - escapeRegex(opts.suffix) + - (opts.negationSymbol.back != "" - ? escapeRegex(opts.negationSymbol.back) + "?" - : "") + - "$)" - ).exec(buffer.slice().reverse().join("")), - number = numberMatches ? numberMatches[2] : "", + let number = matchNumberInWrapper(buffer, opts) ?? "", leadingzeroes = false; if (number) { number = number.split(opts.radixPoint.charAt(0))[0]; @@ -749,23 +761,20 @@ Inputmask.extendAliases({ } } if (buffer[buffer.length - 1] === opts.negationSymbol.front) { - // strip negation symbol on blur when value is 0 - const nmbrMtchs = new RegExp( - "(^" + - (opts.negationSymbol.front != "" - ? escapeRegex(opts.negationSymbol.front) + "?" - : "") + - escapeRegex(opts.prefix) + - ")(.*)(" + - escapeRegex(opts.suffix) + - (opts.negationSymbol.back != "" - ? escapeRegex(opts.negationSymbol.back) + "?" - : "") + - "$)" - ).exec(stripBuffer(buffer.slice(), true).reverse().join("")), - number = nmbrMtchs ? nmbrMtchs[2] : ""; - if (number == 0) { - result = { refreshFromBuffer: true, buffer: [0] }; + // strip negation symbol on blur when value is 0. Match on the + // raw buffer so a groupSeparator-colliding char inside the + // prefix/suffix (e.g. space in "€ ") is preserved; normalize + // only the extracted number group. Fixes #2262. + const rawNumber = matchNumberInWrapper(buffer, opts); + if (rawNumber !== null) { + let number = rawNumber + .split(opts.groupSeparator) + .join(""); + if (opts.radixPoint) + number = number.replace(opts.radixPoint, "."); + if (number === "" || number == 0) { + result = { refreshFromBuffer: true, buffer: [0] }; + } } } else if (opts.radixPoint !== "") { // strip radixpoint on blur when it is the latest char diff --git a/qunit/tests_formatvalidate.js b/qunit/tests_formatvalidate.js index 6a2069b6c..4f251e8d7 100644 --- a/qunit/tests_formatvalidate.js +++ b/qunit/tests_formatvalidate.js @@ -353,4 +353,317 @@ export default function (qunit, Inputmask) { assert.equal(unmasked, "1234567890", "Result " + unmasked); } ); + + // https://github.com/RobinHerbots/Inputmask/issues/2262 + qunit.module("Decimal alias - negative with prefix (#2262)"); + + qunit.test( + 'Inputmask.isValid("-$10.25", { alias: "decimal", groupSeparator: ",", prefix: "$" })', + function (assert) { + var valid = Inputmask.isValid("-$10.25", { + alias: "decimal", + groupSeparator: ",", + prefix: "$" + }); + assert.equal(valid, true, "Result " + valid); + } + ); + + qunit.test( + 'Inputmask.isValid("-$10,000.25", { alias: "decimal", groupSeparator: ",", prefix: "$" })', + function (assert) { + var valid = Inputmask.isValid("-$10,000.25", { + alias: "decimal", + groupSeparator: ",", + prefix: "$" + }); + assert.equal(valid, true, "Result " + valid); + } + ); + + qunit.test( + 'Inputmask.format("-10.25", { alias: "decimal", groupSeparator: ",", prefix: "$" })', + function (assert) { + var formatted = Inputmask.format("-10.25", { + alias: "decimal", + groupSeparator: ",", + prefix: "$" + }); + assert.equal(formatted, "-$10.25", "Result " + formatted); + } + ); + + // Follow-up from issue #2262: space group separator together with a space inside the prefix. + qunit.test( + 'Inputmask.isValid("-\u20AC 10.25", { alias: "decimal", groupSeparator: " ", prefix: "\u20AC " })', + function (assert) { + var valid = Inputmask.isValid("-\u20AC 10.25", { + alias: "decimal", + groupSeparator: " ", + prefix: "\u20AC " + }); + assert.equal(valid, true, "Result " + valid); + } + ); + + qunit.test( + 'Inputmask.format("-10.25", { alias: "decimal", groupSeparator: " ", prefix: "\u20AC " })', + function (assert) { + var formatted = Inputmask.format("-10.25", { + alias: "decimal", + groupSeparator: " ", + prefix: "\u20AC " + }); + assert.equal(formatted, "-\u20AC 10.25", "Result " + formatted); + } + ); + + qunit.test( + 'Inputmask.format("-1234567.89", { alias: "decimal", groupSeparator: " ", prefix: "\u20AC " })', + function (assert) { + var formatted = Inputmask.format("-1234567.89", { + alias: "decimal", + groupSeparator: " ", + prefix: "\u20AC " + }); + assert.equal(formatted, "-\u20AC 1 234 567.89", "Result " + formatted); + } + ); + + // Symmetric case: space inside the suffix. + qunit.test( + 'Inputmask.format("-1234.56", { alias: "decimal", groupSeparator: " ", suffix: " \u20AC" })', + function (assert) { + var formatted = Inputmask.format("-1234.56", { + alias: "decimal", + groupSeparator: " ", + suffix: " \u20AC" + }); + assert.equal(formatted, "-1 234.56 \u20AC", "Result " + formatted); + } + ); + + // Strip-negation-when-zero path: minus on a zero value must be dropped + // without clobbering a prefix character that coincides with groupSeparator. + qunit.test( + 'Inputmask.format("-0", { alias: "decimal", groupSeparator: " ", prefix: "\u20AC " })', + function (assert) { + var formatted = Inputmask.format("-0", { + alias: "decimal", + groupSeparator: " ", + prefix: "\u20AC " + }); + assert.equal(formatted, "\u20AC 0", "Result " + formatted); + } + ); + + // Symmetric strip-negation-when-zero path with suffix collision. + qunit.test( + 'Inputmask.format("-0", { alias: "decimal", groupSeparator: " ", suffix: " \u20AC" })', + function (assert) { + var formatted = Inputmask.format("-0", { + alias: "decimal", + groupSeparator: " ", + suffix: " \u20AC" + }); + assert.equal(formatted, "0 \u20AC", "Result " + formatted); + } + ); + + // Round-trip: the fully-grouped output must also be accepted as valid input. + qunit.test( + 'Inputmask.isValid("-\u20AC 1 234 567.89", { alias: "decimal", groupSeparator: " ", prefix: "\u20AC " })', + function (assert) { + var valid = Inputmask.isValid("-\u20AC 1 234 567.89", { + alias: "decimal", + groupSeparator: " ", + prefix: "\u20AC " + }); + assert.equal(valid, true, "Result " + valid); + } + ); + + // Integer-style mask (radixPoint: ""): a bare negative must still collapse + // to zero on checkval — regression guard for the radix-less zero cleanup. + qunit.test( + 'Inputmask.format("-", { alias: "integer", radixPoint: "" })', + function (assert) { + var formatted = Inputmask.format("-", { + alias: "integer", + radixPoint: "" + }); + assert.equal(formatted, "0", "Result " + formatted); + } + ); + + qunit.test( + 'Inputmask.format("-", { alias: "decimal", radixPoint: "", digits: 0 })', + function (assert) { + var formatted = Inputmask.format("-", { + alias: "decimal", + radixPoint: "", + digits: 0 + }); + assert.equal(formatted, "0", "Result " + formatted); + } + ); + + qunit.test( + 'Inputmask.format("-", { alias: "numeric", radixPoint: "" })', + function (assert) { + var formatted = Inputmask.format("-", { + alias: "numeric", + radixPoint: "" + }); + assert.equal(formatted, "0", "Result " + formatted); + } + ); + + qunit.test( + 'Inputmask.format("-", { alias: "integer", placeholder: "" })', + function (assert) { + var formatted = Inputmask.format("-", { + alias: "integer", + placeholder: "" + }); + assert.equal(formatted, "0", "Result " + formatted); + } + ); + + // Radix-less with groupSeparator: an empty radixPoint replace must not + // inject a stray "." into the number string. + qunit.test( + 'Inputmask.format("-", { alias: "numeric", radixPoint: "", groupSeparator: ",", digits: 0 })', + function (assert) { + var formatted = Inputmask.format("-", { + alias: "numeric", + radixPoint: "", + groupSeparator: ",", + digits: 0 + }); + assert.equal(formatted, "0", "Result " + formatted); + } + ); + + qunit.test( + 'Inputmask.format("-", { alias: "numeric", radixPoint: "", groupSeparator: ",", digits: 0, placeholder: "" })', + function (assert) { + var formatted = Inputmask.format("-", { + alias: "numeric", + radixPoint: "", + groupSeparator: ",", + digits: 0, + placeholder: "" + }); + assert.equal(formatted, "0", "Result " + formatted); + } + ); + + // Parenthetical negation with prefix/groupSeparator collision. + qunit.test( + 'Inputmask.format("-1234.56", { alias: "decimal", groupSeparator: " ", prefix: "\u20AC ", negationSymbol: { front: "(", back: ")" } })', + function (assert) { + var formatted = Inputmask.format("-1234.56", { + alias: "decimal", + groupSeparator: " ", + prefix: "\u20AC ", + negationSymbol: { front: "(", back: ")" } + }); + assert.equal( + formatted, + "(\u20AC 1 234.56)", + "Result " + formatted + ); + } + ); + + qunit.test( + 'Inputmask.isValid("(\u20AC 1 234.56)", { alias: "decimal", groupSeparator: " ", prefix: "\u20AC ", negationSymbol: { front: "(", back: ")" } })', + function (assert) { + var valid = Inputmask.isValid("(\u20AC 1 234.56)", { + alias: "decimal", + groupSeparator: " ", + prefix: "\u20AC ", + negationSymbol: { front: "(", back: ")" } + }); + assert.equal(valid, true, "Result " + valid); + } + ); + + // Exact config from #2678: prefix "Rp. " with groupSeparator "." and radixPoint "," + qunit.test( + 'Inputmask.format("-1000,55", { alias: "decimal", prefix: "Rp. ", radixPoint: ",", groupSeparator: "." }) - #2678', + function (assert) { + var formatted = Inputmask.format("-1000,55", { + alias: "decimal", + prefix: "Rp. ", + radixPoint: ",", + groupSeparator: "." + }); + assert.equal(formatted, "-Rp. 1.000,55", "Result " + formatted); + } + ); + + qunit.test( + 'Inputmask.isValid("-Rp. 1.000,55", { alias: "decimal", prefix: "Rp. ", radixPoint: ",", groupSeparator: "." }) - #2678', + function (assert) { + var valid = Inputmask.isValid("-Rp. 1.000,55", { + alias: "decimal", + prefix: "Rp. ", + radixPoint: ",", + groupSeparator: "." + }); + assert.equal(valid, true, "Result " + valid); + } + ); + + qunit.test( + 'Inputmask.format("-0", { alias: "decimal", prefix: "Rp. ", radixPoint: ",", groupSeparator: "." }) - #2678', + function (assert) { + var formatted = Inputmask.format("-0", { + alias: "decimal", + prefix: "Rp. ", + radixPoint: ",", + groupSeparator: "." + }); + assert.equal(formatted, "Rp. 0", "Result " + formatted); + } + ); + + // Exact config from #2771: suffix " zl" with groupSeparator " " + qunit.test( + 'Inputmask.format("-1234.56", { alias: "decimal", suffix: " zl", groupSeparator: " " }) - #2771', + function (assert) { + var formatted = Inputmask.format("-1234.56", { + alias: "decimal", + suffix: " zl", + groupSeparator: " " + }); + assert.equal(formatted, "-1 234.56 zl", "Result " + formatted); + } + ); + + qunit.test( + 'Inputmask.isValid("-1 234.56 zl", { alias: "decimal", suffix: " zl", groupSeparator: " " }) - #2771', + function (assert) { + var valid = Inputmask.isValid("-1 234.56 zl", { + alias: "decimal", + suffix: " zl", + groupSeparator: " " + }); + assert.equal(valid, true, "Result " + valid); + } + ); + + qunit.test( + 'Inputmask.format("-0", { alias: "decimal", suffix: " zl", groupSeparator: " " }) - #2771', + function (assert) { + var formatted = Inputmask.format("-0", { + alias: "decimal", + suffix: " zl", + groupSeparator: " " + }); + assert.equal(formatted, "0 zl", "Result " + formatted); + } + ); }