@ledgerhq/lumen-utils-shared 0.1.5 → 0.1.7

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/dist/index.js CHANGED
@@ -1,142 +1,138 @@
1
- import { jsx as k } from "react/jsx-runtime";
2
- import { createContext as W, useRef as F, useContext as w, useMemo as I } from "react";
1
+ import { jsx as F } from "react/jsx-runtime";
2
+ import { createContext as W, useRef as v, useContext as w, useMemo as C } from "react";
3
3
  import { clsx as B } from "clsx";
4
4
  import { twMerge as R } from "tailwind-merge";
5
- function G(t) {
5
+ function et(t) {
6
6
  return t ? t.split(/[-_ ]+/).map((e) => e.charAt(0).toUpperCase() + e.slice(1).toLowerCase()).join("") : "";
7
7
  }
8
8
  const _ = {
9
9
  md: { max: 48, min: 17, scale: 2, startAt: 0 },
10
10
  sm: { max: 28, min: 12, scale: 2, startAt: 6 }
11
11
  };
12
- function J(t, e = "md") {
13
- const { max: r, min: o, scale: s, startAt: l } = _[e], i = t.replace(/\D/g, "").length;
14
- return Math.max(o, r - Math.max(0, i - l) * s);
12
+ function nt(t, e = "md") {
13
+ const { max: r, min: n, scale: i, startAt: s } = _[e], o = t.replace(/\D/g, "").length;
14
+ return Math.max(n, r - Math.max(0, o - s) * i);
15
15
  }
16
- function A(t, e = !0) {
16
+ function z(t, e = !0) {
17
17
  if (!t) return "";
18
- const r = t.includes("."), [o, s] = t.split("."), l = o.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
19
- return r && e ? s ? `${l}.${s}` : `${l}.` : l;
18
+ const r = t.includes("."), [n, i] = t.split("."), s = n.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
19
+ return r && e ? i ? `${s}.${i}` : `${s}.` : s;
20
20
  }
21
- function Q(t, e = {}) {
21
+ const Z = (t) => t.replaceAll(",", ".").replaceAll(/[^\d.]/g, ""), q = (t) => t.replace(/^0+(?=\d)/, ""), T = (t, e) => e > 0 ? t.slice(0, e) : t, K = (t, e) => T(t.replaceAll(/\D/g, ""), e), N = (t, e, r) => {
22
+ if (t === ".") return "0.";
23
+ const n = t.indexOf(".");
24
+ if (n === -1) return T(t, e);
25
+ const i = T(t.slice(0, n), e) || "0", s = t.slice(n + 1).replaceAll(".", "").slice(0, r);
26
+ return s.length > 0 ? `${i}.${s}` : t.endsWith(".") ? `${i}.` : i;
27
+ };
28
+ function rt(t, e = {}) {
22
29
  const {
23
30
  allowDecimals: r = !0,
24
- thousandsSeparator: o = !0,
25
- maxIntegerLength: s = 9,
26
- maxDecimalLength: l = 9
27
- } = e, i = t.replace(",", ".").replace(/[^\d.]/g, "");
28
- let n = i;
29
- if (n = n.replace(/^0+(?=\d)/, ""), !r)
30
- return n = n.replace(/\D/g, ""), s > 0 && n.length > s && (n = n.slice(0, s)), o ? A(n) : n;
31
- n === "." && (n = "0.");
32
- const u = n.indexOf(".");
33
- if (u !== -1) {
34
- let a = n.slice(0, u), f = n.slice(u + 1).replace(/\./g, "");
35
- s > 0 && a.length > s && (a = a.slice(0, s)), f = f.slice(0, l), a === "" && (a = "0");
36
- const h = i.endsWith(".") || n.endsWith(".");
37
- n = f.length > 0 ? `${a}.${f}` : h ? `${a}.` : a;
38
- } else
39
- s > 0 && n.length > s && (n = n.slice(0, s));
40
- return o ? A(n) : n;
31
+ thousandsSeparator: n = !0,
32
+ maxIntegerLength: i = 9,
33
+ maxDecimalLength: s = 9,
34
+ decimalSeparator: o = "."
35
+ } = e, c = q(Z(t)), a = r ? N(c, i, s) : K(c, i), d = n ? z(a) : a;
36
+ return o === "." ? d : d.replaceAll(".", o);
41
37
  }
42
- function q(t, e) {
38
+ function H(t, e) {
43
39
  if (Object.is(t, e)) return !0;
44
- const r = t, o = e, s = Object.keys(r), l = Object.keys(o);
45
- return s.length !== l.length ? !1 : s.every(
46
- (i) => Object.prototype.hasOwnProperty.call(o, i) && Object.is(r[i], o[i])
40
+ const r = t, n = e, i = Object.keys(r), s = Object.keys(n);
41
+ return i.length !== s.length ? !1 : i.every(
42
+ (o) => Object.prototype.hasOwnProperty.call(n, o) && Object.is(r[o], n[o])
47
43
  );
48
44
  }
49
- function z(t, e) {
50
- const r = W(e), o = ({ children: l, value: i }) => {
51
- const n = F(i);
52
- return q(n.current, i) || (n.current = i), /* @__PURE__ */ k(r.Provider, { value: n.current, children: l });
45
+ function U(t, e) {
46
+ const r = W(e), n = ({ children: s, value: o }) => {
47
+ const c = v(o);
48
+ return H(c.current, o) || (c.current = o), /* @__PURE__ */ F(r.Provider, { value: c.current, children: s });
53
49
  };
54
- o.displayName = t + "Provider";
55
- function s({
56
- consumerName: l,
57
- contextRequired: i
50
+ n.displayName = t + "Provider";
51
+ function i({
52
+ consumerName: s,
53
+ contextRequired: o
58
54
  }) {
59
- const n = w(r);
60
- if (n)
61
- return n;
62
- if (i)
55
+ const c = w(r);
56
+ if (c)
57
+ return c;
58
+ if (o)
63
59
  throw new Error(
64
- `${l} must be used within ${t}`
60
+ `${s} must be used within ${t}`
65
61
  );
66
62
  return e || {};
67
63
  }
68
- return [o, s];
64
+ return [n, i];
69
65
  }
70
- const [X, K] = z("Disabled", { disabled: !1 }), tt = ({
66
+ const [ot, Y] = U("Disabled", { disabled: !1 }), it = ({
71
67
  consumerName: t,
72
68
  contextRequired: e,
73
69
  mergeWith: r
74
70
  }) => {
75
- const o = K({
71
+ const n = Y({
76
72
  consumerName: t,
77
73
  contextRequired: e ?? !1
78
74
  });
79
- return !!(r?.disabled || o.disabled);
75
+ return !!(r?.disabled || n.disabled);
80
76
  };
81
- function et(...t) {
77
+ function st(...t) {
82
78
  return R(B(t));
83
79
  }
84
- const O = (t) => typeof t == "string" || typeof t == "number", nt = (t) => Array.isArray(t) ? t.every(O) : O(t);
80
+ const O = (t) => typeof t == "string" || typeof t == "number", ct = (t) => Array.isArray(t) ? t.every(O) : O(t);
85
81
  function S(t, e) {
86
82
  if (typeof t == "function")
87
83
  return t(e);
88
84
  typeof t == "object" && t !== null && "current" in t && (t.current = e);
89
85
  }
90
- function N(...t) {
86
+ function G(...t) {
91
87
  const e = /* @__PURE__ */ new Map();
92
88
  return (r) => {
93
89
  if (r === null) {
94
- t.forEach((o) => {
95
- const s = e.get(o);
96
- typeof s == "function" && s(), S(o, null);
90
+ t.forEach((n) => {
91
+ const i = e.get(n);
92
+ typeof i == "function" && i(), S(n, null);
97
93
  }), e.clear();
98
94
  return;
99
95
  }
100
- t.forEach((o) => {
101
- const s = S(o, r);
102
- typeof s == "function" && e.set(o, s);
96
+ t.forEach((n) => {
97
+ const i = S(n, r);
98
+ typeof i == "function" && e.set(n, i);
103
99
  });
104
100
  };
105
101
  }
106
- function rt(...t) {
107
- return I(() => N(...t), t);
102
+ function lt(...t) {
103
+ return C(() => G(...t), t);
108
104
  }
109
- const ot = (t, e) => {
105
+ const at = (t, e) => {
110
106
  let r = t;
111
- for (const o of e) {
107
+ for (const n of e) {
112
108
  if (r == null)
113
109
  return;
114
- r = r[o];
110
+ r = r[n];
115
111
  }
116
112
  return r;
117
- }, it = ({
113
+ }, ut = ({
118
114
  currentStep: t,
119
115
  totalSteps: e,
120
116
  size: r,
121
- label: o,
122
- strokeWidth: s = 4,
123
- arcPercentage: l = 0.75
117
+ label: n,
118
+ strokeWidth: i = 4,
119
+ arcPercentage: s = 0.75
124
120
  }) => {
125
- const i = Math.min(Math.max(t, 0), e), n = o ?? `${i}/${e}`, u = e <= 0 ? 0 : i / e, a = (r - s) / 2, f = r / 2, h = r / 2, d = 2 * Math.PI * a, g = d * l, b = g * (1 - u), p = i <= 0, D = `${g} ${d}`, P = p ? g - 2 : b;
121
+ const o = Math.min(Math.max(t, 0), e), c = n ?? `${o}/${e}`, a = e <= 0 ? 0 : o / e, d = (r - i) / 2, g = r / 2, p = r / 2, u = 2 * Math.PI * d, m = u * s, b = m * (1 - a), h = o <= 0, D = `${m} ${u}`, P = h ? m - 2 : b;
126
122
  return {
127
- displayLabel: n,
128
- progress: u,
129
- r: a,
130
- cx: f,
131
- cy: h,
132
- circumference: d,
133
- trackArcLength: g,
134
- trackDashArray: `${g} ${d}`,
123
+ displayLabel: c,
124
+ progress: a,
125
+ r: d,
126
+ cx: g,
127
+ cy: p,
128
+ circumference: u,
129
+ trackArcLength: m,
130
+ trackDashArray: `${m} ${u}`,
135
131
  progressDashArray: D,
136
132
  progressDashOffset: P,
137
- showMinimalDot: p
133
+ showMinimalDot: h
138
134
  };
139
- }, st = (t) => I(
135
+ }, ft = (t) => C(
140
136
  () => ({
141
137
  integerPart: Array.from(t.integerPart, (e) => ({
142
138
  value: e,
@@ -148,57 +144,57 @@ const ot = (t, e) => {
148
144
  })) : []
149
145
  }),
150
146
  [t.integerPart, t.decimalPart]
151
- ), ct = (t, e, r) => {
147
+ ), dt = (t, e, r) => {
152
148
  if (e) return r;
153
- const o = t.decimalPart ? `${t.decimalSeparator}${t.decimalPart}` : "", s = `${t.integerPart}${o}`;
154
- return t.currencyPosition === "end" ? `${s} ${t.currencyText}` : `${t.currencyText} ${s}`;
149
+ const n = t.decimalPart ? `${t.decimalSeparator}${t.decimalPart}` : "", i = `${t.integerPart}${n}`;
150
+ return t.currencyPosition === "end" ? `${i} ${t.currencyText}` : `${t.currencyText} ${i}`;
155
151
  };
156
- function Z(t, e, r = {}) {
157
- const { leading: o = !1, trailing: s = !0, maxWait: l } = r;
158
- let i = null, n = null, u = null, a = 0, f = null, h = null, d;
159
- const g = l !== void 0, b = g ? Math.max(l, e) : 0, p = (c) => {
160
- a = c;
161
- const m = f, y = h;
162
- return f = null, h = null, d = t.apply(y, m), d;
163
- }, D = (c) => {
164
- const m = u ? c - u : 0;
165
- return e - m;
166
- }, P = (c) => {
167
- const m = u ? c - u : e, y = c - a;
168
- return u === null || m >= e || m < 0 || g && y >= b;
169
- }, $ = (c) => (i = null, s && f ? p(c) : (f = null, h = null, d)), x = () => {
170
- const c = Date.now();
171
- if (P(c)) {
172
- $(c);
152
+ function J(t, e, r = {}) {
153
+ const { leading: n = !1, trailing: i = !0, maxWait: s } = r;
154
+ let o = null, c = null, a = null, d = 0, g = null, p = null, u;
155
+ const m = s !== void 0, b = m ? Math.max(s, e) : 0, h = (l) => {
156
+ d = l;
157
+ const f = g, x = p;
158
+ return g = null, p = null, u = t.apply(x, f), u;
159
+ }, D = (l) => {
160
+ const f = a ? l - a : 0;
161
+ return e - f;
162
+ }, P = (l) => {
163
+ const f = a ? l - a : e, x = l - d;
164
+ return a === null || f >= e || f < 0 || m && x >= b;
165
+ }, $ = (l) => (o = null, i && g ? h(l) : (g = null, p = null, u)), y = () => {
166
+ const l = Date.now();
167
+ if (P(l)) {
168
+ $(l);
173
169
  return;
174
170
  }
175
- i = setTimeout(x, D(c));
171
+ o = setTimeout(y, D(l));
176
172
  }, M = () => {
177
- const c = Date.now();
178
- n = null, f && (p(c), i !== null && (clearTimeout(i), i = setTimeout(x, e)));
179
- }, v = (c) => (a = c, i = setTimeout(x, e), g && (n = setTimeout(M, b)), o ? p(c) : d), E = () => {
180
- i !== null && clearTimeout(i), n !== null && clearTimeout(n), a = 0, f = null, h = null, u = null, i = null, n = null;
181
- }, L = () => i === null && n === null ? d : $(Date.now()), j = () => i !== null || n !== null, T = function(...c) {
182
- const m = Date.now(), y = P(m);
183
- if (f = c, h = this, u = m, y) {
184
- if (i === null)
185
- return v(m);
186
- if (g)
187
- return clearTimeout(i), i = setTimeout(x, e), p(m);
173
+ const l = Date.now();
174
+ c = null, g && (h(l), o !== null && (clearTimeout(o), o = setTimeout(y, e)));
175
+ }, E = (l) => (d = l, o = setTimeout(y, e), m && (c = setTimeout(M, b)), n ? h(l) : u), L = () => {
176
+ o !== null && clearTimeout(o), c !== null && clearTimeout(c), d = 0, g = null, p = null, a = null, o = null, c = null;
177
+ }, j = () => o === null && c === null ? u : $(Date.now()), k = () => o !== null || c !== null, A = function(...l) {
178
+ const f = Date.now(), x = P(f);
179
+ if (g = l, p = this, a = f, x) {
180
+ if (o === null)
181
+ return E(f);
182
+ if (m)
183
+ return clearTimeout(o), o = setTimeout(y, e), h(f);
188
184
  }
189
- return i === null && (i = setTimeout(x, e)), d;
185
+ return o === null && (o = setTimeout(y, e)), u;
190
186
  };
191
- return T.cancel = E, T.flush = L, T.pending = j, T;
187
+ return A.cancel = L, A.flush = j, A.pending = k, A;
192
188
  }
193
- function lt(t, e, r = {}) {
194
- const { leading: o = !0, trailing: s = !0 } = r;
195
- return Z(t, e, {
196
- leading: o,
197
- trailing: s,
189
+ function mt(t, e, r = {}) {
190
+ const { leading: n = !0, trailing: i = !0 } = r;
191
+ return J(t, e, {
192
+ leading: n,
193
+ trailing: i,
198
194
  maxWait: e
199
195
  });
200
196
  }
201
- const at = ({
197
+ const gt = ({
202
198
  onClick: t,
203
199
  disabled: e
204
200
  }) => t ? e ? {
@@ -208,43 +204,43 @@ const at = ({
208
204
  } : {
209
205
  role: "button",
210
206
  tabIndex: 0,
211
- onKeyDown: (o) => {
212
- (o.key === "Enter" || o.key === " ") && (o.preventDefault(), t?.(o));
207
+ onKeyDown: (n) => {
208
+ (n.key === "Enter" || n.key === " ") && (n.preventDefault(), t?.(n));
213
209
  },
214
210
  onClick: t
215
- } : void 0, C = " ", ut = ({
211
+ } : void 0, I = " ", pt = ({
216
212
  label: t,
217
213
  placeholder: e
218
214
  }) => {
219
215
  const r = typeof e == "string" && e.trim().length > 0 ? e : void 0;
220
216
  return t ? {
221
- inputPlaceholder: r ?? C,
217
+ inputPlaceholder: r ?? I,
222
218
  labelStaysFloatedWithPlaceholder: r !== void 0
223
219
  } : {
224
- inputPlaceholder: e ?? C,
220
+ inputPlaceholder: e ?? I,
225
221
  labelStaysFloatedWithPlaceholder: !1
226
222
  };
227
223
  };
228
224
  export {
229
- X as DisabledProvider,
225
+ ot as DisabledProvider,
230
226
  S as assignRef,
231
- ct as buildAriaLabel,
232
- et as cn,
233
- z as createSafeContext,
234
- Z as debounce,
235
- A as formatThousands,
236
- at as getButtonA11yProps,
237
- J as getFontSize,
238
- ot as getObjectPath,
239
- it as getStepperCalculations,
240
- nt as isTextChildren,
241
- N as mergeRefs,
242
- ut as resolveBaseInputPlaceholder,
243
- q as shallowEqual,
244
- Q as textFormatter,
245
- lt as throttle,
246
- G as toPascalCase,
247
- tt as useDisabledContext,
248
- rt as useMergedRef,
249
- st as useSplitText
227
+ dt as buildAriaLabel,
228
+ st as cn,
229
+ U as createSafeContext,
230
+ J as debounce,
231
+ z as formatThousands,
232
+ gt as getButtonA11yProps,
233
+ nt as getFontSize,
234
+ at as getObjectPath,
235
+ ut as getStepperCalculations,
236
+ ct as isTextChildren,
237
+ G as mergeRefs,
238
+ pt as resolveBaseInputPlaceholder,
239
+ H as shallowEqual,
240
+ rt as textFormatter,
241
+ mt as throttle,
242
+ et as toPascalCase,
243
+ it as useDisabledContext,
244
+ lt as useMergedRef,
245
+ ft as useSplitText
250
246
  };
@@ -7,11 +7,20 @@ export type TextFormatterOptions = {
7
7
  maxIntegerLength?: number;
8
8
  /** Maximum length for decimal part (after decimal) */
9
9
  maxDecimalLength?: number;
10
+ /**
11
+ * Character used to display the decimal separator.
12
+ * Input parsing always accepts both `.` and `,` regardless of this value.
13
+ */
14
+ decimalSeparator?: '.' | ',';
10
15
  };
11
16
  /**
12
17
  * Formats and validates numeric input text for amount inputs.
13
- * Handles decimal formatting, leading zeros, and prevents multiple decimal points.
14
- * Can optionally format with space-separated thousands.
18
+ *
19
+ * Parsing is always lenient: both `.` and `,` are accepted as the decimal
20
+ * separator on input, so the result stays correct regardless of the locale
21
+ * keyboard. The `decimalSeparator` option only controls how the result is
22
+ * displayed.
23
+ *
15
24
  * @returns Formatted and cleaned numeric string
16
25
  *
17
26
  * @example
@@ -19,9 +28,11 @@ export type TextFormatterOptions = {
19
28
  * textFormatter('.5') // '0.5'
20
29
  * textFormatter('1.2.3') // '1.23'
21
30
  * textFormatter('1,5') // '1.5'
31
+ * textFormatter('1,5', { decimalSeparator: ',' }) // '1,5'
22
32
  * textFormatter('abc123') // '123'
23
33
  * textFormatter('1000000', { thousandsSeparator: true }) // '1 000 000'
24
34
  * textFormatter('1234.5678', { thousandsSeparator: true }) // '1 234.5678'
35
+ * textFormatter('1234.5', { decimalSeparator: ',' }) // '1 234,5'
25
36
  * textFormatter('123456789012', { maxIntegerLength: 9 }) // '123456789'
26
37
  * textFormatter('123.123456', { maxDecimalLength: 2 }) // '123.12'
27
38
  */
@@ -1 +1 @@
1
- {"version":3,"file":"textFormatter.d.ts","sourceRoot":"","sources":["../../../src/lib/inputFormatter/textFormatter.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,oBAAoB,GAAG;IACjC,sCAAsC;IACtC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,uDAAuD;IACvD,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sDAAsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa;AAC3B,gCAAgC;AAChC,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,oBAAyB,GACjC,MAAM,CA4DR"}
1
+ {"version":3,"file":"textFormatter.d.ts","sourceRoot":"","sources":["../../../src/lib/inputFormatter/textFormatter.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,oBAAoB,GAAG;IACjC,sCAAsC;IACtC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,uDAAuD;IACvD,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sDAAsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;CAC9B,CAAC;AAyCF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,aAAa;AAC3B,gCAAgC;AAChC,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,oBAAyB,GACjC,MAAM,CAsBR"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ledgerhq/lumen-utils-shared",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { textFormatter } from './textFormatter.js';
2
+ import { textFormatter, type TextFormatterOptions } from './textFormatter.js';
3
3
 
4
4
  describe('textFormatter', () => {
5
5
  describe('decimal handling with allowDecimals=true (default)', () => {
@@ -93,6 +93,64 @@ describe('textFormatter', () => {
93
93
  });
94
94
  });
95
95
 
96
+ describe('decimalSeparator display', () => {
97
+ type Case = {
98
+ name: string;
99
+ input: string;
100
+ expected: string;
101
+ options?: TextFormatterOptions;
102
+ };
103
+
104
+ const commaSeparatorCases: Case[] = [
105
+ {
106
+ name: 'display dot decimal as comma',
107
+ input: '1.5',
108
+ expected: '1,5',
109
+ options: { decimalSeparator: ',' },
110
+ },
111
+ {
112
+ name: 'display comma input as comma',
113
+ input: '1,5',
114
+ expected: '1,5',
115
+ options: { decimalSeparator: ',' },
116
+ },
117
+ {
118
+ name: 'comma decimal with space thousands',
119
+ input: '1234.5',
120
+ expected: '1 234,5',
121
+ options: { decimalSeparator: ',' },
122
+ },
123
+ {
124
+ name: 'preserve trailing separator while typing',
125
+ input: '12.',
126
+ expected: '12,',
127
+ options: { decimalSeparator: ',' },
128
+ },
129
+ {
130
+ name: 'no decimal part is unaffected',
131
+ input: '1234',
132
+ expected: '1 234',
133
+ options: { decimalSeparator: ',' },
134
+ },
135
+ {
136
+ name: 'integer-only mode is unaffected by comma separator',
137
+ input: '1.5',
138
+ expected: '15',
139
+ options: { decimalSeparator: ',', allowDecimals: false },
140
+ },
141
+ {
142
+ name: 'explicit dot separator matches default',
143
+ input: '1234.5',
144
+ expected: '1 234.5',
145
+ options: { decimalSeparator: '.' },
146
+ },
147
+ ];
148
+
149
+ it.each(commaSeparatorCases)('$name', ({ input, expected, options }) => {
150
+ expect(textFormatter(input, options)).toBe(expected);
151
+ });
152
+ });
153
+
96
154
  describe('non-numeric character removal', () => {
97
155
  type Case = { name: string; input: string; expected: string };
98
156
 
@@ -9,12 +9,60 @@ export type TextFormatterOptions = {
9
9
  maxIntegerLength?: number;
10
10
  /** Maximum length for decimal part (after decimal) */
11
11
  maxDecimalLength?: number;
12
+ /**
13
+ * Character used to display the decimal separator.
14
+ * Input parsing always accepts both `.` and `,` regardless of this value.
15
+ */
16
+ decimalSeparator?: '.' | ',';
17
+ };
18
+
19
+ /** Keep only digits and dots; commas are treated as decimal separators. */
20
+ const sanitize = (value: string): string =>
21
+ value.replaceAll(',', '.').replaceAll(/[^\d.]/g, '');
22
+
23
+ /** Drop leading zeros, keeping one when it precedes a dot or stands alone (e.g. "0", "0.5"). */
24
+ const stripLeadingZeros = (value: string): string =>
25
+ value.replace(/^0+(?=\d)/, '');
26
+
27
+ /** Truncate to `max` characters. A `max` of 0 disables the limit. */
28
+ const limitIntegerLength = (value: string, max: number): string =>
29
+ max > 0 ? value.slice(0, max) : value;
30
+
31
+ const formatIntegerOnly = (value: string, maxIntegerLength: number): string =>
32
+ limitIntegerLength(value.replaceAll(/\D/g, ''), maxIntegerLength);
33
+
34
+ const formatDecimal = (
35
+ value: string,
36
+ maxIntegerLength: number,
37
+ maxDecimalLength: number,
38
+ ): string => {
39
+ // A lone separator becomes "0." so the user can keep typing decimals.
40
+ if (value === '.') return '0.';
41
+
42
+ const firstDot = value.indexOf('.');
43
+ if (firstDot === -1) return limitIntegerLength(value, maxIntegerLength);
44
+
45
+ const integerPart =
46
+ limitIntegerLength(value.slice(0, firstDot), maxIntegerLength) || '0';
47
+ // Extra dots are ignored, e.g. the decimal part of "1.2.3" is "23".
48
+ const decimalPart = value
49
+ .slice(firstDot + 1)
50
+ .replaceAll('.', '')
51
+ .slice(0, maxDecimalLength);
52
+
53
+ if (decimalPart.length > 0) return `${integerPart}.${decimalPart}`;
54
+ // Preserve a trailing dot mid-typing (e.g. "12.").
55
+ return value.endsWith('.') ? `${integerPart}.` : integerPart;
12
56
  };
13
57
 
14
58
  /**
15
59
  * Formats and validates numeric input text for amount inputs.
16
- * Handles decimal formatting, leading zeros, and prevents multiple decimal points.
17
- * Can optionally format with space-separated thousands.
60
+ *
61
+ * Parsing is always lenient: both `.` and `,` are accepted as the decimal
62
+ * separator on input, so the result stays correct regardless of the locale
63
+ * keyboard. The `decimalSeparator` option only controls how the result is
64
+ * displayed.
65
+ *
18
66
  * @returns Formatted and cleaned numeric string
19
67
  *
20
68
  * @example
@@ -22,9 +70,11 @@ export type TextFormatterOptions = {
22
70
  * textFormatter('.5') // '0.5'
23
71
  * textFormatter('1.2.3') // '1.23'
24
72
  * textFormatter('1,5') // '1.5'
73
+ * textFormatter('1,5', { decimalSeparator: ',' }) // '1,5'
25
74
  * textFormatter('abc123') // '123'
26
75
  * textFormatter('1000000', { thousandsSeparator: true }) // '1 000 000'
27
76
  * textFormatter('1234.5678', { thousandsSeparator: true }) // '1 234.5678'
77
+ * textFormatter('1234.5', { decimalSeparator: ',' }) // '1 234,5'
28
78
  * textFormatter('123456789012', { maxIntegerLength: 9 }) // '123456789'
29
79
  * textFormatter('123.123456', { maxDecimalLength: 2 }) // '123.12'
30
80
  */
@@ -38,58 +88,20 @@ export function textFormatter(
38
88
  thousandsSeparator = true,
39
89
  maxIntegerLength = 9,
40
90
  maxDecimalLength = 9,
91
+ decimalSeparator = '.',
41
92
  } = options;
42
- // Normalize input: convert comma to dot, remove non-digit/non-dot characters
43
- const normalizedValue = value.replace(',', '.').replace(/[^\d.]/g, '');
44
- let cleaned = normalizedValue;
45
-
46
- // Remove leading zeros (except when followed by dot)
47
- cleaned = cleaned.replace(/^0+(?=\d)/, '');
48
-
49
- if (!allowDecimals) {
50
- // Integer-only: remove any non-digit and apply max length
51
- cleaned = cleaned.replace(/\D/g, '');
52
- if (maxIntegerLength > 0 && cleaned.length > maxIntegerLength) {
53
- cleaned = cleaned.slice(0, maxIntegerLength);
54
- }
55
- return thousandsSeparator ? formatThousands(cleaned) : cleaned;
56
- }
57
-
58
- // Handle single dot input as "0."
59
- if (cleaned === '.') {
60
- cleaned = '0.';
61
- }
62
-
63
- const firstDot = cleaned.indexOf('.');
64
- if (firstDot !== -1) {
65
- // Split integer and decimal parts
66
- let integerPart = cleaned.slice(0, firstDot);
67
- let decimalPart = cleaned.slice(firstDot + 1).replace(/\./g, '');
68
93
 
69
- // Apply length limits
70
- if (maxIntegerLength > 0 && integerPart.length > maxIntegerLength) {
71
- integerPart = integerPart.slice(0, maxIntegerLength);
72
- }
73
- decimalPart = decimalPart.slice(0, maxDecimalLength);
94
+ const sanitized = stripLeadingZeros(sanitize(value));
74
95
 
75
- // Ensure integer part is not empty
76
- if (integerPart === '') integerPart = '0';
96
+ const cleaned = allowDecimals
97
+ ? formatDecimal(sanitized, maxIntegerLength, maxDecimalLength)
98
+ : formatIntegerOnly(sanitized, maxIntegerLength);
77
99
 
78
- // Preserve trailing dot if user is typing
79
- const hasTrailingDot =
80
- normalizedValue.endsWith('.') || cleaned.endsWith('.');
81
- cleaned =
82
- decimalPart.length > 0
83
- ? `${integerPart}.${decimalPart}`
84
- : hasTrailingDot
85
- ? `${integerPart}.`
86
- : integerPart;
87
- } else {
88
- // No decimal point, just apply integer length limit
89
- if (maxIntegerLength > 0 && cleaned.length > maxIntegerLength) {
90
- cleaned = cleaned.slice(0, maxIntegerLength);
91
- }
92
- }
100
+ const grouped = thousandsSeparator ? formatThousands(cleaned) : cleaned;
93
101
 
94
- return thousandsSeparator ? formatThousands(cleaned) : cleaned;
102
+ // formatThousands groups with spaces and keeps a single dot for the decimal,
103
+ // so localizing the separator only ever touches that one dot.
104
+ return decimalSeparator === '.'
105
+ ? grouped
106
+ : grouped.replaceAll('.', decimalSeparator);
95
107
  }