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.
@@ -923,7 +923,15 @@ class DigitsOnlyDirective {
923
923
  renderer;
924
924
  document;
925
925
  // ─── Public inputs ──────────────────────────────────────────────────────────
926
- /** How many decimal places are allowed. 0 = integers only. Ignored when pattern is set. */
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 inputMode so mobile keyboards show the number pad
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
- const decimalIsAllowed = this.decimalPlaces > 0 && !this.hasPattern;
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
- // Integer or string mode: no decimal points allowed
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: allow exactly one dot with up to N decimal places
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(cursorPositionBeforeReformat, displayLengthBeforeReformat);
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
- const rawInsertStart = this.hasPattern
1373
- ? this.mapDisplayCursorToRawIndex(inputEl.selectionStart ?? 0)
1374
- : (inputEl.selectionStart ?? 0);
1375
- const rawInsertEnd = this.hasPattern
1376
- ? this.mapDisplayCursorToRawIndex(inputEl.selectionEnd ?? 0)
1377
- : (inputEl.selectionEnd ?? 0);
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
- * and try to restore the cursor to a sensible position.
1448
- *
1449
- * @param cursorBefore Cursor position in the display string BEFORE we reformatted
1450
- * @param lengthBefore Total display string length BEFORE we reformatted
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(cursorBefore, lengthBefore) {
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
- // RTL layout: [LRM][suffix][formattedNumber][prefix]
1462
- // LTR layout: [prefix][formattedNumber][suffix]
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 fullDisplayValue = this.isRtl
1477
- ? LRM + this.suffix + formattedContent + this.prefix
1478
- : this.prefix + formattedContent + this.suffix;
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
- // More than one dot? Collapse everything after the first dot into one decimal part
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
- // Too many decimal digits? Truncate to the allowed count
1646
- const [intPart, decPart] = raw.split('.');
1647
- if (decPart !== undefined && decPart.length > this.decimalPlaces) {
1648
- raw = intPart + '.' + decPart.slice(0, this.decimalPlaces);
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
- return displayCursorPos;
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
- // Adjust for prefix length so we walk the pattern relative to its own start
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 >= positionInPattern) {
1782
+ if (patternPosition >= positionInFormattedNumber) {
1734
1783
  break;
1735
1784
  }
1736
1785
  if (patternChar === '0') {
1737
- rawDigitIndex++; // this position consumed a digit
1786
+ rawDigitIndex++;
1738
1787
  }
1739
1788
  patternPosition++;
1740
1789
  }