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.
@@ -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 parsed = this.#parse(this.displayTarget.value);
19
- this.#lastValue = parsed === null ? null : round(parsed, this.precisionValue);
20
- if (this.displayTarget.value.trim() !== "") {
21
- this.#reformat(true);
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 digit count, and syncs the field,
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 number = this.#parse(raw);
43
- if (number === null) {
44
- this.displayTarget.value = "";
45
- this.#reflect(null, "");
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 value = fixedPrecision ? round(number, this.precisionValue) : number;
49
- const caret = this.displayTarget.selectionStart;
50
- const digitsBeforeCaret = typeof caret === "number" ? countDigits(raw.slice(0, caret)) : null;
51
- const formatted = this.#formatNumber(value, fixedPrecision);
52
- this.displayTarget.value = formatted;
53
- if (digitsBeforeCaret !== null) this.#restoreCaret(formatted, digitsBeforeCaret);
54
- this.#reflect(value, formatted);
55
- }
56
- /** Restores the caret to sit just after the n-th digit of the new string. */
57
- #restoreCaret(formatted, digitsBefore) {
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 >= digitsBefore) {
212
+ if (seen >= significantBefore) {
62
213
  position = i;
63
214
  break;
64
215
  }
65
- if (/\d/.test(formatted[i])) seen += 1;
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
- /** Writes the normalized value to the field, the SR span, and `change`. */
73
- #reflect(value, formatted) {
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.#accessibleText(value);
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
- if (!isEmpty) this.dispatch("change", { detail: { value, formatted } });
267
+ this.dispatch("change", { detail: { value, formatted } });
83
268
  }
84
269
  }
85
- /** Parses arbitrary input text into a finite number, or `null` when blank. */
86
- #parse(text) {
87
- if (text.trim() === "") return null;
88
- const { decimal } = this.#separators();
89
- let cleaned = "";
90
- let sawDot = false;
91
- for (let i = 0; i < text.length; i++) {
92
- const ch = text[i];
93
- if (ch >= "0" && ch <= "9") cleaned += ch;
94
- else if ((ch === "-" || ch === "+") && cleaned === "") cleaned += ch;
95
- else if ((ch === decimal || ch === ".") && !sawDot) {
96
- cleaned += ".";
97
- sawDot = true;
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
- if (cleaned === "" || cleaned === "-" || cleaned === "+" || cleaned === ".") return null;
101
- const value = Number(cleaned);
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
- /** Formats a number with grouping for the display field. */
105
- #formatNumber(value, fixedPrecision) {
106
- const formatter = new Intl.NumberFormat(this.localeValue, {
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: fixedPrecision ? this.precisionValue : 0,
109
- maximumFractionDigits: this.precisionValue
334
+ minimumFractionDigits: this.#precision,
335
+ maximumFractionDigits: this.#precision
110
336
  });
111
- return formatter.format(value);
112
- }
113
- /** The text announced to assistive tech (currency-aware when configured). */
114
- #accessibleText(value) {
115
- if (this.currencyValue) {
116
- return new Intl.NumberFormat(this.localeValue, {
117
- style: "currency",
118
- currency: this.currencyValue
119
- }).format(value);
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
- return new Intl.NumberFormat(this.localeValue, {
122
- minimumFractionDigits: this.precisionValue,
123
- maximumFractionDigits: this.precisionValue
124
- }).format(value);
125
- }
126
- /** Resolves the locale's grouping and decimal separator characters. */
127
- #separators() {
128
- const parts = new Intl.NumberFormat(this.localeValue).formatToParts(11111.1);
129
- const group = parts.find((p) => p.type === "group")?.value ?? ",";
130
- const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
131
- return { group, decimal };
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