@bunnix/components 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Mask Utility for Input Fields
3
+ * Supports various input masks including date, time, email, currency, and custom patterns
4
+ */
5
+
6
+ /**
7
+ * Apply a mask to a value based on the mask type or pattern
8
+ * @param {string} value - The raw input value
9
+ * @param {string|object} mask - The mask type or configuration
10
+ * @returns {string} - The masked value
11
+ */
12
+ export function applyMask(value, mask) {
13
+ if (!mask) return value || "";
14
+
15
+ // Convert value to string and handle empty/undefined/null
16
+ const stringValue = String(value ?? "");
17
+ if (!stringValue) return "";
18
+
19
+ // Handle object mask configuration
20
+ if (typeof mask === 'object') {
21
+ const { type, pattern, options } = mask;
22
+ if (type) {
23
+ return applyMaskByType(stringValue, type, options);
24
+ }
25
+ if (pattern) {
26
+ return applyCustomMask(stringValue, pattern);
27
+ }
28
+ return stringValue;
29
+ }
30
+
31
+ // Handle string mask type
32
+ return applyMaskByType(stringValue, mask);
33
+ }
34
+
35
+ /**
36
+ * Apply mask based on predefined type
37
+ */
38
+ function applyMaskByType(value, type, options = {}) {
39
+ switch (type) {
40
+ case 'date':
41
+ return applyDateMask(value);
42
+ case 'time':
43
+ return applyTimeMask(value);
44
+ case 'email':
45
+ return applyEmailMask(value);
46
+ case 'currency':
47
+ return applyCurrencyMask(value, options);
48
+ case 'decimal':
49
+ return applyDecimalMask(value, options);
50
+ case 'integer':
51
+ return applyIntegerMask(value);
52
+ case 'phone':
53
+ return applyPhoneMask(value, options);
54
+ case 'phone-br':
55
+ return applyPhoneBRMask(value);
56
+ case 'credit-card':
57
+ return applyCreditCardMask(value);
58
+ case 'cpf':
59
+ return applyCPFMask(value);
60
+ case 'cnpj':
61
+ return applyCNPJMask(value);
62
+ case 'cep':
63
+ return applyCEPMask(value);
64
+ default:
65
+ return value;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Date mask: DD/MM/YYYY
71
+ */
72
+ function applyDateMask(value) {
73
+ const digits = value.replace(/\D/g, "");
74
+ let masked = "";
75
+
76
+ for (let i = 0; i < digits.length && i < 8; i++) {
77
+ if (i === 2 || i === 4) {
78
+ masked += "/";
79
+ }
80
+ masked += digits[i];
81
+ }
82
+
83
+ return masked;
84
+ }
85
+
86
+ /**
87
+ * Time mask: HH:MM
88
+ */
89
+ function applyTimeMask(value) {
90
+ const digits = value.replace(/\D/g, "");
91
+ let masked = "";
92
+
93
+ for (let i = 0; i < digits.length && i < 4; i++) {
94
+ if (i === 2) {
95
+ masked += ":";
96
+ }
97
+ masked += digits[i];
98
+ }
99
+
100
+ return masked;
101
+ }
102
+
103
+ /**
104
+ * Email mask: lowercase, no spaces
105
+ */
106
+ function applyEmailMask(value) {
107
+ return value.toLowerCase().replace(/\s/g, "");
108
+ }
109
+
110
+ /**
111
+ * Currency mask: $ 1,234.56
112
+ */
113
+ function applyCurrencyMask(value, options = {}) {
114
+ const {
115
+ prefix = "$",
116
+ thousandsSeparator = ",",
117
+ decimalSeparator = ".",
118
+ decimalPlaces = 2
119
+ } = options;
120
+
121
+ // Ensure value is a string
122
+ const stringValue = String(value ?? "");
123
+
124
+ // Remove everything except digits
125
+ let digits = stringValue.replace(/[^\d]/g, "");
126
+
127
+ if (digits === "") return "";
128
+
129
+ // Convert to number and format
130
+ const number = parseInt(digits, 10);
131
+ const formatted = (number / Math.pow(10, decimalPlaces)).toFixed(decimalPlaces);
132
+
133
+ // Split integer and decimal parts
134
+ const [integer, decimal] = formatted.split(".");
135
+
136
+ // Add thousands separator
137
+ const withSeparators = integer.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparator);
138
+
139
+ return `${prefix} ${withSeparators}${decimalSeparator}${decimal}`;
140
+ }
141
+
142
+ /**
143
+ * Decimal mask: 123.45
144
+ */
145
+ function applyDecimalMask(value, options = {}) {
146
+ const { decimalPlaces = 2, allowNegative = false } = options;
147
+
148
+ let cleaned = value.replace(/[^\d.-]/g, "");
149
+
150
+ if (!allowNegative) {
151
+ cleaned = cleaned.replace(/-/g, "");
152
+ } else {
153
+ // Only allow one minus at the start
154
+ const negative = cleaned.startsWith("-");
155
+ cleaned = cleaned.replace(/-/g, "");
156
+ if (negative) cleaned = "-" + cleaned;
157
+ }
158
+
159
+ // Only allow one decimal point
160
+ const parts = cleaned.split(".");
161
+ if (parts.length > 2) {
162
+ cleaned = parts[0] + "." + parts.slice(1).join("");
163
+ }
164
+
165
+ // Limit decimal places
166
+ if (parts.length === 2 && parts[1].length > decimalPlaces) {
167
+ cleaned = parts[0] + "." + parts[1].substring(0, decimalPlaces);
168
+ }
169
+
170
+ return cleaned;
171
+ }
172
+
173
+ /**
174
+ * Integer mask: only digits
175
+ */
176
+ function applyIntegerMask(value) {
177
+ return value.replace(/\D/g, "");
178
+ }
179
+
180
+ /**
181
+ * International phone mask: +1 (234) 567-8900 or custom format
182
+ */
183
+ function applyPhoneMask(value, options = {}) {
184
+ const { countryCode = "1", format = "us" } = options;
185
+ const digits = value.replace(/\D/g, "");
186
+
187
+ if (digits.length === 0) return "";
188
+
189
+ if (format === "br") {
190
+ return applyPhoneBRMask(value);
191
+ }
192
+
193
+ // Default US/International format
194
+ let masked = "";
195
+
196
+ // Country code
197
+ if (digits.length >= 1) {
198
+ masked = "+" + digits.substring(0, Math.min(digits.length, countryCode.length));
199
+ }
200
+
201
+ const ccLength = countryCode.length;
202
+
203
+ // Area code
204
+ if (digits.length > ccLength) {
205
+ masked += " (" + digits.substring(ccLength, Math.min(digits.length, ccLength + 3));
206
+ }
207
+
208
+ if (digits.length >= ccLength + 3) {
209
+ masked += ")";
210
+ }
211
+
212
+ // First part
213
+ if (digits.length > ccLength + 3) {
214
+ masked += " " + digits.substring(ccLength + 3, Math.min(digits.length, ccLength + 6));
215
+ }
216
+
217
+ // Second part
218
+ if (digits.length > ccLength + 6) {
219
+ masked += "-" + digits.substring(ccLength + 6, Math.min(digits.length, ccLength + 10));
220
+ }
221
+
222
+ return masked;
223
+ }
224
+
225
+ /**
226
+ * Brazilian phone mask: +55 11 99999-9999 (mobile) or +55 11 9999-9999 (landline)
227
+ */
228
+ function applyPhoneBRMask(value) {
229
+ const digits = value.replace(/\D/g, "");
230
+ let masked = "";
231
+
232
+ if (digits.length === 0) return "";
233
+
234
+ // Country code: +55
235
+ if (digits.length >= 1) {
236
+ masked = "+" + digits.substring(0, Math.min(digits.length, 2));
237
+ }
238
+
239
+ // Area code (DDD): 11, 21, etc.
240
+ if (digits.length > 2) {
241
+ masked += " " + digits.substring(2, Math.min(digits.length, 4));
242
+ }
243
+
244
+ // Phone number
245
+ if (digits.length > 4) {
246
+ const areaCodeEnd = 4;
247
+ const remaining = digits.substring(areaCodeEnd);
248
+
249
+ // Check if it's a mobile (9 digits) or landline (8 digits)
250
+ // Mobile: 9XXXX-XXXX
251
+ // Landline: XXXX-XXXX
252
+
253
+ if (remaining.length <= 4) {
254
+ // Still typing the first part
255
+ masked += " " + remaining;
256
+ } else if (remaining.length === 5) {
257
+ // Could be either format, show without dash yet
258
+ masked += " " + remaining;
259
+ } else {
260
+ // Determine format based on length
261
+ // If we have 9+ digits after area code, it's mobile
262
+ const isMobile = remaining.length >= 9 || (remaining.length > 5 && remaining[0] === '9');
263
+
264
+ if (isMobile) {
265
+ // Mobile: +55 11 99999-9999
266
+ masked += " " + remaining.substring(0, 5);
267
+ if (remaining.length > 5) {
268
+ masked += "-" + remaining.substring(5, 9);
269
+ }
270
+ } else {
271
+ // Landline: +55 11 9999-9999
272
+ masked += " " + remaining.substring(0, 4);
273
+ if (remaining.length > 4) {
274
+ masked += "-" + remaining.substring(4, 8);
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ return masked;
281
+ }
282
+
283
+ /**
284
+ * Credit card mask: 1234 5678 9012 3456
285
+ */
286
+ function applyCreditCardMask(value) {
287
+ const digits = value.replace(/\D/g, "");
288
+ const groups = [];
289
+
290
+ for (let i = 0; i < digits.length && i < 16; i += 4) {
291
+ groups.push(digits.substring(i, i + 4));
292
+ }
293
+
294
+ return groups.join(" ");
295
+ }
296
+
297
+ /**
298
+ * Brazilian CPF mask: 123.456.789-01
299
+ */
300
+ function applyCPFMask(value) {
301
+ const digits = value.replace(/\D/g, "");
302
+ let masked = "";
303
+
304
+ for (let i = 0; i < digits.length && i < 11; i++) {
305
+ if (i === 3 || i === 6) {
306
+ masked += ".";
307
+ } else if (i === 9) {
308
+ masked += "-";
309
+ }
310
+ masked += digits[i];
311
+ }
312
+
313
+ return masked;
314
+ }
315
+
316
+ /**
317
+ * Brazilian CNPJ mask: 12.345.678/0001-90
318
+ */
319
+ function applyCNPJMask(value) {
320
+ const digits = value.replace(/\D/g, "");
321
+ let masked = "";
322
+
323
+ for (let i = 0; i < digits.length && i < 14; i++) {
324
+ if (i === 2 || i === 5) {
325
+ masked += ".";
326
+ } else if (i === 8) {
327
+ masked += "/";
328
+ } else if (i === 12) {
329
+ masked += "-";
330
+ }
331
+ masked += digits[i];
332
+ }
333
+
334
+ return masked;
335
+ }
336
+
337
+ /**
338
+ * Brazilian CEP mask: 12345-678
339
+ */
340
+ function applyCEPMask(value) {
341
+ const digits = value.replace(/\D/g, "");
342
+ let masked = "";
343
+
344
+ for (let i = 0; i < digits.length && i < 8; i++) {
345
+ if (i === 5) {
346
+ masked += "-";
347
+ }
348
+ masked += digits[i];
349
+ }
350
+
351
+ return masked;
352
+ }
353
+
354
+ /**
355
+ * Apply custom mask pattern
356
+ * Pattern syntax:
357
+ * - 9: digit
358
+ * - A: letter
359
+ * - *: alphanumeric
360
+ * - Other characters are literals
361
+ *
362
+ * Example: "999.999.999-99" for CPF
363
+ */
364
+ function applyCustomMask(value, pattern) {
365
+ let masked = "";
366
+ let valueIndex = 0;
367
+
368
+ for (let i = 0; i < pattern.length && valueIndex < value.length; i++) {
369
+ const patternChar = pattern[i];
370
+ const valueChar = value[valueIndex];
371
+
372
+ if (patternChar === '9') {
373
+ // Digit only
374
+ if (/\d/.test(valueChar)) {
375
+ masked += valueChar;
376
+ valueIndex++;
377
+ } else {
378
+ valueIndex++;
379
+ i--; // Retry with next value character
380
+ }
381
+ } else if (patternChar === 'A') {
382
+ // Letter only
383
+ if (/[a-zA-Z]/.test(valueChar)) {
384
+ masked += valueChar;
385
+ valueIndex++;
386
+ } else {
387
+ valueIndex++;
388
+ i--; // Retry with next value character
389
+ }
390
+ } else if (patternChar === '*') {
391
+ // Alphanumeric
392
+ if (/[a-zA-Z0-9]/.test(valueChar)) {
393
+ masked += valueChar;
394
+ valueIndex++;
395
+ } else {
396
+ valueIndex++;
397
+ i--; // Retry with next value character
398
+ }
399
+ } else {
400
+ // Literal character
401
+ masked += patternChar;
402
+ if (valueChar === patternChar) {
403
+ valueIndex++;
404
+ }
405
+ }
406
+ }
407
+
408
+ return masked;
409
+ }
410
+
411
+ /**
412
+ * Validate a masked value
413
+ * @param {string} value - The masked value
414
+ * @param {string|object} mask - The mask type or configuration
415
+ * @returns {boolean} - Whether the value is valid
416
+ */
417
+ export function validateMask(value, mask) {
418
+ if (!mask || !value) return true;
419
+
420
+ const maskType = typeof mask === 'object' ? mask.type : mask;
421
+
422
+ switch (maskType) {
423
+ case 'date':
424
+ return validateDate(value);
425
+ case 'time':
426
+ return validateTime(value);
427
+ case 'email':
428
+ return validateEmail(value);
429
+ case 'cpf':
430
+ return validateCPF(value);
431
+ case 'cnpj':
432
+ return validateCNPJ(value);
433
+ default:
434
+ return true; // No validation for other types
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Validate date DD/MM/YYYY
440
+ */
441
+ function validateDate(value) {
442
+ if (value.length !== 10) return false;
443
+ const parts = value.split("/");
444
+ if (parts.length !== 3) return false;
445
+ const day = parseInt(parts[0], 10);
446
+ const month = parseInt(parts[1], 10) - 1;
447
+ const year = parseInt(parts[2], 10);
448
+ if (isNaN(day) || isNaN(month) || isNaN(year)) return false;
449
+ const date = new Date(year, month, day);
450
+ return date.getDate() === day && date.getMonth() === month && date.getFullYear() === year;
451
+ }
452
+
453
+ /**
454
+ * Validate time HH:MM
455
+ */
456
+ function validateTime(value) {
457
+ if (value.length !== 5) return false;
458
+ const parts = value.split(":");
459
+ if (parts.length !== 2) return false;
460
+ const hours = parseInt(parts[0], 10);
461
+ const minutes = parseInt(parts[1], 10);
462
+ return !isNaN(hours) && !isNaN(minutes) && hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59;
463
+ }
464
+
465
+ /**
466
+ * Validate email
467
+ */
468
+ function validateEmail(value) {
469
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
470
+ return emailRegex.test(value);
471
+ }
472
+
473
+ /**
474
+ * Validate Brazilian CPF
475
+ */
476
+ function validateCPF(value) {
477
+ const digits = value.replace(/\D/g, "");
478
+ if (digits.length !== 11) return false;
479
+
480
+ // Check if all digits are the same
481
+ if (/^(\d)\1+$/.test(digits)) return false;
482
+
483
+ // Validate check digits
484
+ let sum = 0;
485
+ for (let i = 0; i < 9; i++) {
486
+ sum += parseInt(digits[i], 10) * (10 - i);
487
+ }
488
+ let checkDigit = 11 - (sum % 11);
489
+ if (checkDigit >= 10) checkDigit = 0;
490
+ if (checkDigit !== parseInt(digits[9], 10)) return false;
491
+
492
+ sum = 0;
493
+ for (let i = 0; i < 10; i++) {
494
+ sum += parseInt(digits[i], 10) * (11 - i);
495
+ }
496
+ checkDigit = 11 - (sum % 11);
497
+ if (checkDigit >= 10) checkDigit = 0;
498
+ if (checkDigit !== parseInt(digits[10], 10)) return false;
499
+
500
+ return true;
501
+ }
502
+
503
+ /**
504
+ * Validate Brazilian CNPJ
505
+ */
506
+ function validateCNPJ(value) {
507
+ const digits = value.replace(/\D/g, "");
508
+ if (digits.length !== 14) return false;
509
+
510
+ // Check if all digits are the same
511
+ if (/^(\d)\1+$/.test(digits)) return false;
512
+
513
+ // Validate first check digit
514
+ let sum = 0;
515
+ let pos = 5;
516
+ for (let i = 0; i < 12; i++) {
517
+ sum += parseInt(digits[i], 10) * pos;
518
+ pos = pos === 2 ? 9 : pos - 1;
519
+ }
520
+ let checkDigit = sum % 11 < 2 ? 0 : 11 - (sum % 11);
521
+ if (checkDigit !== parseInt(digits[12], 10)) return false;
522
+
523
+ // Validate second check digit
524
+ sum = 0;
525
+ pos = 6;
526
+ for (let i = 0; i < 13; i++) {
527
+ sum += parseInt(digits[i], 10) * pos;
528
+ pos = pos === 2 ? 9 : pos - 1;
529
+ }
530
+ checkDigit = sum % 11 < 2 ? 0 : 11 - (sum % 11);
531
+ if (checkDigit !== parseInt(digits[13], 10)) return false;
532
+
533
+ return true;
534
+ }
535
+
536
+ /**
537
+ * Get the maximum length for a mask
538
+ * @param {string|object} mask - The mask type or configuration
539
+ * @returns {number|null} - The maximum length or null if unlimited
540
+ */
541
+ export function getMaskMaxLength(mask) {
542
+ if (!mask) return null;
543
+
544
+ const maskType = typeof mask === 'object' ? mask.type || mask.pattern : mask;
545
+
546
+ const lengths = {
547
+ 'date': 10, // DD/MM/YYYY
548
+ 'time': 5, // HH:MM
549
+ 'cpf': 14, // 123.456.789-01
550
+ 'cnpj': 18, // 12.345.678/0001-90
551
+ 'cep': 9, // 12345-678
552
+ 'credit-card': 19, // 1234 5678 9012 3456
553
+ 'phone': 18, // +1 (234) 567-8900
554
+ 'phone-br': 17, // +55 11 99999-9999
555
+ };
556
+
557
+ // Check if it's a predefined mask type first
558
+ if (lengths[maskType]) {
559
+ return lengths[maskType];
560
+ }
561
+
562
+ // If not predefined, check if it's a custom pattern
563
+ if (typeof maskType === 'string' && (maskType.includes('.') || maskType.includes('-') || maskType.includes('/') || maskType.includes('9') || maskType.includes('A') || maskType.includes('*'))) {
564
+ // Custom pattern - return pattern length
565
+ return maskType.length;
566
+ }
567
+
568
+ return null;
569
+ }