stimeo-ui 0.7.0 → 0.9.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/data_grid_controller.js +150 -24
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +485 -114
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +2764 -923
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/currency_input_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/composition_tracker.ts
|
|
6
|
+
var CompositionTracker = class {
|
|
7
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
8
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
9
|
+
#onStart;
|
|
10
|
+
#onEnd;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.#onStart = options.onStart;
|
|
13
|
+
this.#onEnd = options.onEnd;
|
|
14
|
+
}
|
|
15
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
16
|
+
observe(target) {
|
|
17
|
+
if (this.#observedTargets.has(target)) return;
|
|
18
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
19
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
20
|
+
this.#observedTargets.add(target);
|
|
21
|
+
}
|
|
22
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
23
|
+
unobserve(target) {
|
|
24
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
25
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
26
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
27
|
+
this.#activeTargets.delete(target);
|
|
28
|
+
}
|
|
29
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
30
|
+
disconnect() {
|
|
31
|
+
for (const target of this.#observedTargets) {
|
|
32
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
33
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
34
|
+
}
|
|
35
|
+
this.#observedTargets.clear();
|
|
36
|
+
this.#activeTargets.clear();
|
|
37
|
+
}
|
|
38
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
39
|
+
isComposing(event) {
|
|
40
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
41
|
+
}
|
|
42
|
+
#handleStart = (event) => {
|
|
43
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
44
|
+
this.#onStart?.(event);
|
|
45
|
+
};
|
|
46
|
+
#handleEnd = (event) => {
|
|
47
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
48
|
+
this.#onEnd?.(event);
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/utils/half_width.ts
|
|
53
|
+
var FULL_WIDTH_SHIFT = 65248;
|
|
54
|
+
function halfWidthChar(char) {
|
|
55
|
+
if (char >= "\uFF01" && char <= "\uFF5E") {
|
|
56
|
+
return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);
|
|
57
|
+
}
|
|
58
|
+
return char === "\u3000" ? " " : char;
|
|
59
|
+
}
|
|
60
|
+
|
|
3
61
|
// src/controllers/currency_input_controller.ts
|
|
4
62
|
var CurrencyInputController = class extends Controller {
|
|
5
63
|
static targets = ["display", "field", "srValue"];
|
|
@@ -12,19 +70,92 @@ var CurrencyInputController = class extends Controller {
|
|
|
12
70
|
static events = ["change"];
|
|
13
71
|
/** Last committed numeric value, to suppress duplicate `change` dispatches. */
|
|
14
72
|
#lastValue = null;
|
|
73
|
+
#started = false;
|
|
74
|
+
/** Validated mirrors of the Values; the hot path never reads a raw Value. */
|
|
75
|
+
#locale = "en-US";
|
|
76
|
+
#currency = "";
|
|
77
|
+
#precision = 2;
|
|
78
|
+
/** Formatters rebuilt only when a Value changes, never per keystroke. */
|
|
79
|
+
#grouping;
|
|
80
|
+
#fixed;
|
|
81
|
+
#accessible;
|
|
82
|
+
#group = ",";
|
|
83
|
+
#decimal = ".";
|
|
84
|
+
/** The locale's non-Latin digits mapped back to ASCII (empty for Latin locales). */
|
|
85
|
+
#digits = /* @__PURE__ */ new Map();
|
|
86
|
+
/** Holds mid-composition input so the IME's uncommitted text is never rewritten. */
|
|
87
|
+
#composition = new CompositionTracker({
|
|
88
|
+
onEnd: () => this.#reformat(false)
|
|
89
|
+
});
|
|
90
|
+
/** Re-validates on declaration changes and re-renders the committed display. */
|
|
91
|
+
localeValueChanged() {
|
|
92
|
+
this.#applyValueChange();
|
|
93
|
+
}
|
|
94
|
+
currencyValueChanged() {
|
|
95
|
+
this.#applyValueChange();
|
|
96
|
+
}
|
|
97
|
+
precisionValueChanged() {
|
|
98
|
+
this.#applyValueChange();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Scans the display under the *outgoing* configuration (its separators wrote
|
|
102
|
+
* that text), then revalidates and re-renders under the new one — so a locale
|
|
103
|
+
* switch re-interprets the value, never the old text with new separators.
|
|
104
|
+
*/
|
|
105
|
+
#applyValueChange() {
|
|
106
|
+
const scan = this.#started && this.hasDisplayTarget ? this.#scan(this.displayTarget.value) : null;
|
|
107
|
+
this.#revalidate();
|
|
108
|
+
if (!scan || !this.hasDisplayTarget) return;
|
|
109
|
+
if (document.activeElement === this.displayTarget) {
|
|
110
|
+
const formatted = this.#render(scan.parts);
|
|
111
|
+
this.displayTarget.value = formatted;
|
|
112
|
+
this.#reflect(scan.value, formatted);
|
|
113
|
+
} else if (scan.value === null) {
|
|
114
|
+
this.displayTarget.value = "";
|
|
115
|
+
this.#reflect(null, "");
|
|
116
|
+
} else {
|
|
117
|
+
const rounded = round(scan.value, this.#precision);
|
|
118
|
+
const formatted = this.#fixed.format(rounded);
|
|
119
|
+
this.displayTarget.value = formatted;
|
|
120
|
+
this.#reflect(rounded, formatted);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
15
123
|
/** Normalizes any pre-filled display value to its fixed-precision form. */
|
|
16
124
|
connect() {
|
|
125
|
+
this.#started = true;
|
|
17
126
|
if (!this.hasDisplayTarget) return;
|
|
18
|
-
const
|
|
19
|
-
this.#lastValue =
|
|
20
|
-
if (
|
|
21
|
-
this
|
|
22
|
-
} else {
|
|
127
|
+
const { value } = this.#scan(this.displayTarget.value);
|
|
128
|
+
this.#lastValue = value === null ? null : round(value, this.#precision);
|
|
129
|
+
if (value === null) {
|
|
130
|
+
this.displayTarget.value = "";
|
|
23
131
|
this.#reflect(null, "");
|
|
132
|
+
} else {
|
|
133
|
+
this.#reformat(true);
|
|
24
134
|
}
|
|
25
135
|
}
|
|
136
|
+
disconnect() {
|
|
137
|
+
this.#started = false;
|
|
138
|
+
this.#composition.disconnect();
|
|
139
|
+
}
|
|
140
|
+
/** Tracks composition on an arriving (or swapped-in) display and normalizes it. */
|
|
141
|
+
displayTargetConnected(target) {
|
|
142
|
+
this.#composition.observe(target);
|
|
143
|
+
if (this.#started) this.#reformat(true);
|
|
144
|
+
}
|
|
145
|
+
displayTargetDisconnected(target) {
|
|
146
|
+
this.#composition.unobserve(target);
|
|
147
|
+
}
|
|
148
|
+
/** Syncs a late-arriving hidden field without touching the display or events. */
|
|
149
|
+
fieldTargetConnected() {
|
|
150
|
+
if (this.#started) this.#resync();
|
|
151
|
+
}
|
|
152
|
+
/** Syncs a late-arriving screen-reader span the same way. */
|
|
153
|
+
srValueTargetConnected() {
|
|
154
|
+
if (this.#started) this.#resync();
|
|
155
|
+
}
|
|
26
156
|
/** Re-groups digits as the user types, preserving the caret position. */
|
|
27
|
-
onInput() {
|
|
157
|
+
onInput(event) {
|
|
158
|
+
if (this.#composition.isComposing(event)) return;
|
|
28
159
|
this.#reformat(false);
|
|
29
160
|
}
|
|
30
161
|
/** Applies the fixed-precision rounding on blur. */
|
|
@@ -33,112 +164,212 @@ var CurrencyInputController = class extends Controller {
|
|
|
33
164
|
}
|
|
34
165
|
/**
|
|
35
166
|
* Parses the display value, rewrites it grouped (optionally at fixed
|
|
36
|
-
* precision), keeps the caret stable by
|
|
37
|
-
* the screen-reader span, and the `change` event.
|
|
167
|
+
* precision), keeps the caret stable by significant characters, and syncs the
|
|
168
|
+
* field, the screen-reader span, and the `change` event.
|
|
169
|
+
*
|
|
170
|
+
* @stimeoRenderRoot
|
|
38
171
|
*/
|
|
39
172
|
#reformat(fixedPrecision) {
|
|
40
173
|
if (!this.hasDisplayTarget) return;
|
|
41
174
|
const raw = this.displayTarget.value;
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
175
|
+
const { parts, value } = this.#scan(raw);
|
|
176
|
+
if (fixedPrecision) {
|
|
177
|
+
if (value === null) {
|
|
178
|
+
this.displayTarget.value = "";
|
|
179
|
+
this.#reflect(null, "");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const rounded = round(value, this.#precision);
|
|
183
|
+
const formatted2 = this.#fixed.format(rounded);
|
|
184
|
+
this.displayTarget.value = formatted2;
|
|
185
|
+
this.#reflect(rounded, formatted2);
|
|
46
186
|
return;
|
|
47
187
|
}
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
188
|
+
const formatted = this.#render(parts);
|
|
189
|
+
if (formatted !== raw) {
|
|
190
|
+
const caret = this.displayTarget.selectionStart;
|
|
191
|
+
const anchor = caret === null ? null : this.#significantBefore(raw, caret);
|
|
192
|
+
this.displayTarget.value = formatted;
|
|
193
|
+
if (anchor !== null) this.#restoreCaret(formatted, anchor);
|
|
194
|
+
}
|
|
195
|
+
this.#reflect(value, value === null ? "" : formatted);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The in-progress rendering: grouped integer, sign and fraction as typed.
|
|
199
|
+
*
|
|
200
|
+
* @stimeoRenderRoot
|
|
201
|
+
*/
|
|
202
|
+
#render(parts) {
|
|
203
|
+
const int = parts.int === "" ? "" : this.#grouping.format(BigInt(parts.int));
|
|
204
|
+
const frac = parts.hasDot ? this.#decimal + parts.frac : "";
|
|
205
|
+
return parts.sign + int + frac;
|
|
206
|
+
}
|
|
207
|
+
/** Restores the caret to sit just after the n-th significant character. */
|
|
208
|
+
#restoreCaret(formatted, significantBefore) {
|
|
58
209
|
let seen = 0;
|
|
59
210
|
let position = formatted.length;
|
|
60
211
|
for (let i = 0; i < formatted.length; i++) {
|
|
61
|
-
if (seen >=
|
|
212
|
+
if (seen >= significantBefore) {
|
|
62
213
|
position = i;
|
|
63
214
|
break;
|
|
64
215
|
}
|
|
65
|
-
if (
|
|
216
|
+
if (this.#isSignificant(formatted[i])) seen += 1;
|
|
66
217
|
}
|
|
67
218
|
try {
|
|
68
219
|
this.displayTarget.setSelectionRange(position, position);
|
|
69
220
|
} catch {
|
|
70
221
|
}
|
|
71
222
|
}
|
|
72
|
-
/**
|
|
73
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Counts the characters before `caret` that survive into the rendering,
|
|
225
|
+
* applying the same acceptance rules as {@link #scan} — a rejected keystroke
|
|
226
|
+
* (a mid-string sign, a second decimal mark) must not shift the anchor.
|
|
227
|
+
*/
|
|
228
|
+
#significantBefore(text, caret) {
|
|
229
|
+
let count = 0;
|
|
230
|
+
let sawSign = false;
|
|
231
|
+
let sawDigit = false;
|
|
232
|
+
let sawDot = false;
|
|
233
|
+
for (const ch of this.#normalize(text.slice(0, caret))) {
|
|
234
|
+
if (ch >= "0" && ch <= "9") {
|
|
235
|
+
count += 1;
|
|
236
|
+
sawDigit = true;
|
|
237
|
+
} else if ((ch === "-" || ch === "+") && !sawSign && !sawDigit && !sawDot) {
|
|
238
|
+
count += 1;
|
|
239
|
+
sawSign = true;
|
|
240
|
+
} else if (this.#isDecimalMark(ch) && !sawDot) {
|
|
241
|
+
count += 1;
|
|
242
|
+
sawDot = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return count;
|
|
246
|
+
}
|
|
247
|
+
/** Digits, signs, and the decimal mark anchor the caret; grouping does not. */
|
|
248
|
+
#isSignificant(ch) {
|
|
249
|
+
if (ch >= "0" && ch <= "9") return true;
|
|
250
|
+
if (ch === "-" || ch === "+") return true;
|
|
251
|
+
return ch === this.#decimal || ch === "." && this.#group !== ".";
|
|
252
|
+
}
|
|
253
|
+
/** Writes the normalized value to the field, the SR span, and the empty hook. */
|
|
254
|
+
#write(value) {
|
|
74
255
|
const isEmpty = value === null;
|
|
75
256
|
if (this.hasFieldTarget) this.fieldTarget.value = isEmpty ? "" : String(value);
|
|
76
257
|
if (this.hasSrValueTarget) {
|
|
77
|
-
this.srValueTarget.textContent = isEmpty ? "" : this.#
|
|
258
|
+
this.srValueTarget.textContent = isEmpty ? "" : this.#accessible.format(value);
|
|
78
259
|
}
|
|
79
260
|
this.element.toggleAttribute("data-stimeo--currency-input-empty", isEmpty);
|
|
261
|
+
}
|
|
262
|
+
/** {@link #write}, then reports a moved value as `change` (`""` rides with `null`). */
|
|
263
|
+
#reflect(value, formatted) {
|
|
264
|
+
this.#write(value);
|
|
80
265
|
if (value !== this.#lastValue) {
|
|
81
266
|
this.#lastValue = value;
|
|
82
|
-
|
|
267
|
+
this.dispatch("change", { detail: { value, formatted } });
|
|
83
268
|
}
|
|
84
269
|
}
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Scans arbitrary input text into its number-shaped parts and value. Keeps
|
|
272
|
+
* digits, one leading sign, one decimal mark, and the fraction verbatim;
|
|
273
|
+
* everything else (grouping, symbols, words) is dropped. An ASCII "." is only
|
|
274
|
+
* a decimal mark where it is not the locale's grouping separator. Precision
|
|
275
|
+
* plays no part here — rounding belongs to the blur-time format.
|
|
276
|
+
*/
|
|
277
|
+
#scan(text) {
|
|
278
|
+
let sign = "";
|
|
279
|
+
let int = "";
|
|
280
|
+
let hasDot = false;
|
|
281
|
+
let frac = "";
|
|
282
|
+
for (const ch of this.#normalize(text)) {
|
|
283
|
+
if (ch >= "0" && ch <= "9") {
|
|
284
|
+
if (hasDot) frac += ch;
|
|
285
|
+
else int += ch;
|
|
286
|
+
} else if ((ch === "-" || ch === "+") && sign === "" && int === "" && !hasDot) {
|
|
287
|
+
sign = ch;
|
|
288
|
+
} else if (this.#isDecimalMark(ch) && !hasDot) {
|
|
289
|
+
hasDot = true;
|
|
98
290
|
}
|
|
99
291
|
}
|
|
100
|
-
|
|
101
|
-
const value = Number(
|
|
102
|
-
return Number.isFinite(value) ? value : null;
|
|
292
|
+
const parts = { sign, int, hasDot, frac };
|
|
293
|
+
const value = Number(`${sign}${int}.${frac}`);
|
|
294
|
+
return { parts, value: Number.isFinite(value) ? value : null };
|
|
295
|
+
}
|
|
296
|
+
/** Whether `ch` reads as this locale's decimal mark. */
|
|
297
|
+
#isDecimalMark(ch) {
|
|
298
|
+
return ch === this.#decimal || ch === "." && this.#group !== ".";
|
|
103
299
|
}
|
|
104
|
-
/**
|
|
105
|
-
#
|
|
106
|
-
|
|
300
|
+
/** Re-syncs field / srValue / hook from the current display without dispatching. */
|
|
301
|
+
#resync() {
|
|
302
|
+
if (!this.hasDisplayTarget) return;
|
|
303
|
+
this.#write(this.#scan(this.displayTarget.value).value);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Validates the declared Values, falling back to each Value's default when a
|
|
307
|
+
* declaration cannot be interpreted (a malformed locale or currency tag, a
|
|
308
|
+
* precision outside `Intl`'s 0–100 integer range), and rebuilds the cached
|
|
309
|
+
* formatters from the validated set.
|
|
310
|
+
*/
|
|
311
|
+
#revalidate() {
|
|
312
|
+
this.#locale = "en-US";
|
|
313
|
+
try {
|
|
314
|
+
new Intl.NumberFormat(this.localeValue);
|
|
315
|
+
this.#locale = this.localeValue;
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
const precision = this.precisionValue;
|
|
319
|
+
this.#precision = Number.isInteger(precision) && precision >= 0 && precision <= 100 ? precision : 2;
|
|
320
|
+
this.#currency = "";
|
|
321
|
+
if (this.currencyValue !== "") {
|
|
322
|
+
try {
|
|
323
|
+
new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.currencyValue });
|
|
324
|
+
this.#currency = this.currencyValue;
|
|
325
|
+
} catch {
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
this.#grouping = new Intl.NumberFormat(this.#locale, {
|
|
329
|
+
useGrouping: true,
|
|
330
|
+
maximumFractionDigits: 0
|
|
331
|
+
});
|
|
332
|
+
this.#fixed = new Intl.NumberFormat(this.#locale, {
|
|
107
333
|
useGrouping: true,
|
|
108
|
-
minimumFractionDigits:
|
|
109
|
-
maximumFractionDigits: this
|
|
334
|
+
minimumFractionDigits: this.#precision,
|
|
335
|
+
maximumFractionDigits: this.#precision
|
|
110
336
|
});
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
337
|
+
this.#accessible = this.#currency ? new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.#currency }) : this.#fixed;
|
|
338
|
+
const parts = new Intl.NumberFormat(this.#locale).formatToParts(11111.1);
|
|
339
|
+
this.#group = parts.find((p) => p.type === "group")?.value ?? ",";
|
|
340
|
+
this.#decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
|
|
341
|
+
this.#digits.clear();
|
|
342
|
+
const digitFormatter = new Intl.NumberFormat(this.#locale, { useGrouping: false });
|
|
343
|
+
for (let i = 0; i <= 9; i++) {
|
|
344
|
+
const digit = digitFormatter.format(i);
|
|
345
|
+
if (digit !== String(i)) this.#digits.set(digit, String(i));
|
|
120
346
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Maps the locale's own digits to ASCII, folds full-width forms (the digits,
|
|
350
|
+
* signs, and marks an IME confirms as 0-9+-.,) through the shared
|
|
351
|
+
* half-width mapping, and reads U+2212 MINUS SIGN as "-", which some locales'
|
|
352
|
+
* formatted output uses for negatives.
|
|
353
|
+
*/
|
|
354
|
+
#normalize(text) {
|
|
355
|
+
let out = "";
|
|
356
|
+
for (const ch of text) {
|
|
357
|
+
const mapped = this.#digits.get(ch);
|
|
358
|
+
if (mapped !== void 0) {
|
|
359
|
+
out += mapped;
|
|
360
|
+
} else if (ch === "\u2212") {
|
|
361
|
+
out += "-";
|
|
362
|
+
} else {
|
|
363
|
+
out += halfWidthChar(ch);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
132
367
|
}
|
|
133
368
|
};
|
|
134
|
-
function countDigits(text) {
|
|
135
|
-
let count = 0;
|
|
136
|
-
for (const ch of text) if (ch >= "0" && ch <= "9") count += 1;
|
|
137
|
-
return count;
|
|
138
|
-
}
|
|
139
369
|
function round(value, precision) {
|
|
140
370
|
const factor = 10 ** Math.max(0, precision);
|
|
141
371
|
const rounded = Math.round(value * factor) / factor;
|
|
372
|
+
if (!Number.isFinite(rounded)) return value;
|
|
142
373
|
return rounded === 0 ? 0 : rounded;
|
|
143
374
|
}
|
|
144
375
|
|