ngx-digits-only 1.0.3 → 1.1.0
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.
- package/README.md +382 -52
- package/fesm2022/ngx-digits-only.mjs +105 -56
- package/fesm2022/ngx-digits-only.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-digits-only.d.ts +43 -13
|
@@ -923,7 +923,15 @@ class DigitsOnlyDirective {
|
|
|
923
923
|
renderer;
|
|
924
924
|
document;
|
|
925
925
|
// ─── Public inputs ──────────────────────────────────────────────────────────
|
|
926
|
-
/**
|
|
926
|
+
/**
|
|
927
|
+
* How many decimal places are allowed. Ignored when pattern is set.
|
|
928
|
+
*
|
|
929
|
+
* 0 (default) — integers only, decimal point blocked
|
|
930
|
+
* N — exactly N decimal places allowed (e.g. 2 for currency)
|
|
931
|
+
* null — unlimited decimal places allowed
|
|
932
|
+
*
|
|
933
|
+
* Matches the same null = "no limit" convention used by maxLength, min, and max.
|
|
934
|
+
*/
|
|
927
935
|
decimalPlaces = 0;
|
|
928
936
|
/** Visual grouping character inserted between digit groups. Ignored when pattern is set. */
|
|
929
937
|
thousandSeparator = '';
|
|
@@ -1093,14 +1101,6 @@ class DigitsOnlyDirective {
|
|
|
1093
1101
|
? LRM.length + this.suffix.length // RTL: [LRM][suffix][number][prefix]
|
|
1094
1102
|
: this.prefix.length; // LTR: [prefix][number][suffix]
|
|
1095
1103
|
}
|
|
1096
|
-
/**
|
|
1097
|
-
* The number of characters at the RIGHT end of the display string that
|
|
1098
|
-
* belong to the suffix (LTR) or prefix (RTL) — i.e. characters the cursor
|
|
1099
|
-
* must never move past on the right side.
|
|
1100
|
-
*/
|
|
1101
|
-
get editableEndPadding() {
|
|
1102
|
-
return this.isRtl ? this.prefix.length : this.suffix.length;
|
|
1103
|
-
}
|
|
1104
1104
|
constructor(el, renderer, document) {
|
|
1105
1105
|
this.el = el;
|
|
1106
1106
|
this.renderer = renderer;
|
|
@@ -1108,7 +1108,7 @@ class DigitsOnlyDirective {
|
|
|
1108
1108
|
}
|
|
1109
1109
|
// ─── Angular lifecycle ────────────────────────────────────────────────────────
|
|
1110
1110
|
ngOnInit() {
|
|
1111
|
-
// Always use numeric
|
|
1111
|
+
// Always use numeric inputmode so mobile keyboards show the number pad
|
|
1112
1112
|
// regardless of document direction.
|
|
1113
1113
|
this.renderer.setAttribute(this.el.nativeElement, 'inputmode', 'numeric');
|
|
1114
1114
|
// Render the initial empty state into the input element.
|
|
@@ -1242,7 +1242,10 @@ class DigitsOnlyDirective {
|
|
|
1242
1242
|
}
|
|
1243
1243
|
// ── Allow decimal point (number mode only, no pattern) ────────────────
|
|
1244
1244
|
const isDecimalKey = pressedKey === '.' || pressedKey === ',';
|
|
1245
|
-
|
|
1245
|
+
// decimalPlaces === 0 → integers only, block the dot
|
|
1246
|
+
// decimalPlaces > 0 → allow up to N decimal places
|
|
1247
|
+
// decimalPlaces null → allow unlimited decimal places
|
|
1248
|
+
const decimalIsAllowed = this.decimalPlaces !== 0 && !this.hasPattern;
|
|
1246
1249
|
const noDecimalYet = !this._rawValue.includes('.');
|
|
1247
1250
|
if (isDecimalKey && decimalIsAllowed && noDecimalYet) {
|
|
1248
1251
|
return; // allow the decimal separator through
|
|
@@ -1290,8 +1293,6 @@ class DigitsOnlyDirective {
|
|
|
1290
1293
|
*/
|
|
1291
1294
|
onInput() {
|
|
1292
1295
|
const inputEl = this.el.nativeElement;
|
|
1293
|
-
const cursorPositionBeforeReformat = inputEl.selectionStart ?? 0;
|
|
1294
|
-
const displayLengthBeforeReformat = inputEl.value.length;
|
|
1295
1296
|
// Convert Eastern numerals FIRST, before anything else touches the value
|
|
1296
1297
|
if (this.convertEasternNumerals) {
|
|
1297
1298
|
const converted = convertEasternDigits(inputEl.value);
|
|
@@ -1312,11 +1313,13 @@ class DigitsOnlyDirective {
|
|
|
1312
1313
|
// Non-pattern mode: normalize decimal separator
|
|
1313
1314
|
raw = raw.replace(',', '.');
|
|
1314
1315
|
if (this.decimalPlaces === 0 || this.effectiveOutputType === 'string') {
|
|
1315
|
-
//
|
|
1316
|
+
// Integers only or string mode — remove any decimal point
|
|
1316
1317
|
raw = raw.replace(REGEX.ALL_DOTS, '');
|
|
1317
1318
|
}
|
|
1318
1319
|
else {
|
|
1319
|
-
// Decimal mode
|
|
1320
|
+
// Decimal mode (decimalPlaces = N or null):
|
|
1321
|
+
// enforceDecimalRules handles both — when decimalPlaces is null it
|
|
1322
|
+
// only enforces the single-dot rule without truncating decimal digits.
|
|
1320
1323
|
raw = this.enforceDecimalRules(raw);
|
|
1321
1324
|
}
|
|
1322
1325
|
// Strip leading zeros unless explicitly preserved
|
|
@@ -1326,7 +1329,7 @@ class DigitsOnlyDirective {
|
|
|
1326
1329
|
}
|
|
1327
1330
|
}
|
|
1328
1331
|
this._rawValue = raw;
|
|
1329
|
-
this.refreshDisplay(
|
|
1332
|
+
this.refreshDisplay();
|
|
1330
1333
|
this._onChange(this.buildModelValue());
|
|
1331
1334
|
this._onValidatorChange();
|
|
1332
1335
|
}
|
|
@@ -1368,13 +1371,14 @@ class DigitsOnlyDirective {
|
|
|
1368
1371
|
if (sanitized === '')
|
|
1369
1372
|
return;
|
|
1370
1373
|
const inputEl = this.el.nativeElement;
|
|
1371
|
-
// Figure out where the cursor/selection is inside the RAW digit string
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1374
|
+
// Figure out where the cursor/selection is inside the RAW digit string.
|
|
1375
|
+
//
|
|
1376
|
+
// In both pattern and non-pattern mode we go through mapDisplayCursorToRawIndex
|
|
1377
|
+
// which now correctly subtracts editableStartOffset (handles RTL/LTR decoration).
|
|
1378
|
+
// Previously non-pattern mode used the raw selectionStart directly, which was
|
|
1379
|
+
// wrong in RTL because the LRM + suffix offset was not subtracted.
|
|
1380
|
+
const rawInsertStart = this.mapDisplayCursorToRawIndex(inputEl.selectionStart ?? 0);
|
|
1381
|
+
const rawInsertEnd = this.mapDisplayCursorToRawIndex(inputEl.selectionEnd ?? 0);
|
|
1378
1382
|
// Insert sanitized content into the raw value at the cursor position
|
|
1379
1383
|
const beforeCursor = this._rawValue.slice(0, rawInsertStart);
|
|
1380
1384
|
const afterCursor = this._rawValue.slice(rawInsertEnd);
|
|
@@ -1443,13 +1447,13 @@ class DigitsOnlyDirective {
|
|
|
1443
1447
|
return isNaN(num) ? null : num;
|
|
1444
1448
|
}
|
|
1445
1449
|
/**
|
|
1446
|
-
* Write the formatted display string back to the <input> DOM element
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1450
|
+
* Write the formatted display string back to the <input> DOM element.
|
|
1451
|
+
* Cursor placement is left entirely to the browser — we never call
|
|
1452
|
+
* setSelectionRange here. The browser naturally keeps the cursor where
|
|
1453
|
+
* the user last interacted, which is the correct behaviour for both
|
|
1454
|
+
* LTR and RTL inputs.
|
|
1451
1455
|
*/
|
|
1452
|
-
refreshDisplay(
|
|
1456
|
+
refreshDisplay() {
|
|
1453
1457
|
// ── Step 1: format the number portion ─────────────────────────────────
|
|
1454
1458
|
const formattedContent = this.hasPattern
|
|
1455
1459
|
? this.applyPatternFormatting(this._rawValue)
|
|
@@ -1458,8 +1462,26 @@ class DigitsOnlyDirective {
|
|
|
1458
1462
|
: this.applyThousandsFormatting(this._rawValue); // number mode: comma groups
|
|
1459
1463
|
// ── Step 2: assemble the full display string ───────────────────────────
|
|
1460
1464
|
//
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1465
|
+
// EMPTY STATE — write a true empty string when there is no number content.
|
|
1466
|
+
//
|
|
1467
|
+
// WHY THIS IS CRITICAL FOR ANGULAR MATERIAL:
|
|
1468
|
+
// MatInput determines whether to float the label by checking if the
|
|
1469
|
+
// native input value is empty (''). If we write prefix/suffix into
|
|
1470
|
+
// the DOM even when the field is logically empty (e.g. just '$' or
|
|
1471
|
+
// '\u200E﷼'), Material sees a non-empty value and floats the label
|
|
1472
|
+
// permanently — even though the user has typed nothing.
|
|
1473
|
+
//
|
|
1474
|
+
// Fix: only wrap with prefix/suffix/LRM when formattedContent is
|
|
1475
|
+
// non-empty. When empty, write '' so Material's empty check passes.
|
|
1476
|
+
//
|
|
1477
|
+
// NOTE ON PREFIX/SUFFIX UX:
|
|
1478
|
+
// The prefix and suffix appear as soon as the user starts typing and
|
|
1479
|
+
// disappear when the field is cleared. This matches how every
|
|
1480
|
+
// Angular Material currency/unit input is expected to behave.
|
|
1481
|
+
//
|
|
1482
|
+
// RTL layout (non-empty): [LRM][suffix][formattedNumber][prefix]
|
|
1483
|
+
// LTR layout (non-empty): [prefix][formattedNumber][suffix]
|
|
1484
|
+
// Either layout (empty): ''
|
|
1463
1485
|
//
|
|
1464
1486
|
// WHY SWAP PREFIX AND SUFFIX IN RTL?
|
|
1465
1487
|
// In RTL reading order, the "start" of a line is the right side.
|
|
@@ -1473,21 +1495,13 @@ class DigitsOnlyDirective {
|
|
|
1473
1495
|
// Prevents the bidi algorithm from treating the numeric content as RTL
|
|
1474
1496
|
// text and mirroring digit order or separator placement.
|
|
1475
1497
|
// The LRM is stripped before the value reaches the model.
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1498
|
+
const isEmpty = formattedContent === '' || formattedContent === '-';
|
|
1499
|
+
const fullDisplayValue = isEmpty
|
|
1500
|
+
? formattedContent // '' or '-' — no decoration
|
|
1501
|
+
: this.isRtl
|
|
1502
|
+
? LRM + this.suffix + formattedContent + this.prefix // RTL non-empty
|
|
1503
|
+
: this.prefix + formattedContent + this.suffix; // LTR non-empty
|
|
1479
1504
|
this.renderer.setProperty(this.el.nativeElement, 'value', fullDisplayValue);
|
|
1480
|
-
// ── Step 3: restore cursor position ───────────────────────────────────
|
|
1481
|
-
if (cursorBefore !== undefined && lengthBefore !== undefined) {
|
|
1482
|
-
const lengthDelta = fullDisplayValue.length - lengthBefore;
|
|
1483
|
-
// Clamp cursor between the editable start and end offsets,
|
|
1484
|
-
// both of which account for the RTL/LTR decoration layout.
|
|
1485
|
-
const newCursorPos = Math.max(this.editableStartOffset, Math.min(cursorBefore + lengthDelta, fullDisplayValue.length - this.editableEndPadding));
|
|
1486
|
-
// Defer to next animation frame so the browser has committed the new value
|
|
1487
|
-
requestAnimationFrame(() => {
|
|
1488
|
-
this.el.nativeElement.setSelectionRange(newCursorPos, newCursorPos);
|
|
1489
|
-
});
|
|
1490
|
-
}
|
|
1491
1505
|
}
|
|
1492
1506
|
/**
|
|
1493
1507
|
* Apply the display format pattern to the current raw digit string.
|
|
@@ -1638,14 +1652,17 @@ class DigitsOnlyDirective {
|
|
|
1638
1652
|
*/
|
|
1639
1653
|
enforceDecimalRules(raw) {
|
|
1640
1654
|
const parts = raw.split('.');
|
|
1641
|
-
//
|
|
1655
|
+
// Always enforce: at most one decimal point
|
|
1642
1656
|
if (parts.length > 2) {
|
|
1643
1657
|
raw = parts[0] + '.' + parts.slice(1).join('');
|
|
1644
1658
|
}
|
|
1645
|
-
//
|
|
1646
|
-
|
|
1647
|
-
if (
|
|
1648
|
-
|
|
1659
|
+
// Only truncate decimal digits when decimalPlaces is a finite number.
|
|
1660
|
+
// When decimalPlaces is null the user may type as many decimals as they want.
|
|
1661
|
+
if (this.decimalPlaces !== null) {
|
|
1662
|
+
const [intPart, decPart] = raw.split('.');
|
|
1663
|
+
if (decPart !== undefined && decPart.length > this.decimalPlaces) {
|
|
1664
|
+
raw = intPart + '.' + decPart.slice(0, this.decimalPlaces);
|
|
1665
|
+
}
|
|
1649
1666
|
}
|
|
1650
1667
|
return raw;
|
|
1651
1668
|
}
|
|
@@ -1688,7 +1705,7 @@ class DigitsOnlyDirective {
|
|
|
1688
1705
|
clean = clean.replace(REGEX.ONLY_DIGITS, ''); // string: digits only
|
|
1689
1706
|
}
|
|
1690
1707
|
else if (this.decimalPlaces === 0) {
|
|
1691
|
-
clean = clean.replace(REGEX.ONLY_DIGITS_AND_MINUS, ''); // integer: digits + minus
|
|
1708
|
+
clean = clean.replace(REGEX.ONLY_DIGITS_AND_MINUS, ''); // integer only: digits + minus
|
|
1692
1709
|
}
|
|
1693
1710
|
else {
|
|
1694
1711
|
clean = clean.replace(REGEX.ONLY_DIGITS_DOT_AND_MINUS, ''); // decimal: digits + . + minus
|
|
@@ -1721,20 +1738,52 @@ class DigitsOnlyDirective {
|
|
|
1721
1738
|
* '0' pos 7 → digit (raw index 4) digit consumed → raw index 5
|
|
1722
1739
|
* We reached position 8, so raw index = 5 ✔
|
|
1723
1740
|
*/
|
|
1741
|
+
/**
|
|
1742
|
+
* Convert a DOM cursor position (selectionStart) to an index inside the
|
|
1743
|
+
* raw digit string.
|
|
1744
|
+
*
|
|
1745
|
+
* Works correctly in both LTR and RTL:
|
|
1746
|
+
*
|
|
1747
|
+
* LTR: display = [prefix][formattedNumber][suffix]
|
|
1748
|
+
* number starts at prefix.length
|
|
1749
|
+
*
|
|
1750
|
+
* RTL: display = [LRM][suffix][formattedNumber][prefix]
|
|
1751
|
+
* number starts at LRM.length + suffix.length
|
|
1752
|
+
*
|
|
1753
|
+
* In both cases we subtract editableStartOffset to get a position
|
|
1754
|
+
* relative to the start of the formatted number, then walk the pattern
|
|
1755
|
+
* (if any) to count how many raw digits precede that position.
|
|
1756
|
+
*
|
|
1757
|
+
* Non-pattern mode:
|
|
1758
|
+
* The cursor position inside the number portion equals the raw digit
|
|
1759
|
+
* index directly (no separators injected in string/integer mode, and
|
|
1760
|
+
* thousands separators shift characters but the raw index is what we
|
|
1761
|
+
* need for splice operations).
|
|
1762
|
+
*
|
|
1763
|
+
* Example (RTL, pattern='(000) 000-0000', prefix='﷼', suffix=''):
|
|
1764
|
+
* editableStartOffset = LRM.length (1) + suffix.length (0) = 1
|
|
1765
|
+
* displayCursorPos = 5 → positionInPattern = 5 - 1 = 4
|
|
1766
|
+
* Walk: '(' pos0, '5' pos1 (raw 1), '5' pos2 (raw 2), '5' pos3 (raw 3), ')' pos4
|
|
1767
|
+
* → rawDigitIndex = 3 ✔
|
|
1768
|
+
*/
|
|
1724
1769
|
mapDisplayCursorToRawIndex(displayCursorPos) {
|
|
1770
|
+
// Subtract decoration offset to get position relative to number start.
|
|
1771
|
+
// editableStartOffset already accounts for LTR vs RTL layout.
|
|
1772
|
+
const positionInFormattedNumber = displayCursorPos - this.editableStartOffset;
|
|
1725
1773
|
if (!this.hasPattern) {
|
|
1726
|
-
|
|
1774
|
+
// No pattern: position in formatted number ≈ raw digit index
|
|
1775
|
+
// (thousands separators add chars but we want the raw insert point)
|
|
1776
|
+
return Math.max(0, positionInFormattedNumber);
|
|
1727
1777
|
}
|
|
1728
|
-
//
|
|
1729
|
-
const positionInPattern = displayCursorPos - this.prefix.length;
|
|
1778
|
+
// Pattern mode: walk the pattern counting digit slots up to our position
|
|
1730
1779
|
let rawDigitIndex = 0;
|
|
1731
1780
|
let patternPosition = 0;
|
|
1732
1781
|
for (const patternChar of this._resolvedPattern) {
|
|
1733
|
-
if (patternPosition >=
|
|
1782
|
+
if (patternPosition >= positionInFormattedNumber) {
|
|
1734
1783
|
break;
|
|
1735
1784
|
}
|
|
1736
1785
|
if (patternChar === '0') {
|
|
1737
|
-
rawDigitIndex++;
|
|
1786
|
+
rawDigitIndex++;
|
|
1738
1787
|
}
|
|
1739
1788
|
patternPosition++;
|
|
1740
1789
|
}
|