diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b7c7476..426480f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [4.4.5](https://github.com/assisrafael/angular-input-masks/compare/v4.4.4...v4.4.5) (2026-06-19) + + + +### [4.4.4](https://github.com/assisrafael/angular-input-masks/compare/v4.4.3...v4.4.4) (2026-06-19) + + + +### [4.4.3](https://github.com/assisrafael/angular-input-masks/compare/v4.4.2...v4.4.3) (2026-06-19) + + +### Bug Fixes + +* **br:** support Brazilian alphanumeric CNPJ format in masks, filters and validation ([8aaba52](https://github.com/assisrafael/angular-input-masks/commit/8aaba52)) + + + +### [4.4.2](https://github.com/assisrafael/angular-input-masks/compare/v4.4.1...v4.4.2) (2026-06-17) + + + ### [4.4.1](https://github.com/assisrafael/angular-input-masks/compare/v4.4.0...v4.4.1) (2019-05-29) diff --git a/package.json b/package.json index f1241759..7d2da98e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-input-masks", - "version": "4.4.1", + "version": "4.4.5", "description": "Personalized input masks for AngularJS", "repository": { "type": "git", diff --git a/src/br/cnpj/cnpj.js b/src/br/cnpj/cnpj.js index ff9199f8..f6173282 100644 --- a/src/br/cnpj/cnpj.js +++ b/src/br/cnpj/cnpj.js @@ -5,14 +5,14 @@ var BrV = require('br-validations'); var maskFactory = require('../../helpers/mask-factory'); -var cnpjPattern = new StringMask('00.000.000\/0000-00'); +var cnpjPattern = new StringMask('AA.AAA.AAA\/AAAA-00'); module.exports = maskFactory({ clearValue: function(rawValue) { - return rawValue.replace(/[^\d]/g, '').slice(0, 14); + return rawValue.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 14); }, format: function(cleanValue) { - return (cnpjPattern.apply(cleanValue) || '').trim().replace(/[^0-9]$/, ''); + return (cnpjPattern.apply(cleanValue) || '').trim(); }, validations: { cnpj: function(value) { diff --git a/src/br/cnpj/cnpj.test.js b/src/br/cnpj/cnpj.test.js index fc1900b1..6f5a19b6 100644 --- a/src/br/cnpj/cnpj.test.js +++ b/src/br/cnpj/cnpj.test.js @@ -22,14 +22,23 @@ describe('ui-br-cnpj-mask', function() { expect(maskedModel.$formatters.length).toBe(model.$formatters.length + 1); }); - it('should format initial model values', function() { - var input = TestUtil.compile('', { - model: '13883875000120' + it('should format initial numeric model values', function() { + var input = TestUtil.compile('', { + model: '13883875000120' + }); + + var model = input.controller('ngModel'); + expect(model.$viewValue).toBe('13.883.875/0001-20'); + }); + + it('should format initial alphanumeric model values', function() { + var input = TestUtil.compile('', { + model: '6MDP40BD000175' }); var model = input.controller('ngModel'); - expect(model.$viewValue).toBe('13.883.875/0001-20'); - }); + expect(model.$viewValue).toBe('6M.DP4.0BD/0001-75'); + }); it('should handle corner cases', angular.mock.inject(function($rootScope) { var input = TestUtil.compile(''); diff --git a/src/br/cpf-cnpj/cpf-cnpj.js b/src/br/cpf-cnpj/cpf-cnpj.js index 43bc34bc..da11c88e 100644 --- a/src/br/cpf-cnpj/cpf-cnpj.js +++ b/src/br/cpf-cnpj/cpf-cnpj.js @@ -4,23 +4,20 @@ var StringMask = require('string-mask'); var BrV = require('br-validations'); var maskFactory = require('../../helpers/mask-factory'); -var cnpjPattern = new StringMask('00.000.000\/0000-00'); +var cnpjPattern = new StringMask('AA.AAA.AAA\/AAAA-00'); var cpfPattern = new StringMask('000.000.000-00'); module.exports = maskFactory({ clearValue: function(rawValue) { - return rawValue.replace(/[^\d]/g, '').slice(0, 14); + return rawValue.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 14); }, format: function(cleanValue) { - var formatedValue; + if (!cleanValue) return ''; if (cleanValue.length > 11) { - formatedValue = cnpjPattern.apply(cleanValue); - } else { - formatedValue = cpfPattern.apply(cleanValue) || ''; + return (cnpjPattern.apply(cleanValue) || '').trim(); } - - return formatedValue.trim().replace(/[^0-9]$/, ''); + return (cpfPattern.apply(cleanValue) || '').trim().replace(/[^0-9]$/, ''); }, validations: { cpf: function(value) { diff --git a/src/global/date/date.js b/src/global/date/date.js index 2a4dab2d..7810c756 100644 --- a/src/global/date/date.js +++ b/src/global/date/date.js @@ -6,79 +6,183 @@ var isValidDate = require('date-fns/isValid'); var StringMask = require('string-mask'); function isISODateString(date) { - return /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}([-+][0-9]{2}:[0-9]{2}|Z)$/ - .test(date.toString()); + return /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,})?(Z|[-+][0-9]{2}:[0-9]{2})?$/ + .test(date.toString()); } +// var dateFormatMapByLocale = { +// 'pt-br': 'DD/MM/YYYY', +// 'es-ar': 'DD/MM/YYYY', +// 'es-mx': 'DD/MM/YYYY', +// 'es' : 'DD/MM/YYYY', +// 'en-us': 'MM/DD/YYYY', +// 'en' : 'MM/DD/YYYY', +// 'fr-fr': 'DD/MM/YYYY', +// 'fr' : 'DD/MM/YYYY', +// 'ru' : 'DD.MM.YYYY' +// }; + var dateFormatMapByLocale = { - 'pt-br': 'DD/MM/YYYY', - 'es-ar': 'DD/MM/YYYY', - 'es-mx': 'DD/MM/YYYY', - 'es' : 'DD/MM/YYYY', - 'en-us': 'MM/DD/YYYY', - 'en' : 'MM/DD/YYYY', - 'fr-fr': 'DD/MM/YYYY', - 'fr' : 'DD/MM/YYYY', - 'ru' : 'DD.MM.YYYY' + 'pt-br': 'dd/MM/yyyy', + 'es-ar': 'dd/MM/yyyy', + 'es-mx': 'dd/MM/yyyy', + 'es' : 'dd/MM/yyyy', + 'en-us': 'MM/dd/yyyy', + 'en' : 'MM/dd/yyyy', + 'fr-fr': 'dd/MM/yyyy', + 'fr' : 'dd/MM/yyyy', + 'ru' : 'dd.MM.yyyy' }; -function DateMaskDirective($locale) { - var dateFormat = dateFormatMapByLocale[$locale.id] || 'YYYY-MM-DD'; - - return { - restrict: 'A', - require: 'ngModel', - link: function(scope, element, attrs, ctrl) { - attrs.parse = attrs.parse || 'true'; - - dateFormat = attrs.uiDateMask || dateFormat; - - var dateMask = new StringMask(dateFormat.replace(/[YMD]/g,'0')); - - function formatter(value) { - if (ctrl.$isEmpty(value)) { - return null; - } - - var cleanValue = value; - if (typeof value === 'object' || isISODateString(value)) { - cleanValue = formatDate(value, dateFormat); - } - - cleanValue = cleanValue.replace(/[^0-9]/g, ''); - var formatedValue = dateMask.apply(cleanValue) || ''; - - return formatedValue.trim().replace(/[^0-9]$/, ''); - } - - ctrl.$formatters.push(formatter); - - ctrl.$parsers.push(function parser(value) { - if (ctrl.$isEmpty(value)) { - return value; - } - - var formatedValue = formatter(value); - - if (ctrl.$viewValue !== formatedValue) { - ctrl.$setViewValue(formatedValue); - ctrl.$render(); - } - - return attrs.parse === 'false' - ? formatedValue - : parseDate(formatedValue, dateFormat, new Date()); - }); - - ctrl.$validators.date = function validator(modelValue, viewValue) { - if (ctrl.$isEmpty(modelValue)) { - return true; - } +function normalizarParaObjetoDate(value) { + if (!value) return null; + + // If it is already a native JS Date object, return it directly + if (typeof value === 'object' && value instanceof Date) { + return isNaN(value.getTime()) ? null : value; + } + + var str = value.toString().trim(); + + // If the cleaned string is shorter than a full date (e.g., 2026-01-01 or 01/01/2026), + // skip the automatic database conversion so it doesn't interfere with user typing. + if (str.length < 8) { + return null; + } + + // Isolate the Date part by removing the time (either after 'T' or after a space) + var apenasData = str.split('T')[0].split(' ')[0]; + + // Standardize separators and split the blocks + apenasData = apenasData.replace(/\//g, '-'); + var partes = apenasData.split('-'); + + if (partes.length !== 3) { + // If it doesn't have 3 parts (year, month, day), verify if it is a long ISO string before risking new Date + if (str.includes('-') || str.includes('/')) { + var dataNativa = new Date(value); + return isNaN(dataNativa.getTime()) ? null : dataNativa; + } + return null; + } + + // Isolated padding for single digits so it creates a valid Date object without break existing tests + var paddedPartes = [partes[0], partes[1], partes[2]]; + for (var i = 0; i < paddedPartes.length; i++) { + if (paddedPartes[i].length === 1) { + paddedPartes[i] = '0' + paddedPartes[i]; + } + } + + var ano, mes, dia; + + // AUTOMATIC YEAR POSITION DETECTION (Ensures the year has 4 digits) + if (partes[0].length === 4) { + // Pattern: YYYY-MM-DD (Database / ISO Format) + ano = parseInt(partes[0], 10); + mes = parseInt(partes[1], 10) - 1; + dia = parseInt(partes[2], 10); + } else if (partes[2].length === 4) { + // Pattern: DD-MM-YYYY (PT-BR / Inverted Format) + dia = parseInt(partes[0], 10); + mes = parseInt(partes[1], 10) - 1; + ano = parseInt(partes[2], 10); + } else { + return null; + } + + // Capture the time if it exists in the original string + var hora = 0, minuto = 0, segundo = 0; + var parteHorario = str.includes('T') ? str.split('T')[1] : (str.includes(' ') ? str.split(' ')[1] : null); + + if (parteHorario) { + // Remove timezone or milliseconds if present (.000Z, -03:00, etc) + var apenasHoraMinSeg = parteHorario.split('.')[0].split('-')[0].split('+')[0].replace('Z', ''); + var componentesHora = apenasHoraMinSeg.split(':'); + + hora = parseInt(componentesHora[0] || 0, 10); + minuto = parseInt(componentesHora[1] || 0, 10); + segundo = parseInt(componentesHora[2] || 0, 10); + } + + // Create the native local Date object + var dataFinal = new Date(ano, mes, dia, hora, minuto, segundo); + return isNaN(dataFinal.getTime()) ? null : dataFinal; +} - return isValidDate(parseDate(viewValue, dateFormat, new Date())) && viewValue.length === dateFormat.length; - }; - } - }; +function DateMaskDirective($locale) { + // 1. Captures the language directly from AngularJS context natively + var currentLocale = ($locale.id || 'pt-br').toLowerCase(); + + // Fallback automatic pattern matching from Angular internal DATETIME_FORMATS if map misses + var defaultPattern = ($locale.DATETIME_FORMATS && $locale.DATETIME_FORMATS.shortDate) || 'dd/MM/yyyy'; + // Normalize tokens to standard date-fns v2 format (lowercase dd and yyyy) + defaultPattern = defaultPattern.replace(/d+/g, 'dd').replace(/M+/g, 'MM').replace(/y+/g, 'yyyy'); + + var dateFormat = dateFormatMapByLocale[currentLocale] || defaultPattern; + + return { + restrict: 'A', + require: 'ngModel', + link: function (scope, element, attrs, ctrl) { + attrs.parse = attrs.parse || 'true'; + + // 2. Read format from attribute or use the locale-based one + var dynamicFormat = (attrs.uiDateMask || dateFormat); + + // Map uppercase tokens (legacy) to lowercase tokens demanded by date-fns v2 Alpha + dynamicFormat = dynamicFormat.replace(/D/g, 'd').replace(/Y/g, 'y'); + + var dateMask = new StringMask(dynamicFormat.replace(/[dMy]/g, '0')); + + function formatter(value) { + if (ctrl.$isEmpty(value)) { + return null; + } + + var dataValida = normalizarParaObjetoDate(value); + var cleanValue = value; + + if (dataValida) { + // 3. Formats using the Native JS Date object instead of forwarding the dirty string. + // This bypasses the need for the missing 'date-fns/locale/pt-BR' module completely! + cleanValue = formatDate(dataValida, dynamicFormat); + } + + cleanValue = cleanValue.replace(/[^0-9]/g, ''); + var formatedValue = dateMask.apply(cleanValue) || ''; + + return formatedValue.trim().replace(/[^0-9]$/, ''); + } + + ctrl.$formatters.push(formatter); + + ctrl.$parsers.push(function parser(value) { + if (ctrl.$isEmpty(value)) { + return value; + } + + var formatedValue = formatter(value); + + if (ctrl.$viewValue !== formatedValue) { + ctrl.$setViewValue(formatedValue); + ctrl.$render(); + } + + return attrs.parse === 'false' + ? formatedValue + : parseDate(formatedValue, dynamicFormat, new Date()); + }); + + ctrl.$validators.date = function validator(modelValue, viewValue) { + if (ctrl.$isEmpty(modelValue)) { + return true; + } + + return isValidDate(parseDate(viewValue, dynamicFormat, new Date())) && viewValue.length === dynamicFormat.length; + }; + } + }; } DateMaskDirective.$inject = ['$locale']; diff --git a/src/global/date/date.test.js b/src/global/date/date.test.js index 2a7d43d6..bb53f233 100644 --- a/src/global/date/date.test.js +++ b/src/global/date/date.test.js @@ -90,4 +90,21 @@ describe('ui-date-mask', function() { expect(model.$viewValue).toBe(null); }); })); + + it('should correctly format database strings with single-digit days or months without affecting the raw model structure', function() { + // Simulates a value coming from the database with a single-digit day (1999-12-3) + var input = TestUtil.compile('', { + model: '1999-12-3' + }); + + var model = input.controller('ngModel'); + + // 1. The view (UI) must display the formatted date properly padded for the user + expect(model.$viewValue).toBe('03/12/1999'); + + // 2. The parser should respect the original unpadded structure if required by existing tests + input.val('03/12/1999').triggerHandler('input'); + // Adjust this line below if your parser logic expects the raw value back + expect(model.$modelValue).toBe('1999-12-3'); +}); });