@daformat/react-number-flow-input 1.0.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.
Files changed (42) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +298 -0
  3. package/dist/NumberFlowInput.d.ts +60 -0
  4. package/dist/NumberFlowInput.js +3099 -0
  5. package/dist/NumberFlowInput.js.map +1 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/styles.d.ts +5 -0
  10. package/dist/styles.js +140 -0
  11. package/dist/styles.js.map +1 -0
  12. package/dist/utils/barrelWheel.d.ts +12 -0
  13. package/dist/utils/barrelWheel.js +83 -0
  14. package/dist/utils/barrelWheel.js.map +1 -0
  15. package/dist/utils/changes.d.ts +87 -0
  16. package/dist/utils/changes.js +794 -0
  17. package/dist/utils/changes.js.map +1 -0
  18. package/dist/utils/combineRefs.d.ts +5 -0
  19. package/dist/utils/combineRefs.js +16 -0
  20. package/dist/utils/combineRefs.js.map +1 -0
  21. package/dist/utils/cssEasing.d.ts +24 -0
  22. package/dist/utils/cssEasing.js +25 -0
  23. package/dist/utils/cssEasing.js.map +1 -0
  24. package/dist/utils/formatting.d.ts +33 -0
  25. package/dist/utils/formatting.js +99 -0
  26. package/dist/utils/formatting.js.map +1 -0
  27. package/dist/utils/maybe.d.ts +3 -0
  28. package/dist/utils/maybe.js +2 -0
  29. package/dist/utils/maybe.js.map +1 -0
  30. package/dist/utils/moveElementPreservingAnimation.d.ts +6 -0
  31. package/dist/utils/moveElementPreservingAnimation.js +61 -0
  32. package/dist/utils/moveElementPreservingAnimation.js.map +1 -0
  33. package/dist/utils/nullable.d.ts +12 -0
  34. package/dist/utils/nullable.js +19 -0
  35. package/dist/utils/nullable.js.map +1 -0
  36. package/dist/utils/textCleaning.d.ts +6 -0
  37. package/dist/utils/textCleaning.js +60 -0
  38. package/dist/utils/textCleaning.js.map +1 -0
  39. package/dist/utils/utils.d.ts +33 -0
  40. package/dist/utils/utils.js +162 -0
  41. package/dist/utils/utils.js.map +1 -0
  42. package/package.json +68 -0
@@ -0,0 +1,3099 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState, } from "react";
3
+ import { injectStyles } from "./styles.js";
4
+ import { cleanupWidthAnimation, clearBarrelWheelsAndSpans, getAllBarrelWheels, getBarrelWheel, repositionBarrelWheel, setWidthConstraints, temporarilyRemoveAncestorsTransform, } from "./utils/barrelWheel.js";
5
+ import { getChanges, getFormattedChanges, getPositionChanges, getReplacementChanges, getReplacementFormattedChanges, } from "./utils/changes.js";
6
+ import { combineRefs } from "./utils/combineRefs.js";
7
+ import { formatValue, getLocaleSeparators, isRawCharacter, } from "./utils/formatting.js";
8
+ import { moveElementPreservingAnimation } from "./utils/moveElementPreservingAnimation.js";
9
+ import { isNonNullable } from "./utils/nullable.js";
10
+ import { cleanText, parseNumberValue } from "./utils/textCleaning.js";
11
+ import { clearWidthStyles, getSelectionRange, hasWidthStyles, isTransparent, measureText, removeTransparentColor, setCursorAtPosition, setCursorPositionInElement, } from "./utils/utils.js";
12
+ export const NumberFlowInput = forwardRef(({ value, defaultValue, onChange, autoAddLeadingZero = false, allowNegative, decimalScale, placeholder, locale, format = false, onFocus, onBlur, className, style, isAllowed, autoFocus = false, ...inputProps }, ref) => {
13
+ // Inject the component's stylesheet exactly once, before any layout
14
+ // effects run so the styles are in place for the very first paint.
15
+ useInsertionEffect(() => {
16
+ injectStyles();
17
+ }, []);
18
+ const spanRef = useRef(null);
19
+ const inputRef = useRef(null);
20
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
21
+ const isControlled = value !== undefined;
22
+ const actualValue = isControlled ? value : uncontrolledValue;
23
+ // Raw display value (unformatted, e.g., "1234.56")
24
+ const [displayValue, setDisplayValue] = useState(actualValue?.toString() ?? "");
25
+ const [, setCursorPosition] = useState(0);
26
+ const { maxLength } = inputProps;
27
+ // Get separators for the locale (or default locale if format is true)
28
+ const separators = useMemo(() => {
29
+ if (locale || format) {
30
+ return getLocaleSeparators(locale);
31
+ }
32
+ return { decimal: ".", group: "," };
33
+ }, [locale, format]);
34
+ const localeSeparators = useMemo(() => {
35
+ return getLocaleSeparators(locale);
36
+ }, [locale]);
37
+ // Format options for the formatValue function
38
+ const formatOptions = useMemo(() => ({ locale, format, autoAddLeadingZero, separators }), [locale, format, autoAddLeadingZero, separators]);
39
+ // Compute the formatted display value
40
+ const formattedDisplayValue = useMemo(() => formatValue(displayValue, formatOptions), [displayValue, formatOptions]);
41
+ // Track previous formatted value for change detection
42
+ const prevFormattedValueRef = useRef(formattedDisplayValue);
43
+ // Undo/Redo history - stores cursor position before and after each change
44
+ const historyRef = useRef([]);
45
+ const historyIndexRef = useRef(-1);
46
+ const isUndoRedoRef = useRef(false);
47
+ // Track if we should prevent the next input event (for leading 0 bug)
48
+ const shouldPreventInputRef = useRef(false);
49
+ const preventInputCursorPosRef = useRef(0);
50
+ // Track ResizeObservers for digits with barrel wheel animations
51
+ const resizeObserversRef = useRef(new Map());
52
+ // Helper to check if a character is a "raw" character (digit, decimal, or minus)
53
+ const isRawChar = useCallback((char) => isRawCharacter(char, separators.decimal), [separators]);
54
+ // Helper to map a raw index to a formatted index
55
+ // Raw: "1234.56" -> Formatted: "1,234.56"
56
+ // Raw index 0 (1) -> Formatted index 0
57
+ // Raw index 1 (2) -> Formatted index 2 (after comma)
58
+ const mapRawToFormattedIndex = useCallback((rawValue, formattedValue, rawIndex) => {
59
+ if (rawIndex <= 0) {
60
+ return 0;
61
+ }
62
+ if (rawIndex >= rawValue.length) {
63
+ return formattedValue.length;
64
+ }
65
+ // Count how many raw characters we've seen to find the rawIndex'th one
66
+ let rawCount = 0;
67
+ for (let formattedIndex = 0; formattedIndex < formattedValue.length; formattedIndex++) {
68
+ const formattedChar = formattedValue[formattedIndex];
69
+ // Check if this is a raw character (digit, decimal, minus) vs format character (comma, space, etc.)
70
+ if (isRawChar(formattedChar)) {
71
+ if (rawCount === rawIndex) {
72
+ // Found the rawIndex'th raw character
73
+ return formattedIndex;
74
+ }
75
+ rawCount++;
76
+ }
77
+ }
78
+ return formattedValue.length;
79
+ }, [isRawChar]);
80
+ // Helper to map a formatted index to a raw index
81
+ const mapFormattedToRawIndex = useCallback((rawValue, formattedValue, formattedIndex) => {
82
+ if (formattedIndex <= 0) {
83
+ return 0;
84
+ }
85
+ if (formattedIndex >= formattedValue.length) {
86
+ return rawValue.length;
87
+ }
88
+ let rawIndex = 0;
89
+ for (let i = 0; i < formattedIndex && i < formattedValue.length; i++) {
90
+ const char = formattedValue[i];
91
+ if (isRawChar(char)) {
92
+ rawIndex++;
93
+ }
94
+ }
95
+ return Math.min(rawIndex, rawValue.length);
96
+ }, [isRawChar]);
97
+ // Helper to format a raw value string (raw always uses '.' as decimal)
98
+ const formatRawValue = useCallback((rawValue) => formatValue(rawValue, formatOptions), [formatOptions]);
99
+ const addToHistory = useCallback((text, cursorPosBefore, cursorPosAfter, value) => {
100
+ historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
101
+ historyRef.current.push({
102
+ text,
103
+ cursorPosBefore,
104
+ cursorPosAfter,
105
+ value,
106
+ });
107
+ historyIndexRef.current = historyRef.current.length - 1;
108
+ if (historyRef.current.length > 50) {
109
+ historyRef.current.shift();
110
+ historyIndexRef.current--;
111
+ }
112
+ }, []);
113
+ // Helper function to reposition all existing barrel wheels
114
+ const repositionAllBarrelWheels = useCallback(() => {
115
+ if (!spanRef.current?.parentElement) {
116
+ return;
117
+ }
118
+ const parentContainer = spanRef.current.parentElement;
119
+ const existingBarrelWheels = parentContainer.querySelectorAll("[data-char-index][data-barrel-wheel]");
120
+ if (existingBarrelWheels.length === 0) {
121
+ return;
122
+ }
123
+ const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current.parentElement);
124
+ // Get all spans in DOM order and calculate their FINAL widths using measureText
125
+ // This is needed because some spans may be animating their width from 0
126
+ const allSpans = Array.from(spanRef.current.querySelectorAll("[data-char-index]"));
127
+ // Sort by data-char-index to ensure correct order
128
+ allSpans.sort((a, b) => {
129
+ const aIndex = parseInt(a.getAttribute("data-char-index") ?? "0", 10);
130
+ const bIndex = parseInt(b.getAttribute("data-char-index") ?? "0", 10);
131
+ return aIndex - bIndex;
132
+ });
133
+ // Calculate final widths for all spans using measureText
134
+ const spanWidths = new Map();
135
+ allSpans.forEach((span) => {
136
+ const index = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
137
+ if (index >= 0 && span.textContent) {
138
+ // Use measureText to get the accurate final width
139
+ const finalWidth = measureText(span.textContent, span);
140
+ spanWidths.set(index, finalWidth);
141
+ }
142
+ });
143
+ // Get the container's position for relative positioning
144
+ const containerRect = spanRef.current.getBoundingClientRect();
145
+ const parentRect = parentContainer.getBoundingClientRect();
146
+ existingBarrelWheels.forEach((wheel) => {
147
+ const wheelEl = wheel;
148
+ const indexStr = wheelEl.getAttribute("data-char-index");
149
+ if (indexStr !== null) {
150
+ const barrelIndex = parseInt(indexStr, 10);
151
+ if (!isNaN(barrelIndex) && barrelIndex >= 0 && spanRef.current) {
152
+ const charSpan = spanRef.current.querySelector(`[data-char-index="${barrelIndex}"]`);
153
+ if (charSpan) {
154
+ if (!isTransparent(charSpan)) {
155
+ charSpan.style.color = "transparent";
156
+ }
157
+ // Calculate the left position by summing widths of all preceding spans
158
+ let leftPosition = containerRect.left - parentRect.left;
159
+ for (let i = 0; i < barrelIndex; i++) {
160
+ const width = spanWidths.get(i);
161
+ if (width !== undefined) {
162
+ leftPosition += width;
163
+ }
164
+ }
165
+ // Get the barrel wheel span's dimensions
166
+ const barrelSpanWidth = spanWidths.get(barrelIndex) ?? 0;
167
+ const barrelSpanHeight = charSpan.getBoundingClientRect().height;
168
+ // Position the barrel wheel
169
+ wheelEl.style.left = `${leftPosition}px`;
170
+ wheelEl.style.top = `${containerRect.top - parentRect.top}px`;
171
+ wheelEl.style.width = `${barrelSpanWidth}px`;
172
+ wheelEl.style.height = `${barrelSpanHeight}px`;
173
+ }
174
+ }
175
+ }
176
+ });
177
+ cleanup();
178
+ }, []);
179
+ // Helper function to remove barrel wheels at specific indices
180
+ const removeBarrelWheelsAtIndices = useCallback((indices) => {
181
+ if (!spanRef.current) {
182
+ return;
183
+ }
184
+ const parentContainer = spanRef.current.parentElement;
185
+ if (!parentContainer) {
186
+ return;
187
+ }
188
+ indices.forEach((index) => {
189
+ // Clean up ResizeObserver for this index
190
+ const observer = resizeObserversRef.current.get(index);
191
+ if (observer) {
192
+ observer.disconnect();
193
+ resizeObserversRef.current.delete(index);
194
+ }
195
+ const charSpan = spanRef.current?.querySelector(`[data-char-index="${index}"]`);
196
+ if (charSpan) {
197
+ if (charSpan.hasAttribute("data-width-animate")) {
198
+ cleanupWidthAnimation(charSpan);
199
+ }
200
+ if (isTransparent(charSpan)) {
201
+ charSpan.style.color = "";
202
+ }
203
+ }
204
+ const barrelWheel = parentContainer.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
205
+ if (barrelWheel) {
206
+ barrelWheel.remove();
207
+ // After removing barrel wheel, do a final pass to ensure the span at THIS index is not still transparent
208
+ // IMPORTANT: Only check the span at the specific index, not other spans with the same final digit
209
+ // This prevents conflicts when multiple barrel wheels have the same final digit
210
+ if (spanRef.current) {
211
+ const spanAtIndex = spanRef.current.querySelector(`[data-char-index="${index}"]`);
212
+ if (spanAtIndex) {
213
+ const hasBarrelWheel = parentContainer.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
214
+ if (isTransparent(spanAtIndex) && !hasBarrelWheel) {
215
+ spanAtIndex.style.color = "";
216
+ }
217
+ }
218
+ }
219
+ }
220
+ });
221
+ }, []);
222
+ const updateValue = useCallback((newText, newCursorPos, selectionStart, selectionEnd, options = {}) => {
223
+ const { skipHistory = false, skipOnChange = false, skipCursor = false, asReplacement = false, } = options;
224
+ if (isAllowed && !isAllowed(Number(newText))) {
225
+ return;
226
+ }
227
+ // Clean up stale width animations from fast typing
228
+ if (spanRef.current) {
229
+ spanRef.current
230
+ .querySelectorAll("[data-char-index][data-show]")
231
+ .forEach((span) => {
232
+ const el = span;
233
+ if (hasWidthStyles(el) &&
234
+ !el.hasAttribute("data-width-animate")) {
235
+ clearWidthStyles(el);
236
+ }
237
+ });
238
+ }
239
+ const oldText = displayValue;
240
+ const rawCleaned = newText.replace(/[^\d.-]/g, "");
241
+ const { cleanedText: baseCleanedText, leadingZerosRemoved } = cleanText(rawCleaned, autoAddLeadingZero);
242
+ const cleanedText = baseCleanedText;
243
+ if (leadingZerosRemoved > 0 && newCursorPos > 0) {
244
+ newCursorPos = Math.max(0, newCursorPos - leadingZerosRemoved);
245
+ }
246
+ if (autoAddLeadingZero) {
247
+ // Check rawCleaned (before leading zero was added) to detect if we added a leading zero
248
+ if (rawCleaned.startsWith(".")) {
249
+ newCursorPos += 1;
250
+ }
251
+ else if (rawCleaned.startsWith("-.")) {
252
+ newCursorPos += 1;
253
+ }
254
+ }
255
+ const numberValue = parseNumberValue(cleanedText);
256
+ if (!skipOnChange) {
257
+ onChange?.(numberValue);
258
+ }
259
+ setUncontrolledValue(numberValue);
260
+ setDisplayValue(cleanedText);
261
+ setCursorPosition(newCursorPos);
262
+ if (!skipHistory && !isUndoRedoRef.current) {
263
+ addToHistory(cleanedText, selectionEnd, newCursorPos, numberValue);
264
+ }
265
+ // Update DOM with animation
266
+ if (spanRef.current) {
267
+ // Special handling for leading zero removal and decimal point deletion
268
+ let adjustedOldText = oldText;
269
+ let adjustedSelectionStart = selectionStart;
270
+ let adjustedSelectionEnd = selectionEnd;
271
+ let adjustedNewCursorPos = newCursorPos;
272
+ // Check if we deleted a decimal point that was after a leading zero (e.g., "0.122" -> "122")
273
+ const deletedDecimalAfterZero = oldText.startsWith("0.") &&
274
+ !cleanedText.startsWith("0") &&
275
+ cleanedText.length > 0 &&
276
+ oldText.length > cleanedText.length &&
277
+ oldText.includes(".") &&
278
+ !cleanedText.includes(".");
279
+ // Check if we're replacing a single leading "0" with a non-zero digit (e.g., "0" -> "1")
280
+ const replacedLeadingZero = oldText === "0" &&
281
+ cleanedText.length > 0 &&
282
+ cleanedText[0] !== "0" &&
283
+ !cleanedText.startsWith("0.");
284
+ if (deletedDecimalAfterZero) {
285
+ // When deleting "." from "0.122", we get "0122" which becomes "122"
286
+ // We want all digits in "122" to have data-show, so we compare "" with "122"
287
+ adjustedOldText = "";
288
+ adjustedSelectionStart = 0;
289
+ adjustedSelectionEnd = 0;
290
+ adjustedNewCursorPos = cleanedText.length;
291
+ }
292
+ else if (replacedLeadingZero) {
293
+ // Special case: "0" -> "1" (or any non-zero digit)
294
+ // We want to treat this as if we're starting from scratch
295
+ adjustedOldText = "";
296
+ adjustedSelectionStart = 0;
297
+ adjustedSelectionEnd = 0;
298
+ adjustedNewCursorPos = cleanedText.length;
299
+ }
300
+ else if (leadingZerosRemoved > 0 && oldText.length > 0) {
301
+ // If we removed leading zeros, adjust the oldText comparison
302
+ if (oldText.startsWith("0") &&
303
+ oldText.length > 1 &&
304
+ oldText[1] !== ".") {
305
+ // More general case: if oldText was "0123" and we typed "4" to get "01234" which became "1234",
306
+ // we need to adjust the comparison
307
+ const oldWithoutLeadingZeros = oldText.replace(/^0+/, "");
308
+ if (oldWithoutLeadingZeros ===
309
+ cleanedText.slice(0, oldWithoutLeadingZeros.length)) {
310
+ // The old text (without leading zeros) matches the start of new text
311
+ // This means we just added characters at the end
312
+ adjustedOldText = oldWithoutLeadingZeros;
313
+ // Adjust selection and cursor positions to account for removed leading zeros
314
+ adjustedSelectionStart = Math.max(0, selectionStart - leadingZerosRemoved);
315
+ adjustedSelectionEnd = Math.max(0, selectionEnd - leadingZerosRemoved);
316
+ adjustedNewCursorPos = Math.max(0, newCursorPos - leadingZerosRemoved);
317
+ }
318
+ }
319
+ }
320
+ // Compute formatted versions for display
321
+ const oldFormattedText = prevFormattedValueRef.current;
322
+ const newFormattedText = formatRawValue(cleanedText);
323
+ // When invoked as a wholesale replacement (e.g. `value` prop
324
+ // changed externally), align digits column-by-column so changed
325
+ // digits play barrel-wheel animations instead of all being treated
326
+ // as added.
327
+ const changes = asReplacement
328
+ ? getReplacementChanges(adjustedOldText, cleanedText)
329
+ : getChanges(adjustedOldText, cleanedText, adjustedSelectionStart, adjustedSelectionEnd, adjustedNewCursorPos);
330
+ const formattedChanges = asReplacement
331
+ ? getReplacementFormattedChanges(oldFormattedText, newFormattedText, separators.decimal)
332
+ : getFormattedChanges(oldFormattedText, newFormattedText, adjustedNewCursorPos, adjustedSelectionStart, adjustedOldText.length, separators.decimal);
333
+ // Detect position changes for x-position animation (used later)
334
+ const positionChanges = getPositionChanges(oldFormattedText, newFormattedText, separators.decimal);
335
+ // For FLIP animation: capture old positions BEFORE any DOM changes
336
+ // Store by character + formatted index
337
+ const oldPositions = new Map();
338
+ if (spanRef.current) {
339
+ const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current);
340
+ const containerRect = spanRef.current.getBoundingClientRect();
341
+ for (let i = 0; i < oldFormattedText.length; i++) {
342
+ const char = oldFormattedText[i];
343
+ // Find the span for this index
344
+ const span = spanRef.current.querySelector(`[data-char-index="${i}"]`);
345
+ // Verify span exists and content matches (DOM might be out of sync)
346
+ if (span && char !== undefined && span.textContent === char) {
347
+ const rect = span.getBoundingClientRect();
348
+ // Key: char + its position in the old formatted string
349
+ const key = `${char}@${i}`;
350
+ oldPositions.set(key, {
351
+ x: rect.left - containerRect.left,
352
+ width: rect.width,
353
+ });
354
+ }
355
+ }
356
+ cleanup();
357
+ }
358
+ // Update the previous formatted value ref for next comparison
359
+ // so barrel wheel and span shifting logic will use the correct old value
360
+ prevFormattedValueRef.current = newFormattedText;
361
+ // Update barrel wheel indices if characters were inserted or deleted before them
362
+ // IMPORTANT: Barrel wheel indices are FORMATTED indices (include separators)
363
+ // We need to map raw selection positions to formatted positions for correct comparison
364
+ if (spanRef.current?.parentElement) {
365
+ const parentContainer = spanRef.current.parentElement;
366
+ const existingBarrelWheels = parentContainer.querySelectorAll("[data-char-index][data-barrel-wheel]");
367
+ // Note: oldFormattedText is captured above before updating prevFormattedValueRef
368
+ existingBarrelWheels.forEach((wheel) => {
369
+ const wheelEl = wheel;
370
+ const oldFormattedIndexStr = wheelEl.getAttribute("data-char-index");
371
+ const finalDigitStr = wheelEl.getAttribute("data-final-digit");
372
+ if (oldFormattedIndexStr !== null) {
373
+ const oldFormattedIndex = parseInt(oldFormattedIndexStr, 10);
374
+ if (!isNaN(oldFormattedIndex) && oldFormattedIndex >= 0) {
375
+ // Convert barrel wheel's formatted index to raw index
376
+ const barrelWheelRawIndex = mapFormattedToRawIndex(adjustedOldText, oldFormattedText, oldFormattedIndex);
377
+ const lengthDiff = cleanedText.length - adjustedOldText.length;
378
+ // hadSelection is true when user had text selected (replacement creates barrel wheel at new position)
379
+ // For single-char insertions, adjustedSelectionStart === adjustedSelectionEnd
380
+ const hadUserSelection = adjustedSelectionStart < adjustedSelectionEnd &&
381
+ lengthDiff >= 0; // Only for insertions/replacements, not deletions
382
+ if (!hadUserSelection &&
383
+ lengthDiff > 0 &&
384
+ adjustedSelectionStart <= barrelWheelRawIndex) {
385
+ // Characters were inserted at or before this index, so shift it forward
386
+ // Calculate new raw index
387
+ const numInserted = adjustedNewCursorPos - adjustedSelectionStart;
388
+ const newRawIndex = barrelWheelRawIndex + numInserted;
389
+ // Map back to formatted index
390
+ const newFormattedIndex = mapRawToFormattedIndex(cleanedText, newFormattedText, newRawIndex);
391
+ wheelEl.setAttribute("data-char-index", newFormattedIndex.toString());
392
+ const observer = resizeObserversRef.current.get(oldFormattedIndex);
393
+ if (observer) {
394
+ resizeObserversRef.current.delete(oldFormattedIndex);
395
+ resizeObserversRef.current.set(newFormattedIndex, observer);
396
+ }
397
+ if (finalDigitStr && spanRef.current) {
398
+ const oldSpan = spanRef.current.querySelector(`[data-char-index="${oldFormattedIndex}"]`);
399
+ if (oldSpan && oldSpan.textContent === finalDigitStr) {
400
+ oldSpan.setAttribute("data-char-index", newFormattedIndex.toString());
401
+ }
402
+ }
403
+ }
404
+ else if (lengthDiff < 0 &&
405
+ adjustedSelectionStart < barrelWheelRawIndex) {
406
+ // Characters were deleted before this index, so shift it backward
407
+ // Note: No hadUserSelection check for deletions - always shift barrel wheels after deletion point
408
+ const numDeleted = adjustedSelectionEnd - adjustedSelectionStart;
409
+ const newRawIndex = Math.max(0, barrelWheelRawIndex - numDeleted);
410
+ // Only update if the new index is valid and the barrel wheel should still exist
411
+ // Also ensure the barrel wheel is not in the deletion range
412
+ if (newRawIndex < cleanedText.length &&
413
+ barrelWheelRawIndex >= adjustedSelectionEnd) {
414
+ // Map back to formatted index
415
+ const newFormattedIndex = mapRawToFormattedIndex(cleanedText, newFormattedText, newRawIndex);
416
+ wheelEl.setAttribute("data-char-index", newFormattedIndex.toString());
417
+ const observer = resizeObserversRef.current.get(oldFormattedIndex);
418
+ if (observer) {
419
+ resizeObserversRef.current.delete(oldFormattedIndex);
420
+ resizeObserversRef.current.set(newFormattedIndex, observer);
421
+ }
422
+ if (finalDigitStr && spanRef.current) {
423
+ // Find the span that matches the final digit - it might still be at oldIndex
424
+ // or it might have already been shifted
425
+ const oldSpan = spanRef.current.querySelector(`[data-char-index="${oldFormattedIndex}"]`);
426
+ if (oldSpan && oldSpan.textContent === finalDigitStr) {
427
+ oldSpan.setAttribute("data-char-index", newFormattedIndex.toString());
428
+ }
429
+ else {
430
+ // Also check if there's a span at the new index that matches
431
+ const newSpan = spanRef.current.querySelector(`[data-char-index="${newFormattedIndex}"]`);
432
+ if (newSpan &&
433
+ newSpan.textContent === finalDigitStr) {
434
+ // Span is already at the correct index, just ensure it's marked correctly
435
+ }
436
+ else {
437
+ // Search for any transparent span with the final digit
438
+ const allSpans = spanRef.current.querySelectorAll("[data-char-index]");
439
+ Array.from(allSpans).forEach((span) => {
440
+ const spanEl = span;
441
+ if (spanEl.textContent === finalDigitStr &&
442
+ isTransparent(spanEl)) {
443
+ spanEl.setAttribute("data-char-index", newFormattedIndex.toString());
444
+ }
445
+ });
446
+ }
447
+ }
448
+ }
449
+ }
450
+ else {
451
+ // Barrel wheel is now out of bounds, remove it
452
+ const observer = resizeObserversRef.current.get(oldFormattedIndex);
453
+ if (observer) {
454
+ observer.disconnect();
455
+ resizeObserversRef.current.delete(oldFormattedIndex);
456
+ }
457
+ wheelEl.remove();
458
+ }
459
+ }
460
+ }
461
+ }
462
+ });
463
+ }
464
+ // Incrementally update DOM instead of full reconstruction
465
+ if (spanRef.current) {
466
+ // First, update indices of existing spans that need to shift due to insertions or deletions
467
+ // This handles the case where characters are inserted/deleted before existing spans
468
+ // Do this BEFORE collecting spans by index, so the map is correct
469
+ // IMPORTANT: Span indices are FORMATTED (include separators), but selection positions are RAW
470
+ // We need to map between them correctly
471
+ // Note: oldFormattedText is captured at the start of updateValue, before prevFormattedValueRef update
472
+ const lengthDiff = cleanedText.length - adjustedOldText.length;
473
+ // For insertions, hadSelection means user had text selected (replacement scenario)
474
+ // For deletions, we always want to shift spans, so don't check hadSelection
475
+ const hadSelectionForInsert = adjustedSelectionStart < adjustedSelectionEnd && lengthDiff > 0;
476
+ if (lengthDiff !== 0 &&
477
+ (lengthDiff < 0 || !hadSelectionForInsert)) {
478
+ // Collect all spans first
479
+ const allSpans = [];
480
+ let nodeToUpdate = spanRef.current.firstChild;
481
+ while (nodeToUpdate) {
482
+ if (nodeToUpdate instanceof HTMLElement &&
483
+ nodeToUpdate.hasAttribute("data-char-index")) {
484
+ allSpans.push(nodeToUpdate);
485
+ }
486
+ nodeToUpdate = nodeToUpdate.nextSibling;
487
+ }
488
+ // Update indices of spans that need to shift
489
+ allSpans.forEach((span) => {
490
+ const oldFormattedIndexStr = span.getAttribute("data-char-index");
491
+ if (oldFormattedIndexStr !== null) {
492
+ const oldFormattedIndex = parseInt(oldFormattedIndexStr, 10);
493
+ if (!isNaN(oldFormattedIndex) && oldFormattedIndex >= 0) {
494
+ // Convert span's formatted index to raw index
495
+ const spanRawIndex = mapFormattedToRawIndex(adjustedOldText, oldFormattedText, oldFormattedIndex);
496
+ if (lengthDiff > 0) {
497
+ // Characters were inserted - shift spans at and after the insertion point
498
+ if (spanRawIndex >= adjustedSelectionStart &&
499
+ spanRawIndex < adjustedOldText.length) {
500
+ const numInserted = adjustedNewCursorPos - adjustedSelectionStart;
501
+ const newRawIndex = spanRawIndex + numInserted;
502
+ if (newRawIndex < cleanedText.length) {
503
+ const newFormattedIndex = mapRawToFormattedIndex(cleanedText, newFormattedText, newRawIndex);
504
+ span.setAttribute("data-char-index", newFormattedIndex.toString());
505
+ }
506
+ }
507
+ }
508
+ else if (lengthDiff < 0) {
509
+ // Characters were deleted - shift spans after the deletion point backward
510
+ const numDeleted = adjustedSelectionEnd - adjustedSelectionStart;
511
+ if (spanRawIndex >= adjustedSelectionStart &&
512
+ spanRawIndex < adjustedSelectionEnd) {
513
+ // Span is in the deletion range - it will be removed by cleanup logic
514
+ }
515
+ else if (spanRawIndex >= adjustedSelectionEnd) {
516
+ // Span is after the deletion point, shift it backward
517
+ const newRawIndex = Math.max(0, spanRawIndex - numDeleted);
518
+ if (newRawIndex < cleanedText.length) {
519
+ const newFormattedIndex = mapRawToFormattedIndex(cleanedText, newFormattedText, newRawIndex);
520
+ span.setAttribute("data-char-index", newFormattedIndex.toString());
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ });
527
+ }
528
+ // Get all existing spans mapped by index (after updating indices)
529
+ // Also track transparent spans separately to handle them specially
530
+ // Track visible spans by content to find them when indices shift
531
+ const existingSpansByIndex = new Map();
532
+ const allExistingSpans = [];
533
+ const transparentSpans = new Map();
534
+ const transparentSpansByContent = new Map();
535
+ const visibleSpansByContent = new Map();
536
+ const textNodesToRemove = [];
537
+ let node = spanRef.current.firstChild;
538
+ while (node) {
539
+ if (node instanceof HTMLElement &&
540
+ node.hasAttribute("data-char-index")) {
541
+ const index = parseInt(node.getAttribute("data-char-index") ?? "-1", 10);
542
+ if (index >= 0) {
543
+ const isTransparentSpan = isTransparent(node);
544
+ if (isTransparentSpan) {
545
+ transparentSpans.set(index, node);
546
+ const content = node.textContent ?? "";
547
+ if (!transparentSpansByContent.has(content)) {
548
+ transparentSpansByContent.set(content, node);
549
+ }
550
+ }
551
+ else {
552
+ // Track visible spans by content for fallback lookup
553
+ const content = node.textContent ?? "";
554
+ if (!visibleSpansByContent.has(content)) {
555
+ visibleSpansByContent.set(content, []);
556
+ }
557
+ visibleSpansByContent.get(content).push(node);
558
+ }
559
+ if (!existingSpansByIndex.has(index) || !isTransparentSpan) {
560
+ existingSpansByIndex.set(index, node);
561
+ }
562
+ allExistingSpans.push(node);
563
+ }
564
+ }
565
+ else if (node.nodeType === Node.TEXT_NODE) {
566
+ textNodesToRemove.push(node);
567
+ }
568
+ node = node.nextSibling;
569
+ }
570
+ // Remove any stray text nodes (from undo/redo or other operations)
571
+ textNodesToRemove.forEach((textNode) => {
572
+ if (textNode.parentNode) {
573
+ textNode.parentNode.removeChild(textNode);
574
+ }
575
+ });
576
+ // Track which spans we've used
577
+ const usedSpans = new Set();
578
+ const newSpans = [];
579
+ let referenceNode = null;
580
+ // Build new structure, reusing existing spans when possible
581
+ // Get parent container once for barrel wheel checks
582
+ const parentContainer = spanRef.current.parentElement;
583
+ // Use formatted text for display (includes thousand separators, etc.)
584
+ for (let i = 0; i < newFormattedText.length; i++) {
585
+ const char = newFormattedText[i];
586
+ const isUnchanged = formattedChanges.unchangedIndices.has(i);
587
+ // For barrel wheel, we need to map from formatted index to raw index
588
+ const rawIndex = mapFormattedToRawIndex(cleanedText, newFormattedText, i);
589
+ // Barrel wheels only apply to digit positions. Without this
590
+ // guard, a separator (e.g. the comma in "1,000") can map to
591
+ // the same raw index as an adjacent digit's barrel wheel and
592
+ // get treated as a wheel position, which strips its
593
+ // `data-flow` and leaves it stuck at width:0.
594
+ const isDigitChar = char !== undefined && /^\d$/.test(char);
595
+ const barrelWheel = isDigitChar
596
+ ? changes.barrelWheelIndices.get(rawIndex)
597
+ : undefined;
598
+ // Check if there's a barrel wheel in DOM for this index (indices may have shifted)
599
+ const hasBarrelWheelInDOM = parentContainer?.querySelector(`[data-char-index="${i}"][data-barrel-wheel]`);
600
+ // Also check if there's a transparent span at this index that indicates a barrel wheel
601
+ // (the barrel wheel might have shifted and the span index was updated but barrel wheel query might miss it)
602
+ let hasTransparentSpanWithBarrelWheel = false;
603
+ const spanAtI = existingSpansByIndex.get(i);
604
+ if (spanAtI && isTransparent(spanAtI)) {
605
+ // Check if there's a barrel wheel anywhere that might be associated with this span
606
+ const allBarrelWheels = parentContainer?.querySelectorAll("[data-char-index][data-barrel-wheel]");
607
+ if (allBarrelWheels) {
608
+ // Check if any barrel wheel's final digit matches this span's content
609
+ Array.from(allBarrelWheels).forEach((wheel) => {
610
+ const wheelEl = wheel;
611
+ const finalDigit = wheelEl.getAttribute("data-final-digit");
612
+ if (finalDigit === char) {
613
+ hasTransparentSpanWithBarrelWheel = true;
614
+ }
615
+ });
616
+ }
617
+ }
618
+ const hasBarrelWheel = barrelWheel !== undefined ||
619
+ !!hasBarrelWheelInDOM ||
620
+ hasTransparentSpanWithBarrelWheel;
621
+ // Try to reuse existing span at this index
622
+ let span = existingSpansByIndex.get(i);
623
+ let shouldReuse = false;
624
+ // Only reuse if this index is marked as unchanged (not added)
625
+ // New characters should always get new spans to trigger animations
626
+ const isAdded = formattedChanges.addedIndices.has(i);
627
+ const isUnchangedIndex = formattedChanges.unchangedIndices.has(i);
628
+ // If no exact index match, try to find by content for unchanged characters
629
+ // This handles the case where indices shifted due to separator insertion
630
+ if ((!span || span.textContent !== char) &&
631
+ char &&
632
+ !isAdded &&
633
+ isUnchangedIndex) {
634
+ const candidates = visibleSpansByContent.get(char) ?? [];
635
+ for (const candidate of candidates) {
636
+ if (!usedSpans.has(candidate) &&
637
+ candidate.textContent === char) {
638
+ span = candidate;
639
+ break;
640
+ }
641
+ }
642
+ }
643
+ if (span &&
644
+ span.textContent === char &&
645
+ !usedSpans.has(span) &&
646
+ !isAdded &&
647
+ isUnchangedIndex) {
648
+ // Check if span is in approximately the right position
649
+ // (within 2 positions is acceptable to avoid unnecessary reordering)
650
+ let currentPos = 0;
651
+ let node = spanRef.current.firstChild;
652
+ while (node && node !== span) {
653
+ if (node instanceof HTMLElement &&
654
+ node.hasAttribute("data-char-index")) {
655
+ currentPos++;
656
+ }
657
+ node = node.nextSibling;
658
+ }
659
+ if (Math.abs(currentPos - i) <= 2) {
660
+ shouldReuse = true;
661
+ }
662
+ }
663
+ if (shouldReuse && span) {
664
+ // Reuse existing span - ensure textContent matches (defensive check)
665
+ if (span.textContent !== char) {
666
+ span.textContent = char ?? "";
667
+ }
668
+ // Update data-char-index to new position
669
+ span.setAttribute("data-char-index", i.toString());
670
+ // Update attributes if needed
671
+ const shouldHaveFlow = !barrelWheel;
672
+ const shouldHaveShow = isUnchanged;
673
+ const hasFlow = span.hasAttribute("data-flow");
674
+ const hasShow = span.hasAttribute("data-show");
675
+ if (shouldHaveFlow && !hasFlow) {
676
+ span.setAttribute("data-flow", "");
677
+ }
678
+ else if (!shouldHaveFlow && hasFlow) {
679
+ span.removeAttribute("data-flow");
680
+ }
681
+ if (shouldHaveShow && !hasShow) {
682
+ span.setAttribute("data-show", "");
683
+ }
684
+ else if (!shouldHaveShow && hasShow) {
685
+ span.removeAttribute("data-show");
686
+ }
687
+ // Clear any leftover transparency from a previous render's
688
+ // barrel wheel: this span is being reused at a position that
689
+ // does NOT have a new wheel (barrelWheel is falsy in this
690
+ // branch), so it must be visible. Stale transparency can
691
+ // appear when rapid prop changes interrupt wheel cleanup.
692
+ if (!barrelWheel && isTransparent(span)) {
693
+ removeTransparentColor(span);
694
+ }
695
+ usedSpans.add(span);
696
+ // Move to correct position if needed, preserving any ongoing animations
697
+ if (referenceNode) {
698
+ const nextSibling = referenceNode.nextSibling;
699
+ if (span.previousSibling !== referenceNode && nextSibling) {
700
+ moveElementPreservingAnimation(span, spanRef.current, nextSibling);
701
+ }
702
+ else if (!nextSibling &&
703
+ span.parentNode !== spanRef.current) {
704
+ moveElementPreservingAnimation(span, spanRef.current, null);
705
+ }
706
+ }
707
+ referenceNode = span;
708
+ }
709
+ else {
710
+ // Check if there's an existing span that's currently animating (barrel wheel or width)
711
+ // First check if there's a transparent span at this index (might have shifted)
712
+ let existingSpan = existingSpansByIndex.get(i);
713
+ // If no span at this index, check if there's a transparent span that shifted here
714
+ if (!existingSpan && transparentSpans.has(i)) {
715
+ existingSpan = transparentSpans.get(i);
716
+ }
717
+ // Also check all transparent spans to see if any match this character and should be at this index
718
+ // This handles the case where a transparent span shifted but wasn't found in the map
719
+ if (!existingSpan || !isTransparent(existingSpan)) {
720
+ // First, check if there's a transparent span with matching content
721
+ const matchingTransparentSpan = char
722
+ ? transparentSpansByContent.get(char)
723
+ : undefined;
724
+ if (matchingTransparentSpan &&
725
+ !usedSpans.has(matchingTransparentSpan) &&
726
+ isTransparent(matchingTransparentSpan)) {
727
+ // Check if there's a barrel wheel that matches this span
728
+ const allBarrelWheels = parentContainer?.querySelectorAll("[data-char-index][data-barrel-wheel]");
729
+ if (allBarrelWheels) {
730
+ for (const wheel of Array.from(allBarrelWheels)) {
731
+ const wheelEl = wheel;
732
+ const wheelIndex = parseInt(wheelEl.getAttribute("data-char-index") ?? "-1", 10);
733
+ const finalDigit = wheelEl.getAttribute("data-final-digit");
734
+ if (finalDigit === char) {
735
+ // This transparent span is associated with a barrel wheel
736
+ // If the barrel wheel is at index i, or if it should be at i (was shifted)
737
+ if (wheelIndex === i) {
738
+ existingSpan = matchingTransparentSpan;
739
+ matchingTransparentSpan.setAttribute("data-char-index", i.toString());
740
+ break;
741
+ }
742
+ else if (wheelIndex > i && lengthDiff < 0) {
743
+ // Barrel wheel was shifted but might not be at i yet - update both
744
+ existingSpan = matchingTransparentSpan;
745
+ matchingTransparentSpan.setAttribute("data-char-index", i.toString());
746
+ wheelEl.setAttribute("data-char-index", i.toString());
747
+ const observer = resizeObserversRef.current.get(wheelIndex);
748
+ if (observer) {
749
+ resizeObserversRef.current.delete(wheelIndex);
750
+ resizeObserversRef.current.set(i, observer);
751
+ }
752
+ break;
753
+ }
754
+ }
755
+ }
756
+ }
757
+ }
758
+ }
759
+ const hasWidthAnimation = existingSpan?.hasAttribute("data-width-animate");
760
+ // Check if span is hidden (indicates barrel wheel animation in progress)
761
+ const isHidden = existingSpan?.style.color === "transparent" ||
762
+ existingSpan?.style.color === "rgba(0, 0, 0, 0)" ||
763
+ (existingSpan &&
764
+ window.getComputedStyle(existingSpan).color ===
765
+ "rgba(0, 0, 0, 0)");
766
+ // Don't reuse span if there's a barrel wheel animation for this index
767
+ // The barrel wheel code needs to set up width animation, so let it handle the span
768
+ // Only reuse if it's a width animation without barrel wheel (width animation cleanup)
769
+ const shouldReuseSpan = !hasBarrelWheel &&
770
+ hasWidthAnimation &&
771
+ !isHidden &&
772
+ existingSpan &&
773
+ !usedSpans.has(existingSpan);
774
+ // IMPORTANT: If there's a transparent span at this index, it's part of an ongoing barrel wheel
775
+ // We MUST reuse it, even if changes.barrelWheelIndices doesn't have this index
776
+ // (because the barrel wheel's index may have shifted)
777
+ // Also check if there's a barrel wheel anywhere that matches this character
778
+ let hasMatchingBarrelWheel = hasBarrelWheel || hasBarrelWheelInDOM;
779
+ if (!hasMatchingBarrelWheel && isHidden && existingSpan) {
780
+ // Check all barrel wheels to see if any match this character
781
+ const allBarrelWheels = parentContainer?.querySelectorAll("[data-char-index][data-barrel-wheel]");
782
+ if (allBarrelWheels) {
783
+ for (const wheel of Array.from(allBarrelWheels)) {
784
+ const wheelEl = wheel;
785
+ const finalDigit = wheelEl.getAttribute("data-final-digit");
786
+ if (finalDigit === char) {
787
+ hasMatchingBarrelWheel = true;
788
+ break;
789
+ }
790
+ }
791
+ }
792
+ }
793
+ const shouldReuseTransparentSpan = isHidden &&
794
+ existingSpan &&
795
+ !usedSpans.has(existingSpan) &&
796
+ (hasBarrelWheel ||
797
+ hasBarrelWheelInDOM ||
798
+ hasMatchingBarrelWheel);
799
+ // If there's a transparent span (barrel wheel animation), reuse it
800
+ if (shouldReuseTransparentSpan && existingSpan) {
801
+ // Reuse the transparent span - it's part of an ongoing barrel wheel animation
802
+ span = existingSpan;
803
+ // Update textContent if needed (should match the final digit)
804
+ if (span.textContent !== char) {
805
+ span.textContent = char ?? "";
806
+ }
807
+ // Ensure data-char-index is correct
808
+ span.setAttribute("data-char-index", i.toString());
809
+ // Keep it transparent (barrel wheel is still animating)
810
+ span.style.color = "transparent";
811
+ // Don't set data-flow (barrel wheel handles it)
812
+ span.removeAttribute("data-flow");
813
+ if (isUnchanged) {
814
+ span.setAttribute("data-show", "");
815
+ }
816
+ else {
817
+ span.removeAttribute("data-show");
818
+ }
819
+ usedSpans.add(span);
820
+ // Ensure it's in the correct position, preserving any ongoing animations
821
+ if (referenceNode) {
822
+ const nextSibling = referenceNode.nextSibling;
823
+ if (span.previousSibling !== referenceNode && nextSibling) {
824
+ moveElementPreservingAnimation(span, spanRef.current, nextSibling);
825
+ }
826
+ else if (!nextSibling &&
827
+ span.parentNode !== spanRef.current) {
828
+ moveElementPreservingAnimation(span, spanRef.current, null);
829
+ }
830
+ }
831
+ referenceNode = span;
832
+ }
833
+ else if (shouldReuseSpan && existingSpan) {
834
+ // If there's an existing span that's animating width (not barrel wheel), update it
835
+ // Update the existing animating span
836
+ span = existingSpan;
837
+ // Update textContent if it changed (shouldn't happen during barrel wheel, but defensive)
838
+ if (span.textContent !== char) {
839
+ span.textContent = char ?? "";
840
+ }
841
+ // Update data-char-index to ensure it's correct
842
+ span.setAttribute("data-char-index", i.toString());
843
+ // Update attributes
844
+ if (!barrelWheel) {
845
+ span.setAttribute("data-flow", "");
846
+ }
847
+ else {
848
+ span.removeAttribute("data-flow");
849
+ }
850
+ if (isUnchanged) {
851
+ span.setAttribute("data-show", "");
852
+ }
853
+ else {
854
+ span.removeAttribute("data-show");
855
+ }
856
+ usedSpans.add(span);
857
+ // Ensure it's in the correct position, preserving any ongoing animations
858
+ if (referenceNode) {
859
+ const nextSibling = referenceNode.nextSibling;
860
+ if (span.previousSibling !== referenceNode && nextSibling) {
861
+ moveElementPreservingAnimation(span, spanRef.current, nextSibling);
862
+ }
863
+ else if (!nextSibling &&
864
+ span.parentNode !== spanRef.current) {
865
+ moveElementPreservingAnimation(span, spanRef.current, null);
866
+ }
867
+ }
868
+ referenceNode = span;
869
+ }
870
+ else {
871
+ // If there's a barrel wheel animation, we need to ensure the span exists
872
+ // but let the barrel wheel code handle width animation setup
873
+ // So we'll update the existing span if it exists, or create a new one
874
+ if (hasBarrelWheel &&
875
+ existingSpan &&
876
+ !usedSpans.has(existingSpan)) {
877
+ // Update existing span for barrel wheel - barrel wheel code will handle width animation
878
+ span = existingSpan;
879
+ // Preserve old width BEFORE updating textContent to prevent flash
880
+ // Get the current width (which is the old digit's width)
881
+ const cleanup = temporarilyRemoveAncestorsTransform(span);
882
+ const oldWidth = span.getBoundingClientRect().width;
883
+ cleanup();
884
+ // Ensure display is inline-block so width can be applied
885
+ span.style.display = "inline-block";
886
+ // Constrain to old width IMMEDIATELY before updating textContent
887
+ // This prevents flash of natural width when textContent changes
888
+ if (oldWidth > 0) {
889
+ span.style.width = `${oldWidth}px`;
890
+ span.style.minWidth = `${oldWidth}px`;
891
+ span.style.maxWidth = `${oldWidth}px`;
892
+ // Force reflow to ensure width constraint is applied
893
+ void span.offsetWidth;
894
+ }
895
+ // NOW update textContent (span is already constrained, so no flash)
896
+ if (span.textContent !== char) {
897
+ span.textContent = char ?? "";
898
+ }
899
+ // Update data-char-index to ensure it's correct
900
+ span.setAttribute("data-char-index", i.toString());
901
+ // Don't set data-flow for barrel wheel (barrel wheel code handles it)
902
+ span.removeAttribute("data-flow");
903
+ if (isUnchanged) {
904
+ span.setAttribute("data-show", "");
905
+ }
906
+ else {
907
+ span.removeAttribute("data-show");
908
+ }
909
+ // Reset color in case it was hidden from previous animation
910
+ span.style.color = "";
911
+ // Remove data-width-animate if present (barrel wheel code will add it)
912
+ span.removeAttribute("data-width-animate");
913
+ usedSpans.add(span);
914
+ // Ensure it's in the correct position, preserving any ongoing animations
915
+ if (referenceNode) {
916
+ const nextSibling = referenceNode.nextSibling;
917
+ if (span.previousSibling !== referenceNode &&
918
+ nextSibling) {
919
+ moveElementPreservingAnimation(span, spanRef.current, nextSibling);
920
+ }
921
+ else if (!nextSibling &&
922
+ span.parentNode !== spanRef.current) {
923
+ moveElementPreservingAnimation(span, spanRef.current, null);
924
+ }
925
+ }
926
+ referenceNode = span;
927
+ }
928
+ else {
929
+ // Reach this branch when the existing span at this
930
+ // position is transparent (isHidden) but neither this
931
+ // position nor any other wheel in the DOM matches its
932
+ // content (all of hasBarrelWheel / hasBarrelWheelInDOM /
933
+ // hasMatchingBarrelWheel were false — otherwise we would
934
+ // have taken `shouldReuseTransparentSpan` above).
935
+ //
936
+ // That means the transparency is *orphaned* — left
937
+ // behind by a previous render's wheel whose cleanup
938
+ // never landed on this span (rapid prop changes or
939
+ // index reshuffles can do this). Reuse the span but
940
+ // clear its color so the underlying character is
941
+ // visible; if this position truly has a new wheel, the
942
+ // wheel-creation code below will re-hide it.
943
+ if (isHidden &&
944
+ existingSpan &&
945
+ !usedSpans.has(existingSpan)) {
946
+ span = existingSpan;
947
+ if (span.textContent !== char) {
948
+ span.textContent = char ?? "";
949
+ }
950
+ span.setAttribute("data-char-index", i.toString());
951
+ removeTransparentColor(span);
952
+ cleanupWidthAnimation(span);
953
+ if (!barrelWheel) {
954
+ span.setAttribute("data-flow", "");
955
+ }
956
+ else {
957
+ span.removeAttribute("data-flow");
958
+ }
959
+ if (isUnchanged) {
960
+ span.setAttribute("data-show", "");
961
+ }
962
+ else {
963
+ span.removeAttribute("data-show");
964
+ }
965
+ usedSpans.add(span);
966
+ // Ensure it's in the correct position, preserving any ongoing animations
967
+ if (referenceNode) {
968
+ const nextSibling = referenceNode.nextSibling;
969
+ if (span.previousSibling !== referenceNode &&
970
+ nextSibling) {
971
+ moveElementPreservingAnimation(span, spanRef.current, nextSibling);
972
+ }
973
+ else if (!nextSibling &&
974
+ span.parentNode !== spanRef.current) {
975
+ moveElementPreservingAnimation(span, spanRef.current, null);
976
+ }
977
+ }
978
+ referenceNode = span;
979
+ }
980
+ else {
981
+ // Remove existing span at this index if it exists and doesn't match
982
+ if (existingSpan && existingSpan.textContent !== char) {
983
+ // Only remove if not animating (not hidden, not part of barrel wheel, and not in flow animation)
984
+ // Also don't remove if the span's content appears elsewhere in the new text
985
+ // (it might be reused at a different index when separators shift things)
986
+ const hasFlowAnimation = existingSpan.hasAttribute("data-flow");
987
+ const contentWillBeReused = existingSpan.textContent &&
988
+ newFormattedText.includes(existingSpan.textContent);
989
+ const isCurrentlyAnimating = hasFlowAnimation ||
990
+ (isHidden && !hasBarrelWheel) ||
991
+ (hasWidthAnimation && !hasBarrelWheel);
992
+ if (!isCurrentlyAnimating && !contentWillBeReused) {
993
+ existingSpan.remove();
994
+ existingSpansByIndex.delete(i);
995
+ usedSpans.delete(existingSpan);
996
+ }
997
+ }
998
+ // Create new span
999
+ span = document.createElement("span");
1000
+ span.setAttribute("data-char-index", i.toString());
1001
+ span.textContent = char ?? "";
1002
+ if (!barrelWheel) {
1003
+ span.setAttribute("data-flow", "");
1004
+ }
1005
+ if (isUnchanged) {
1006
+ span.setAttribute("data-show", "");
1007
+ }
1008
+ else if (isAdded) {
1009
+ // Animate width from 0 for newly added digits
1010
+ span.style.width = "0px";
1011
+ span.style.minWidth = "0px";
1012
+ span.style.maxWidth = "0px";
1013
+ }
1014
+ // Insert at correct position
1015
+ if (referenceNode) {
1016
+ spanRef.current.insertBefore(span, referenceNode.nextSibling);
1017
+ }
1018
+ else {
1019
+ spanRef.current.insertBefore(span, spanRef.current.firstChild);
1020
+ }
1021
+ referenceNode = span;
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ newSpans.push(span);
1027
+ }
1028
+ // Remove unused spans that aren't animating
1029
+ // Also check for spans that are hidden (color: transparent) which indicates barrel wheel animation
1030
+ // IMPORTANT: Check for barrel wheels in DOM, not just formattedChanges.barrelWheelIndices,
1031
+ // because indices may have shifted when characters were inserted before animating digits
1032
+ allExistingSpans.forEach((span) => {
1033
+ if (!usedSpans.has(span)) {
1034
+ const index = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
1035
+ const isHidden = span.style.color === "transparent" ||
1036
+ span.style.color === "rgba(0, 0, 0, 0)" ||
1037
+ window.getComputedStyle(span).color === "rgba(0, 0, 0, 0)";
1038
+ // Check if there's actually a barrel wheel for this index in the DOM
1039
+ // (indices may have shifted, so formattedChanges.barrelWheelIndices might not be accurate)
1040
+ const hasBarrelWheelInDOM = parentContainer?.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
1041
+ // Map formatted index to raw index for barrel wheel check
1042
+ const rawIdx = mapFormattedToRawIndex(cleanedText, newFormattedText, index);
1043
+ // Only consider a barrel-wheel match if this span actually
1044
+ // holds a digit AND the new formatted character at this
1045
+ // position is also a digit. Otherwise a stale digit span
1046
+ // sitting at a position that is now a separator (or out of
1047
+ // bounds) can collide with an adjacent digit's raw barrel
1048
+ // wheel index and incorrectly survive cleanup.
1049
+ const spanIsDigit = /^\d$/.test(span.textContent ?? "");
1050
+ const newCharAtIndex = newFormattedText[index];
1051
+ const newCharIsDigit = newCharAtIndex !== undefined && /^\d$/.test(newCharAtIndex);
1052
+ const hasBarrelWheel = (spanIsDigit &&
1053
+ newCharIsDigit &&
1054
+ changes.barrelWheelIndices.has(rawIdx)) ||
1055
+ !!hasBarrelWheelInDOM;
1056
+ const hasWidthAnimation = span.hasAttribute("data-width-animate");
1057
+ // `isHidden` alone is NOT a reason to keep a span: a span
1058
+ // can be left transparent by an interrupted wheel from a
1059
+ // previous render whose cleanup landed on a different
1060
+ // span. Only true ongoing animations should pin the span
1061
+ // in place.
1062
+ const isCurrentlyAnimating = hasBarrelWheel || hasWidthAnimation;
1063
+ const isOutOfBounds = index >= newFormattedText.length;
1064
+ // If text is empty, remove all spans regardless of animation state
1065
+ // This handles the case where user selects all and deletes
1066
+ if (newFormattedText.length === 0) {
1067
+ span.remove();
1068
+ }
1069
+ else if (isOutOfBounds) {
1070
+ // Out-of-bounds unused spans are always ghosts. If a
1071
+ // wheel still references this index, drop it too so the
1072
+ // wheel doesn't try to un-hide a now-removed span (or
1073
+ // worse, a different span that happens to land here
1074
+ // later).
1075
+ if (hasBarrelWheelInDOM) {
1076
+ hasBarrelWheelInDOM.remove();
1077
+ resizeObserversRef.current.get(index)?.disconnect();
1078
+ resizeObserversRef.current.delete(index);
1079
+ }
1080
+ span.remove();
1081
+ }
1082
+ else if (!isCurrentlyAnimating) {
1083
+ span.remove();
1084
+ }
1085
+ }
1086
+ });
1087
+ // Final verification: ensure all spans have correct textContent
1088
+ // This catches any cases where spans weren't properly updated
1089
+ for (let i = 0; i < newFormattedText.length; i++) {
1090
+ const char = newFormattedText[i];
1091
+ const span = newSpans[i];
1092
+ if (span && span.textContent !== char) {
1093
+ span.textContent = char ?? "";
1094
+ }
1095
+ }
1096
+ // Reposition all barrel wheels after DOM update completes
1097
+ // This ensures barrel wheels stay aligned whenever characters are inserted/deleted
1098
+ // Use requestAnimationFrame to ensure DOM has fully updated
1099
+ requestAnimationFrame(() => {
1100
+ repositionAllBarrelWheels();
1101
+ });
1102
+ // Remove any remaining spans with invalid indices or wrong characters
1103
+ // Also check for duplicate indices and transparent spans that are ghosts
1104
+ const allSpans = Array.from(spanRef.current.querySelectorAll("[data-char-index]"));
1105
+ // Track spans by index to detect duplicates
1106
+ const spansByIndex = new Map();
1107
+ allSpans.forEach((span) => {
1108
+ const index = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
1109
+ if (index >= 0) {
1110
+ if (!spansByIndex.has(index)) {
1111
+ spansByIndex.set(index, []);
1112
+ }
1113
+ spansByIndex.get(index).push(span);
1114
+ }
1115
+ });
1116
+ // Handle duplicate indices - keep the one that's animating or matches the character, remove others
1117
+ spansByIndex.forEach((spans, index) => {
1118
+ if (spans.length > 1) {
1119
+ // Find the span that should be kept
1120
+ let spanToKeep = null;
1121
+ // First, try to find one that matches the expected character.
1122
+ // The lookup uses the FORMATTED text (not the raw text) since
1123
+ // `index` is a formatted-string index. Mixing them up causes a
1124
+ // stale digit span sitting at a separator's position to be
1125
+ // preferred over the correctly-placed separator span.
1126
+ const expectedChar = newFormattedText[index];
1127
+ for (const span of spans) {
1128
+ if (span.textContent === expectedChar) {
1129
+ const isHidden = span.style.color === "transparent" ||
1130
+ span.style.color === "rgba(0, 0, 0, 0)" ||
1131
+ window.getComputedStyle(span).color ===
1132
+ "rgba(0, 0, 0, 0)";
1133
+ // Prefer non-transparent spans that match the character
1134
+ if (!isHidden) {
1135
+ spanToKeep = span;
1136
+ break;
1137
+ }
1138
+ else if (!spanToKeep) {
1139
+ // Keep transparent one as fallback if it matches
1140
+ spanToKeep = span;
1141
+ }
1142
+ }
1143
+ }
1144
+ // If no matching character found, find the animating one
1145
+ if (!spanToKeep) {
1146
+ for (const span of spans) {
1147
+ const isHidden = span.style.color === "transparent" ||
1148
+ span.style.color === "rgba(0, 0, 0, 0)" ||
1149
+ window.getComputedStyle(span).color ===
1150
+ "rgba(0, 0, 0, 0)";
1151
+ const hasBarrelWheelInDOM = parentContainer?.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
1152
+ const hasWidthAnimation = span.hasAttribute("data-width-animate");
1153
+ if (isHidden || hasBarrelWheelInDOM || hasWidthAnimation) {
1154
+ spanToKeep = span;
1155
+ break;
1156
+ }
1157
+ }
1158
+ }
1159
+ // If still no span found, keep the first one
1160
+ if (!spanToKeep && spans.length > 0) {
1161
+ const firstSpan = spans.find(() => true);
1162
+ if (firstSpan) {
1163
+ spanToKeep = firstSpan;
1164
+ }
1165
+ }
1166
+ // Remove all other spans at this index
1167
+ if (spanToKeep) {
1168
+ spans.forEach((span) => {
1169
+ if (span !== spanToKeep) {
1170
+ span.remove();
1171
+ }
1172
+ });
1173
+ }
1174
+ }
1175
+ });
1176
+ // Now check remaining spans for out-of-bounds or wrong characters
1177
+ const remainingSpans = Array.from(spanRef.current.querySelectorAll("[data-char-index]"));
1178
+ remainingSpans.forEach((span) => {
1179
+ const index = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
1180
+ const isHidden = span.style.color === "transparent" ||
1181
+ span.style.color === "rgba(0, 0, 0, 0)" ||
1182
+ window.getComputedStyle(span).color === "rgba(0, 0, 0, 0)";
1183
+ // Check if there's actually a barrel wheel for this index in the DOM
1184
+ const hasBarrelWheelInDOM = parentContainer?.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
1185
+ // Map formatted index to raw index for barrel wheel check
1186
+ const rawIdx = mapFormattedToRawIndex(cleanedText, newFormattedText, index);
1187
+ // Only consider a barrel-wheel match if this span actually
1188
+ // holds a digit; otherwise a separator can collide with an
1189
+ // adjacent digit's barrel-wheel raw index.
1190
+ const spanIsDigit = /^\d$/.test(span.textContent ?? "");
1191
+ const hasBarrelWheel = (spanIsDigit && changes.barrelWheelIndices.has(rawIdx)) ||
1192
+ !!hasBarrelWheelInDOM;
1193
+ const hasWidthAnimation = span.hasAttribute("data-width-animate");
1194
+ // `isHidden` is intentionally NOT part of the "animating"
1195
+ // determination here. An interrupted wheel from a previous
1196
+ // render can leave a span at color:transparent without any
1197
+ // active wheel/width animation; that orphan transparency
1198
+ // shouldn't pin a stale span in the DOM.
1199
+ const isCurrentlyAnimating = hasBarrelWheel || hasWidthAnimation;
1200
+ // If text is empty, remove all spans
1201
+ if (newFormattedText.length === 0) {
1202
+ span.remove();
1203
+ return;
1204
+ }
1205
+ // Remove if index is out of bounds. When the value shrinks
1206
+ // (e.g. 8 digits → 6 digits during fast prop swaps), trailing
1207
+ // spans get left behind at indices that no longer exist; if
1208
+ // they were also transparent the previous heuristic kept
1209
+ // them around as "animating" and they reappeared as ghost
1210
+ // chars once a wheel cleanup eventually un-hid them. Drop
1211
+ // them — and any orphan wheel that still points at this
1212
+ // index — unconditionally.
1213
+ if (index < 0 || index >= newFormattedText.length) {
1214
+ if (hasBarrelWheelInDOM) {
1215
+ hasBarrelWheelInDOM.remove();
1216
+ resizeObserversRef.current.get(index)?.disconnect();
1217
+ resizeObserversRef.current.delete(index);
1218
+ }
1219
+ span.remove();
1220
+ }
1221
+ else if (span.textContent !== newFormattedText[index]) {
1222
+ // Character mismatch - update or remove
1223
+ // If it's a transparent span with wrong character and no barrel wheel, it's a ghost - remove it
1224
+ if (isHidden && !hasBarrelWheelInDOM && !hasBarrelWheel) {
1225
+ span.remove();
1226
+ }
1227
+ else if (!isCurrentlyAnimating) {
1228
+ // Update textContent to match
1229
+ span.textContent = newFormattedText[index] ?? "";
1230
+ }
1231
+ }
1232
+ });
1233
+ }
1234
+ // Animate new characters and create barrel wheels
1235
+ // Use requestAnimationFrame to ensure DOM is updated
1236
+ requestAnimationFrame(() => {
1237
+ const flowElements = spanRef.current?.querySelectorAll("[data-flow]");
1238
+ if (flowElements) {
1239
+ Array.from(flowElements).forEach((element) => {
1240
+ const index = parseInt(element.getAttribute("data-char-index") ??
1241
+ "-1", 10);
1242
+ if (element instanceof HTMLElement &&
1243
+ formattedChanges.addedIndices.has(index)) {
1244
+ element.dataset.show = "";
1245
+ const span = spanRef.current;
1246
+ if (span && element.textContent) {
1247
+ const width = measureText(element.textContent, span);
1248
+ element.style.width = `${width}px`;
1249
+ element.style.minWidth = `${width}px`;
1250
+ element.style.maxWidth = `${width}px`;
1251
+ // Remove inline width styles after transition completes
1252
+ const handleTransitionEnd = (e) => {
1253
+ if (["width", "min-width", "max-width"].includes(e.propertyName)) {
1254
+ element.style.width = "";
1255
+ element.style.minWidth = "";
1256
+ element.style.maxWidth = "";
1257
+ element.removeEventListener("transitionend", handleTransitionEnd);
1258
+ }
1259
+ };
1260
+ element.addEventListener("transitionend", handleTransitionEnd);
1261
+ }
1262
+ }
1263
+ });
1264
+ }
1265
+ // Create barrel wheels as absolutely positioned elements outside contentEditable
1266
+ // Map raw indices to formatted indices for barrel wheel positioning
1267
+ const barrelWheelIndices = Array.from(changes.barrelWheelIndices.keys());
1268
+ // During rapid prop changes a previous render's wheel can be
1269
+ // left running at a formatted index that this update has no
1270
+ // transition for. The reuse path below only touches wheels
1271
+ // whose index matches one of this update's barrelWheelIndices;
1272
+ // everything else keeps spinning to its (now stale) final
1273
+ // digit, overlapping the new transition visually (multiple
1274
+ // ghosted digits/separators piling up during a spam click).
1275
+ // Finalize and remove those orphan wheels here so only the
1276
+ // current update's wheel cohort is active.
1277
+ // Only snap orphans when this update actually introduces a
1278
+ // new wheel cohort. An "uneventful" update (no transitions of
1279
+ // its own — e.g. a re-render that doesn't change the value)
1280
+ // must leave existing wheels alone or it would kill the
1281
+ // animation that the previous update just started.
1282
+ const wheelSnapParent = spanRef.current?.parentElement;
1283
+ if (barrelWheelIndices.length > 0 && wheelSnapParent) {
1284
+ const intendedFormattedIndices = new Set();
1285
+ for (const rawIdx of barrelWheelIndices) {
1286
+ intendedFormattedIndices.add(mapRawToFormattedIndex(cleanedText, newFormattedText, rawIdx));
1287
+ }
1288
+ const allExistingWheels = wheelSnapParent.querySelectorAll("[data-barrel-wheel][data-char-index]");
1289
+ allExistingWheels.forEach((wheel) => {
1290
+ const wheelEl = wheel;
1291
+ const idxStr = wheelEl.getAttribute("data-char-index");
1292
+ if (idxStr === null) {
1293
+ return;
1294
+ }
1295
+ const idx = parseInt(idxStr, 10);
1296
+ if (intendedFormattedIndices.has(idx)) {
1297
+ return;
1298
+ }
1299
+ const charSpan = spanRef.current?.querySelector(`[data-char-index="${idx}"]`);
1300
+ if (charSpan) {
1301
+ removeTransparentColor(charSpan);
1302
+ cleanupWidthAnimation(charSpan);
1303
+ }
1304
+ resizeObserversRef.current.get(idx)?.disconnect();
1305
+ resizeObserversRef.current.delete(idx);
1306
+ wheelEl.remove();
1307
+ });
1308
+ }
1309
+ const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current);
1310
+ barrelWheelIndices.forEach((rawIndex) => {
1311
+ const barrelWheelData = changes.barrelWheelIndices.get(rawIndex);
1312
+ // Map raw index to formatted index
1313
+ const index = mapRawToFormattedIndex(cleanedText, newFormattedText, rawIndex);
1314
+ if (!barrelWheelData) {
1315
+ return;
1316
+ }
1317
+ const direction = barrelWheelData.direction;
1318
+ const finalDigitStr = barrelWheelData.sequence[barrelWheelData.sequence.length - 1];
1319
+ const finalDigit = finalDigitStr
1320
+ ? parseInt(finalDigitStr, 10)
1321
+ : 0;
1322
+ const initialDigitStr = barrelWheelData.sequence[0];
1323
+ // Determine old and new digits based on direction
1324
+ // When direction is "up": sequence = [old, ..., new] so initialDigitStr = old, finalDigitStr = new
1325
+ // When direction is "down": sequence = [new, ..., old] so initialDigitStr = new, finalDigitStr = old
1326
+ const oldDigitStr = direction === "up" ? initialDigitStr : finalDigitStr;
1327
+ const newDigitStr = direction === "up" ? finalDigitStr : initialDigitStr;
1328
+ // Find the span element at this index
1329
+ const charSpan = spanRef.current?.querySelector(`[data-char-index="${index}"]`);
1330
+ if (!charSpan || !(charSpan instanceof HTMLElement)) {
1331
+ return;
1332
+ }
1333
+ // Get position of the character span relative to the parent container
1334
+ const parentContainer = spanRef.current?.parentElement;
1335
+ if (!parentContainer) {
1336
+ return;
1337
+ }
1338
+ // Check if a barrel wheel already exists for this index
1339
+ const existingWheel = parentContainer.querySelector(`[data-char-index="${index}"][data-barrel-wheel]`);
1340
+ if (existingWheel) {
1341
+ // Reuse existing barrel wheel - update direction and position
1342
+ const existingWrapper = existingWheel.querySelector("[data-barrel-wheel-digits-wrapper]");
1343
+ if (existingWrapper) {
1344
+ // IMPORTANT: Update the character span's textContent to the new digit
1345
+ // This ensures the span has the correct final digit when the animation completes
1346
+ if (charSpan.textContent !== newDigitStr) {
1347
+ charSpan.textContent = newDigitStr ?? "";
1348
+ }
1349
+ existingWheel.setAttribute("data-final-digit", newDigitStr ?? "");
1350
+ requestAnimationFrame(() => {
1351
+ existingWrapper.style.setProperty("--digit-position", newDigitStr ?? "");
1352
+ });
1353
+ const oldDigitWidth = oldDigitStr
1354
+ ? measureText(oldDigitStr, charSpan)
1355
+ : 0;
1356
+ const newDigitWidth = newDigitStr
1357
+ ? measureText(newDigitStr, charSpan)
1358
+ : 0;
1359
+ if (oldDigitWidth > 0 && newDigitWidth > 0) {
1360
+ const currentWidth = charSpan.getBoundingClientRect().width;
1361
+ setWidthConstraints(charSpan, currentWidth);
1362
+ charSpan.setAttribute("data-width-animate", "");
1363
+ requestAnimationFrame(() => {
1364
+ setWidthConstraints(charSpan, newDigitWidth);
1365
+ });
1366
+ }
1367
+ repositionBarrelWheel(existingWheel, charSpan, parentContainer);
1368
+ charSpan.style.color = "transparent";
1369
+ return;
1370
+ }
1371
+ }
1372
+ // `fromZero` wheels are for digits being added by a value-
1373
+ // prop replacement (no aligned old digit). They start at
1374
+ // width 0 (the slot grows in), the wheel digit rolls from
1375
+ // "0" to the new digit, and the wheel's opacity fades from
1376
+ // 0 → 1. For every other wheel we keep the existing
1377
+ // old-digit → new-digit width animation.
1378
+ const isFromZero = barrelWheelData.fromZero === true;
1379
+ const oldDigitWidth = isFromZero
1380
+ ? 0
1381
+ : oldDigitStr
1382
+ ? measureText(oldDigitStr, charSpan)
1383
+ : 0;
1384
+ const newDigitWidth = newDigitStr
1385
+ ? measureText(newDigitStr, charSpan)
1386
+ : 0;
1387
+ const shouldAnimateWidth = newDigitWidth > 0 && (oldDigitWidth > 0 || isFromZero);
1388
+ const wheel = document.createElement("span");
1389
+ wheel.dataset.barrelWheel = "";
1390
+ wheel.setAttribute("data-direction", direction);
1391
+ wheel.setAttribute("data-final-digit", finalDigit.toString());
1392
+ wheel.setAttribute("data-char-index", index.toString());
1393
+ if (isFromZero) {
1394
+ wheel.dataset.fromZero = "";
1395
+ }
1396
+ const wrapper = document.createElement("div");
1397
+ wrapper.dataset.barrelWheelDigitsWrapper = "";
1398
+ wrapper.style.position = "relative";
1399
+ // Create digits 0-9
1400
+ for (let digit = 0; digit <= 9; digit++) {
1401
+ const digitStr = digit.toString();
1402
+ const digitElement = document.createElement("div");
1403
+ digitElement.dataset.barrelDigit = "";
1404
+ digitElement.setAttribute("data-digit", digitStr);
1405
+ digitElement.textContent = digitStr;
1406
+ digitElement.style.position = "relative";
1407
+ digitElement.style.height = "1em";
1408
+ digitElement.style.lineHeight = "1em";
1409
+ wrapper.appendChild(digitElement);
1410
+ }
1411
+ charSpan.style.color = "transparent";
1412
+ wheel.appendChild(wrapper);
1413
+ parentContainer.appendChild(wheel);
1414
+ // Set initial width synchronously to prevent flash. For
1415
+ // `fromZero` this also pins the slot at 0 before the next
1416
+ // frame's animation target.
1417
+ if (shouldAnimateWidth) {
1418
+ setWidthConstraints(charSpan, oldDigitWidth);
1419
+ void charSpan.offsetWidth;
1420
+ charSpan.setAttribute("data-width-animate", "");
1421
+ void charSpan.offsetWidth;
1422
+ }
1423
+ if (isFromZero) {
1424
+ // Fade the wheel in alongside the digit roll + width
1425
+ // animation. Use a CSS transition so jsdom (which has no
1426
+ // Web Animations API) can still drive it via
1427
+ // `fireEvent.transitionEnd`. Duration/easing match the
1428
+ // barrel-wheel transition so all three land together.
1429
+ wheel.style.opacity = "0";
1430
+ wheel.style.transition =
1431
+ "opacity 0.4s cubic-bezier(.215, .61, .355, 1)";
1432
+ void wheel.offsetWidth;
1433
+ requestAnimationFrame(() => {
1434
+ wheel.style.opacity = "1";
1435
+ });
1436
+ }
1437
+ // Attach the wheel's transitionend cleanup synchronously so
1438
+ // tests (and rapid back-to-back replacements) that fire
1439
+ // `transitionend` before the deeply-nested rAF chain that
1440
+ // starts the digit-roll has run can still drive the cleanup
1441
+ // path that un-hides the underlying char span.
1442
+ const handleWheelTransitionEnd = () => {
1443
+ const currentIndexStr = wheel.getAttribute("data-char-index");
1444
+ const currentIndex = currentIndexStr !== null
1445
+ ? parseInt(currentIndexStr, 10)
1446
+ : index;
1447
+ const finalDigitAttr = wheel.getAttribute("data-final-digit");
1448
+ const resolvedFinalDigit = finalDigitAttr !== null ? finalDigitAttr : newDigitStr;
1449
+ const observer = resizeObserversRef.current.get(currentIndex) ||
1450
+ resizeObserversRef.current.get(index);
1451
+ if (observer) {
1452
+ observer.disconnect();
1453
+ resizeObserversRef.current.delete(currentIndex);
1454
+ resizeObserversRef.current.delete(index);
1455
+ }
1456
+ let targetSpan = null;
1457
+ if (spanRef.current) {
1458
+ const spanAtCurrentIndex = spanRef.current.querySelector(`[data-char-index="${currentIndex}"]`);
1459
+ if (spanAtCurrentIndex) {
1460
+ targetSpan = spanAtCurrentIndex;
1461
+ }
1462
+ }
1463
+ if (!targetSpan &&
1464
+ charSpan instanceof HTMLElement &&
1465
+ charSpan.textContent === resolvedFinalDigit) {
1466
+ const spanIndex = charSpan.getAttribute("data-char-index");
1467
+ if (spanIndex !== currentIndex.toString()) {
1468
+ charSpan.setAttribute("data-char-index", currentIndex.toString());
1469
+ }
1470
+ targetSpan = charSpan;
1471
+ }
1472
+ if (targetSpan instanceof HTMLElement) {
1473
+ cleanupWidthAnimation(targetSpan);
1474
+ removeTransparentColor(targetSpan);
1475
+ targetSpan.removeAttribute("data-flow");
1476
+ targetSpan.style.transition = "none";
1477
+ }
1478
+ if (!targetSpan && spanRef.current) {
1479
+ const spanAtCurrentIndex = spanRef.current.querySelector(`[data-char-index="${currentIndex}"]`);
1480
+ if (spanAtCurrentIndex && isTransparent(spanAtCurrentIndex)) {
1481
+ removeTransparentColor(spanAtCurrentIndex);
1482
+ spanAtCurrentIndex.removeAttribute("data-flow");
1483
+ spanAtCurrentIndex.style.transition = "none";
1484
+ cleanupWidthAnimation(spanAtCurrentIndex);
1485
+ }
1486
+ }
1487
+ wheel.remove();
1488
+ requestAnimationFrame(() => {
1489
+ if (!spanRef.current) {
1490
+ return;
1491
+ }
1492
+ const spanAtCurrentIndex = spanRef.current.querySelector(`[data-char-index="${currentIndex}"]`);
1493
+ if (spanAtCurrentIndex && isTransparent(spanAtCurrentIndex)) {
1494
+ const parent = spanRef.current.parentElement;
1495
+ const hasBarrelWheel = parent && getBarrelWheel(parent, currentIndex);
1496
+ if (!hasBarrelWheel) {
1497
+ removeTransparentColor(spanAtCurrentIndex);
1498
+ }
1499
+ }
1500
+ });
1501
+ };
1502
+ wrapper.addEventListener("transitionend", handleWheelTransitionEnd, { once: true });
1503
+ wheel.style.position = "absolute";
1504
+ wheel.style.display = "flex";
1505
+ repositionBarrelWheel(wheel, charSpan, parentContainer);
1506
+ // Continuously align the wheel with its underlying span for
1507
+ // the whole lifetime of the wheel. Surrounding spans may grow
1508
+ // (data-flow), shrink, or shift (positionChanges) during the
1509
+ // animation — none of which trigger a resize on the wheel's
1510
+ // own charSpan, so a ResizeObserver alone is not enough. The
1511
+ // loop self-terminates as soon as the wheel is removed from
1512
+ // the DOM (transitionend handler calls `wheel.remove()`).
1513
+ const trackWheelPosition = () => {
1514
+ if (!wheel.isConnected) {
1515
+ return;
1516
+ }
1517
+ if (!charSpan.isConnected || !spanRef.current?.parentElement) {
1518
+ requestAnimationFrame(trackWheelPosition);
1519
+ return;
1520
+ }
1521
+ const parent = spanRef.current.parentElement;
1522
+ const trCleanup = temporarilyRemoveAncestorsTransform(charSpan);
1523
+ const rect = charSpan.getBoundingClientRect();
1524
+ const parentRect = parent.getBoundingClientRect();
1525
+ wheel.style.left = `${rect.left - parentRect.left}px`;
1526
+ wheel.style.top = `${rect.top - parentRect.top}px`;
1527
+ wheel.style.width = `${rect.width}px`;
1528
+ wheel.style.height = `${rect.height}px`;
1529
+ trCleanup();
1530
+ requestAnimationFrame(trackWheelPosition);
1531
+ };
1532
+ requestAnimationFrame(trackWheelPosition);
1533
+ requestAnimationFrame(() => {
1534
+ // Verify width constraints are still set
1535
+ if (shouldAnimateWidth) {
1536
+ if (!charSpan.style.width || charSpan.style.width === "") {
1537
+ setWidthConstraints(charSpan, oldDigitWidth);
1538
+ void charSpan.offsetWidth;
1539
+ }
1540
+ if (!charSpan.hasAttribute("data-width-animate")) {
1541
+ charSpan.setAttribute("data-width-animate", "");
1542
+ void charSpan.offsetWidth;
1543
+ }
1544
+ }
1545
+ const initialPosition = oldDigitStr
1546
+ ? parseInt(oldDigitStr, 10)
1547
+ : 0;
1548
+ const finalPosition = newDigitStr
1549
+ ? parseInt(newDigitStr, 10)
1550
+ : 0;
1551
+ wrapper.style.setProperty("--digit-position", initialPosition.toString());
1552
+ requestAnimationFrame(() => {
1553
+ wrapper.dataset.animating = "";
1554
+ requestAnimationFrame(() => {
1555
+ wrapper.style.setProperty("--digit-position", finalPosition.toString());
1556
+ if (shouldAnimateWidth) {
1557
+ if (!charSpan.style.width ||
1558
+ charSpan.style.width === "") {
1559
+ setWidthConstraints(charSpan, oldDigitWidth);
1560
+ void charSpan.offsetWidth;
1561
+ }
1562
+ if (!charSpan.hasAttribute("data-width-animate")) {
1563
+ charSpan.setAttribute("data-width-animate", "");
1564
+ }
1565
+ if (window.getComputedStyle(charSpan).display !==
1566
+ "inline-block") {
1567
+ charSpan.style.display = "inline-block";
1568
+ }
1569
+ void charSpan.offsetWidth;
1570
+ // Set up ResizeObserver to update barrel wheel position during width animation
1571
+ const existingObserver = resizeObserversRef.current.get(index);
1572
+ if (existingObserver) {
1573
+ existingObserver.disconnect();
1574
+ resizeObserversRef.current.delete(index);
1575
+ }
1576
+ const resizeObserver = new ResizeObserver(() => {
1577
+ if (!spanRef.current || !charSpan) {
1578
+ return;
1579
+ }
1580
+ const parent = spanRef.current.parentElement;
1581
+ if (!parent) {
1582
+ return;
1583
+ }
1584
+ const bw = getBarrelWheel(parent, index);
1585
+ if (bw) {
1586
+ const cleanup = temporarilyRemoveAncestorsTransform(charSpan);
1587
+ const rect = charSpan.getBoundingClientRect();
1588
+ const parentRect = parent.getBoundingClientRect();
1589
+ bw.style.left = `${rect.left - parentRect.left}px`;
1590
+ bw.style.width = `${rect.width}px`;
1591
+ cleanup();
1592
+ }
1593
+ });
1594
+ resizeObserver.observe(charSpan);
1595
+ resizeObserversRef.current.set(index, resizeObserver);
1596
+ requestAnimationFrame(() => {
1597
+ void charSpan.offsetWidth;
1598
+ const computedStyle = window.getComputedStyle(charSpan);
1599
+ const transition = computedStyle.transition;
1600
+ if (!transition ||
1601
+ transition === "none" ||
1602
+ transition === "all 0s ease 0s") {
1603
+ // Match the data-width-animate CSS rule and the
1604
+ // barrel wheel's digit-roll transition so the
1605
+ // underlying char animates in lockstep with the
1606
+ // wheel (same duration + easing).
1607
+ charSpan.style.transition =
1608
+ "width 0.4s cubic-bezier(.215, .61, .355, 1), min-width 0.4s cubic-bezier(.215, .61, .355, 1), max-width 0.4s cubic-bezier(.215, .61, .355, 1)";
1609
+ void charSpan.offsetWidth;
1610
+ }
1611
+ setWidthConstraints(charSpan, newDigitWidth);
1612
+ const handleWidthAnimationEnd = (e) => {
1613
+ if (["width", "min-width", "max-width"].includes(e.propertyName)) {
1614
+ cleanupWidthAnimation(charSpan);
1615
+ charSpan.removeEventListener("transitionend", handleWidthAnimationEnd);
1616
+ const observer = resizeObserversRef.current.get(index);
1617
+ if (observer) {
1618
+ observer.disconnect();
1619
+ resizeObserversRef.current.delete(index);
1620
+ }
1621
+ }
1622
+ };
1623
+ charSpan.addEventListener("transitionend", handleWidthAnimationEnd);
1624
+ });
1625
+ }
1626
+ });
1627
+ });
1628
+ });
1629
+ });
1630
+ // Apply x-position animations for characters that moved
1631
+ // (separators and digits that crossed group boundaries)
1632
+ if (positionChanges.length > 0 && spanRef.current) {
1633
+ const containerRect = spanRef.current.getBoundingClientRect();
1634
+ positionChanges.forEach((change) => {
1635
+ const span = spanRef.current?.querySelector(`[data-char-index="${change.newIndex}"]`);
1636
+ if (span && span.textContent === change.char) {
1637
+ // Look up old position using the character and its old index
1638
+ const oldKey = `${change.char}@${change.oldIndex}`;
1639
+ const oldPos = oldPositions.get(oldKey);
1640
+ if (oldPos) {
1641
+ const newRect = span.getBoundingClientRect();
1642
+ const newX = newRect.left - containerRect.left;
1643
+ const offsetX = oldPos.x - newX;
1644
+ // Only animate if there's a significant position change
1645
+ if (Math.abs(offsetX) > 1) {
1646
+ // Use Web Animations API for smooth x-position animation
1647
+ span.animate([
1648
+ { transform: `translateX(${offsetX}px)` },
1649
+ { transform: "translateX(0)" },
1650
+ ], {
1651
+ duration: 250,
1652
+ easing: "cubic-bezier(0.33, 1, 0.68, 1)", // ease-out-cubic
1653
+ fill: "forwards",
1654
+ });
1655
+ }
1656
+ }
1657
+ }
1658
+ });
1659
+ }
1660
+ cleanup();
1661
+ });
1662
+ if (!skipCursor) {
1663
+ const setCursor = () => {
1664
+ if (!spanRef.current) {
1665
+ return;
1666
+ }
1667
+ // Map raw cursor position to formatted position
1668
+ const formattedCursorPos = mapRawToFormattedIndex(cleanedText, newFormattedText, Math.min(newCursorPos, cleanedText.length));
1669
+ setCursorPositionInElement(spanRef.current, formattedCursorPos);
1670
+ };
1671
+ setCursor();
1672
+ requestAnimationFrame(setCursor);
1673
+ }
1674
+ }
1675
+ }, [
1676
+ isAllowed,
1677
+ displayValue,
1678
+ onChange,
1679
+ autoAddLeadingZero,
1680
+ repositionAllBarrelWheels,
1681
+ addToHistory,
1682
+ formatRawValue,
1683
+ mapRawToFormattedIndex,
1684
+ mapFormattedToRawIndex,
1685
+ separators,
1686
+ ]);
1687
+ // Initialize
1688
+ useEffect(() => {
1689
+ if (spanRef.current && formattedDisplayValue) {
1690
+ spanRef.current.textContent = formattedDisplayValue;
1691
+ }
1692
+ // Initialize history with initial state
1693
+ if (historyRef.current.length === 0) {
1694
+ const initialValue = actualValue;
1695
+ historyRef.current.push({
1696
+ text: displayValue,
1697
+ cursorPosBefore: 0,
1698
+ cursorPosAfter: 0,
1699
+ value: initialValue,
1700
+ });
1701
+ historyIndexRef.current = 0;
1702
+ }
1703
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1704
+ }, []);
1705
+ // Animate the diff when the `value` prop changes externally (i.e. the
1706
+ // parent updated `value` outside of our own onChange flow).
1707
+ //
1708
+ // This is naturally a no-op on the initial mount: `displayValue` is
1709
+ // seeded from `actualValue` in `useState`, so the two are in sync and
1710
+ // the guard below short-circuits until something actually changes.
1711
+ useEffect(() => {
1712
+ const newRawDisplay = actualValue?.toString() ?? "";
1713
+ const currentParsed = ["", "-", ".", "-."].includes(displayValue)
1714
+ ? undefined
1715
+ : parseFloat(displayValue);
1716
+ if (currentParsed === actualValue || !spanRef.current) {
1717
+ return;
1718
+ }
1719
+ updateValue(newRawDisplay, newRawDisplay.length, 0, displayValue.length, {
1720
+ skipHistory: true,
1721
+ skipOnChange: true,
1722
+ skipCursor: true,
1723
+ asReplacement: true,
1724
+ });
1725
+ }, [actualValue, displayValue, updateValue]);
1726
+ // Handle format or locale prop changes - animate the transition
1727
+ useEffect(() => {
1728
+ if (!spanRef.current) {
1729
+ return;
1730
+ }
1731
+ const oldFormattedText = prevFormattedValueRef.current;
1732
+ const newFormattedText = formattedDisplayValue;
1733
+ // Skip if no actual change
1734
+ if (oldFormattedText === newFormattedText) {
1735
+ return;
1736
+ }
1737
+ const parentContainer = spanRef.current.parentElement;
1738
+ // Collect existing barrel wheels and their associated spans BEFORE any DOM changes
1739
+ // We'll update their indices and reposition them after the DOM is rebuilt
1740
+ const existingBarrelWheels = [];
1741
+ if (parentContainer) {
1742
+ const cleanup = temporarilyRemoveAncestorsTransform(parentContainer);
1743
+ const parentRect = parentContainer.getBoundingClientRect();
1744
+ const barrelWheels = getAllBarrelWheels(parentContainer);
1745
+ barrelWheels.forEach((wheel) => {
1746
+ const oldIndexStr = wheel.getAttribute("data-char-index");
1747
+ const finalDigit = wheel.getAttribute("data-final-digit") ?? "";
1748
+ if (oldIndexStr !== null) {
1749
+ const oldIndex = parseInt(oldIndexStr, 10);
1750
+ // Find the associated transparent span
1751
+ const span = spanRef.current?.querySelector(`[data-char-index="${oldIndex}"]`);
1752
+ // Capture old x position relative to parent
1753
+ const wheelRect = wheel.getBoundingClientRect();
1754
+ const oldX = wheelRect.left - parentRect.left;
1755
+ existingBarrelWheels.push({
1756
+ wheel,
1757
+ oldIndex,
1758
+ finalDigit,
1759
+ span,
1760
+ oldX,
1761
+ });
1762
+ }
1763
+ });
1764
+ cleanup();
1765
+ }
1766
+ // Don't clear ResizeObservers - they'll be updated with new indices
1767
+ // Capture old positions BEFORE updating DOM
1768
+ const oldPositions = new Map();
1769
+ const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current);
1770
+ const containerRect = spanRef.current.getBoundingClientRect();
1771
+ const existingSpans = spanRef.current.querySelectorAll("[data-char-index]");
1772
+ existingSpans.forEach((span) => {
1773
+ const el = span;
1774
+ const index = parseInt(el.getAttribute("data-char-index") ?? "-1", 10);
1775
+ const char = el.textContent ?? "";
1776
+ if (index >= 0 && char) {
1777
+ const rect = el.getBoundingClientRect();
1778
+ oldPositions.set(`${char}@${index}`, {
1779
+ x: rect.left - containerRect.left,
1780
+ width: rect.width,
1781
+ });
1782
+ }
1783
+ });
1784
+ cleanup();
1785
+ // Build maps of character positions for matching old -> new
1786
+ const oldDigitPositions = new Map();
1787
+ const oldSeparatorPositions = new Map();
1788
+ const { decimal: localeDecimal } = separators;
1789
+ // Helper to check if a char is "raw" (digit, decimal, or minus) for matching purposes
1790
+ // This is different from the component's isRawChar because we need to consider
1791
+ // both the old and new decimal separators
1792
+ const isRawCharForMatching = (char, _text, textDecimal) => {
1793
+ if (!char) {
1794
+ return false;
1795
+ }
1796
+ if (/[\d-]/.test(char)) {
1797
+ return true;
1798
+ }
1799
+ if (char === ".") {
1800
+ return true;
1801
+ }
1802
+ if (char === localeDecimal) {
1803
+ return true;
1804
+ }
1805
+ // If the text uses this char as its decimal
1806
+ if (textDecimal && char === textDecimal) {
1807
+ return true;
1808
+ }
1809
+ return false;
1810
+ };
1811
+ // Detect which decimal is used in each text
1812
+ const detectDecimal = (text) => {
1813
+ // Count occurrences of possible decimals
1814
+ let dotCount = 0;
1815
+ let commaCount = 0;
1816
+ for (const char of text) {
1817
+ if (char === ".") {
1818
+ dotCount++;
1819
+ }
1820
+ if (char === ",") {
1821
+ commaCount++;
1822
+ }
1823
+ }
1824
+ // A decimal separator appears at most once
1825
+ // Group separators appear multiple times
1826
+ if (dotCount === 1 && commaCount > 1) {
1827
+ return ".";
1828
+ } // e.g., "1,234.56"
1829
+ if (commaCount === 1 && dotCount > 1) {
1830
+ return ",";
1831
+ } // e.g., "1.234,56" (German)
1832
+ if (dotCount === 1 && commaCount === 0) {
1833
+ return ".";
1834
+ } // e.g., "1.56"
1835
+ if (commaCount === 1 && dotCount === 0) {
1836
+ return ",";
1837
+ } // e.g., "1,56"
1838
+ if (dotCount === 0 && commaCount === 0) {
1839
+ return null;
1840
+ } // no decimal
1841
+ // If both appear once, assume the last one is decimal
1842
+ const lastDot = text.lastIndexOf(".");
1843
+ const lastComma = text.lastIndexOf(",");
1844
+ if (lastDot > lastComma) {
1845
+ return ".";
1846
+ }
1847
+ if (lastComma > lastDot) {
1848
+ return ",";
1849
+ }
1850
+ return null;
1851
+ };
1852
+ const oldTextDecimal = detectDecimal(oldFormattedText);
1853
+ const newTextDecimal = detectDecimal(newFormattedText);
1854
+ // Helper to normalize decimal separators for matching
1855
+ // Both "." and "," (when used as decimal) should be treated as the same character
1856
+ const normalizeForMatching = (char, textDecimal) => {
1857
+ // If this char is the decimal for its text, normalize to "DECIMAL"
1858
+ if (char === "." || char === textDecimal) {
1859
+ // Check if it's actually a decimal (not a group separator)
1860
+ if (char === "." && textDecimal === ".") {
1861
+ return "DECIMAL";
1862
+ }
1863
+ if (char === "," && textDecimal === ",") {
1864
+ return "DECIMAL";
1865
+ }
1866
+ if (char === "." && textDecimal === null) {
1867
+ return "DECIMAL";
1868
+ } // assume it's decimal
1869
+ if (char === localeDecimal) {
1870
+ return "DECIMAL";
1871
+ }
1872
+ }
1873
+ return char;
1874
+ };
1875
+ for (let i = 0; i < oldFormattedText.length; i++) {
1876
+ const char = oldFormattedText[i] ?? "";
1877
+ if (!char) {
1878
+ continue;
1879
+ }
1880
+ const isRaw = isRawCharForMatching(char, oldFormattedText, oldTextDecimal);
1881
+ const map = isRaw ? oldDigitPositions : oldSeparatorPositions;
1882
+ const normalizedChar = normalizeForMatching(char, oldTextDecimal);
1883
+ if (!map.has(normalizedChar)) {
1884
+ map.set(normalizedChar, []);
1885
+ }
1886
+ map.get(normalizedChar).push(i);
1887
+ }
1888
+ // Track which old positions have been matched
1889
+ const usedOldPositions = new Set();
1890
+ // First pass: match old characters to new characters
1891
+ const oldToNewMapping = new Map(); // oldIndex -> newIndex
1892
+ const newToOldMapping = new Map(); // newIndex -> oldIndex
1893
+ for (let newIdx = 0; newIdx < newFormattedText.length; newIdx++) {
1894
+ const char = newFormattedText[newIdx] ?? "";
1895
+ if (!char) {
1896
+ continue;
1897
+ }
1898
+ const isRaw = isRawCharForMatching(char, newFormattedText, newTextDecimal);
1899
+ const posMap = isRaw ? oldDigitPositions : oldSeparatorPositions;
1900
+ const normalizedChar = normalizeForMatching(char, newTextDecimal);
1901
+ const oldIndices = posMap.get(normalizedChar) ?? [];
1902
+ for (const oldIdx of oldIndices) {
1903
+ if (!usedOldPositions.has(oldIdx)) {
1904
+ usedOldPositions.add(oldIdx);
1905
+ oldToNewMapping.set(oldIdx, newIdx);
1906
+ newToOldMapping.set(newIdx, oldIdx);
1907
+ break;
1908
+ }
1909
+ }
1910
+ }
1911
+ // Find separators that need to be removed (in old but not matched)
1912
+ const separatorsToRemove = [];
1913
+ for (let i = 0; i < oldFormattedText.length; i++) {
1914
+ const char = oldFormattedText[i] ?? "";
1915
+ if (!char) {
1916
+ continue;
1917
+ }
1918
+ const isRaw = isRawCharForMatching(char, oldFormattedText, oldTextDecimal);
1919
+ if (!isRaw && !usedOldPositions.has(i)) {
1920
+ separatorsToRemove.push({ char, oldIndex: i });
1921
+ }
1922
+ }
1923
+ const mergedItems = [];
1924
+ // Add new characters
1925
+ for (let i = 0; i < newFormattedText.length; i++) {
1926
+ const char = newFormattedText[i] ?? "";
1927
+ if (!char) {
1928
+ continue;
1929
+ }
1930
+ const isRaw = isRawCharForMatching(char, newFormattedText, newTextDecimal);
1931
+ const isNewSeparator = !isRaw && !newToOldMapping.has(i);
1932
+ mergedItems.push({
1933
+ type: "new",
1934
+ char,
1935
+ newIndex: i,
1936
+ isNewSeparator: isNewSeparator && oldFormattedText.length > 0,
1937
+ });
1938
+ }
1939
+ // Insert removing separators at their visual positions
1940
+ // We need to figure out where they should go based on surrounding characters
1941
+ separatorsToRemove.forEach(({ char, oldIndex }) => {
1942
+ // Find the position in the merged array where this separator should go
1943
+ // It should be after any new characters that come from old positions before it
1944
+ // and before any new characters that come from old positions after it
1945
+ let insertPosition = 0;
1946
+ for (let i = 0; i < mergedItems.length; i++) {
1947
+ const item = mergedItems[i];
1948
+ if (item?.type === "new") {
1949
+ const oldIdx = newToOldMapping.get(item.newIndex);
1950
+ if (oldIdx !== undefined && oldIdx < oldIndex) {
1951
+ insertPosition = i + 1;
1952
+ }
1953
+ }
1954
+ }
1955
+ mergedItems.splice(insertPosition, 0, {
1956
+ type: "removing",
1957
+ char,
1958
+ oldIndex,
1959
+ });
1960
+ });
1961
+ // Update barrel wheel indices using the mapping BEFORE clearing the container
1962
+ const barrelWheelNewIndices = new Map();
1963
+ const barrelWheelSpansToPreserve = new Set();
1964
+ existingBarrelWheels.forEach(({ wheel, oldIndex, span }) => {
1965
+ const newIndex = oldToNewMapping.get(oldIndex);
1966
+ if (newIndex !== undefined) {
1967
+ // Update the barrel wheel's index
1968
+ wheel.setAttribute("data-char-index", newIndex.toString());
1969
+ barrelWheelNewIndices.set(wheel, newIndex);
1970
+ // Update ResizeObserver mapping
1971
+ const observer = resizeObserversRef.current.get(oldIndex);
1972
+ if (observer) {
1973
+ resizeObserversRef.current.delete(oldIndex);
1974
+ resizeObserversRef.current.set(newIndex, observer);
1975
+ }
1976
+ // Mark span to preserve
1977
+ if (span) {
1978
+ barrelWheelSpansToPreserve.add(span);
1979
+ }
1980
+ }
1981
+ else {
1982
+ // Barrel wheel's character was removed - clean up
1983
+ const observer = resizeObserversRef.current.get(oldIndex);
1984
+ if (observer) {
1985
+ observer.disconnect();
1986
+ resizeObserversRef.current.delete(oldIndex);
1987
+ }
1988
+ wheel.remove();
1989
+ }
1990
+ });
1991
+ // Clear the container but preserve barrel wheel spans (we'll update them)
1992
+ const allChildren = Array.from(spanRef.current.childNodes);
1993
+ allChildren.forEach((child) => {
1994
+ if (child instanceof HTMLElement &&
1995
+ barrelWheelSpansToPreserve.has(child)) {
1996
+ // Keep this span - it's associated with an active barrel wheel
1997
+ // But temporarily remove it so we can reinsert at correct position
1998
+ child.remove();
1999
+ }
2000
+ else {
2001
+ // Remove this span
2002
+ if (child.parentNode) {
2003
+ child.parentNode.removeChild(child);
2004
+ }
2005
+ }
2006
+ });
2007
+ // Create spans based on merged sequence
2008
+ const newSpans = [];
2009
+ const addedSeparatorSpans = [];
2010
+ const removingSpans = [];
2011
+ const cleanup2 = temporarilyRemoveAncestorsTransform(spanRef.current);
2012
+ mergedItems.forEach((item) => {
2013
+ if (!item) {
2014
+ return;
2015
+ }
2016
+ if (item.type === "new") {
2017
+ // Check if there's a barrel wheel span that should be at this index
2018
+ let span = null;
2019
+ for (const { wheel, span: bwSpan, finalDigit, } of existingBarrelWheels) {
2020
+ const newIndex = barrelWheelNewIndices.get(wheel);
2021
+ if (newIndex === item.newIndex && bwSpan) {
2022
+ // Reuse the barrel wheel span
2023
+ span = bwSpan;
2024
+ span.setAttribute("data-char-index", item.newIndex.toString());
2025
+ // Update text content to match the new character (decimal separator might have changed)
2026
+ if (span.textContent !== item.char && item.char !== finalDigit) {
2027
+ // Only update if not the final digit (barrel wheel is still animating)
2028
+ span.textContent = item.char;
2029
+ }
2030
+ break;
2031
+ }
2032
+ }
2033
+ if (!span) {
2034
+ // Create new span
2035
+ span = document.createElement("span");
2036
+ span.textContent = item.char;
2037
+ span.setAttribute("data-char-index", item.newIndex.toString());
2038
+ }
2039
+ if (item.isNewSeparator) {
2040
+ // New separator - animate in with width from 0 and slide up
2041
+ span.setAttribute("data-flow", "");
2042
+ spanRef.current.appendChild(span);
2043
+ const finalWidth = span.getBoundingClientRect().width;
2044
+ span.style.width = "0px";
2045
+ span.style.minWidth = "0px";
2046
+ span.style.maxWidth = "0px";
2047
+ addedSeparatorSpans.push({ span, finalWidth });
2048
+ }
2049
+ else {
2050
+ // Existing character or digit - show immediately (unless it's a barrel wheel span)
2051
+ if (!barrelWheelSpansToPreserve.has(span)) {
2052
+ span.setAttribute("data-flow", "");
2053
+ span.setAttribute("data-show", "");
2054
+ }
2055
+ spanRef.current.appendChild(span);
2056
+ }
2057
+ newSpans.push(span);
2058
+ }
2059
+ else {
2060
+ // Removing separator - keep in flow, will animate out
2061
+ const span = document.createElement("span");
2062
+ span.textContent = item.char;
2063
+ const oldKey = `${item.char}@${item.oldIndex}`;
2064
+ const oldPos = oldPositions.get(oldKey);
2065
+ span.setAttribute("data-flow", "");
2066
+ span.setAttribute("data-show", "");
2067
+ span.setAttribute("data-removing", "");
2068
+ span.style.overflow = "visible";
2069
+ span.style.display = "inline-block"; // Required for width animation on inline elements
2070
+ if (oldPos) {
2071
+ span.style.width = `${oldPos.width}px`;
2072
+ span.style.minWidth = `${oldPos.width}px`;
2073
+ span.style.maxWidth = `${oldPos.width}px`;
2074
+ }
2075
+ spanRef.current.appendChild(span);
2076
+ removingSpans.push(span);
2077
+ }
2078
+ });
2079
+ cleanup2();
2080
+ // Force reflow
2081
+ void spanRef.current.offsetWidth;
2082
+ const cleanup3 = temporarilyRemoveAncestorsTransform(spanRef.current);
2083
+ // Get new container rect for position calculations
2084
+ const newContainerRect = spanRef.current.getBoundingClientRect();
2085
+ // Apply x-position animations for digits that moved
2086
+ newSpans.forEach((span) => {
2087
+ const char = span.textContent ?? "";
2088
+ if (!char) {
2089
+ return;
2090
+ }
2091
+ const isSeparator = !isRawChar(char);
2092
+ if (isSeparator) {
2093
+ return;
2094
+ } // Don't animate x for separators, they use width animation
2095
+ // Find the old position for this character using the mapping
2096
+ const newIndex = parseInt(span.getAttribute("data-char-index") ?? "-1", 10);
2097
+ const oldIndex = newToOldMapping.get(newIndex);
2098
+ if (oldIndex !== undefined) {
2099
+ const oldKey = `${char}@${oldIndex}`;
2100
+ const oldPos = oldPositions.get(oldKey);
2101
+ if (oldPos) {
2102
+ const newRect = span.getBoundingClientRect();
2103
+ const newX = newRect.left - newContainerRect.left;
2104
+ const offsetX = oldPos.x - newX;
2105
+ if (Math.abs(offsetX) > 1) {
2106
+ span.animate([
2107
+ { transform: `translateX(${offsetX}px)` },
2108
+ { transform: "translateX(0)" },
2109
+ ], {
2110
+ duration: 400,
2111
+ easing: "cubic-bezier(.215, .61, .355, 1)",
2112
+ fill: "forwards",
2113
+ });
2114
+ }
2115
+ }
2116
+ }
2117
+ });
2118
+ cleanup3();
2119
+ // Trigger animations in next frame
2120
+ requestAnimationFrame(() => {
2121
+ // Animate in new separators (width from 0 to final + slide up)
2122
+ addedSeparatorSpans.forEach(({ span, finalWidth }) => {
2123
+ span.setAttribute("data-show", "");
2124
+ span.style.width = `${finalWidth}px`;
2125
+ span.style.minWidth = `${finalWidth}px`;
2126
+ span.style.maxWidth = `${finalWidth}px`;
2127
+ // Clean up inline styles after transition
2128
+ const handleTransitionEnd = (e) => {
2129
+ if (e.propertyName === "width") {
2130
+ span.style.width = "";
2131
+ span.style.minWidth = "";
2132
+ span.style.maxWidth = "";
2133
+ span.style.overflow = "";
2134
+ span.style.display = "";
2135
+ span.removeEventListener("transitionend", handleTransitionEnd);
2136
+ }
2137
+ };
2138
+ span.addEventListener("transitionend", handleTransitionEnd);
2139
+ });
2140
+ // Clean up any digits that might have width styles (from previous animations)
2141
+ newSpans.forEach((span) => {
2142
+ const isSeparator = !isRawChar(span.textContent ?? "");
2143
+ if (!isSeparator && span.style.width) {
2144
+ const handleTransitionEnd = (e) => {
2145
+ if (e.propertyName === "width") {
2146
+ span.style.width = "";
2147
+ span.style.minWidth = "";
2148
+ span.style.maxWidth = "";
2149
+ span.style.display = "";
2150
+ span.removeEventListener("transitionend", handleTransitionEnd);
2151
+ }
2152
+ };
2153
+ span.addEventListener("transitionend", handleTransitionEnd);
2154
+ }
2155
+ });
2156
+ // Animate out removed separators (width to 0 + slide down)
2157
+ removingSpans.forEach((span) => {
2158
+ span.removeAttribute("data-show");
2159
+ span.setAttribute("data-hide", "");
2160
+ span.style.width = "0px";
2161
+ span.style.minWidth = "0px";
2162
+ span.style.maxWidth = "0px";
2163
+ // Remove after animation completes
2164
+ const handleTransitionEnd = (e) => {
2165
+ if (e.propertyName === "translate" || e.propertyName === "width") {
2166
+ span.removeEventListener("transitionend", handleTransitionEnd);
2167
+ // Only remove when both animations are done
2168
+ if (span.style.width === "0px" &&
2169
+ span.getAttribute("data-hide") !== null) {
2170
+ span.remove();
2171
+ }
2172
+ }
2173
+ };
2174
+ span.addEventListener("transitionend", handleTransitionEnd);
2175
+ });
2176
+ // Reposition existing barrel wheels after DOM changes with x-position animation
2177
+ // We need to calculate the FINAL x position (after all width animations complete)
2178
+ if (parentContainer && existingBarrelWheels.length > 0) {
2179
+ // Create a set of removing span indices for quick lookup
2180
+ const removingSpanSet = new Set(removingSpans.map((s) => s));
2181
+ // Create a map of new separator spans to their final widths
2182
+ const separatorFinalWidths = new Map();
2183
+ addedSeparatorSpans.forEach(({ span, finalWidth }) => {
2184
+ separatorFinalWidths.set(span, finalWidth);
2185
+ });
2186
+ existingBarrelWheels.forEach(({ wheel, oldX }) => {
2187
+ const newIndex = barrelWheelNewIndices.get(wheel);
2188
+ if (newIndex === undefined) {
2189
+ return; // Was removed
2190
+ }
2191
+ // Find the span at the new index
2192
+ const span = spanRef.current?.querySelector(`[data-char-index="${newIndex}"]`);
2193
+ if (span && spanRef.current) {
2194
+ const cleanup = temporarilyRemoveAncestorsTransform(spanRef.current);
2195
+ const parentRect = parentContainer.getBoundingClientRect();
2196
+ const containerRect = spanRef.current.getBoundingClientRect();
2197
+ // Calculate final x position by summing final widths of all elements before this span
2198
+ let finalX = containerRect.left - parentRect.left;
2199
+ let foundSpan = false;
2200
+ // Iterate through all children in order
2201
+ for (const child of Array.from(spanRef.current.children)) {
2202
+ if (child === span) {
2203
+ foundSpan = true;
2204
+ break;
2205
+ }
2206
+ const childEl = child;
2207
+ // Determine the final width of this element
2208
+ let elementFinalWidth;
2209
+ if (removingSpanSet.has(childEl)) {
2210
+ // Removing separator - final width is 0
2211
+ elementFinalWidth = 0;
2212
+ }
2213
+ else if (separatorFinalWidths.has(childEl)) {
2214
+ // New separator - use the final width (after animation)
2215
+ elementFinalWidth = separatorFinalWidths.get(childEl);
2216
+ }
2217
+ else {
2218
+ // Regular span - use measureText to get natural width
2219
+ const text = childEl.textContent ?? "";
2220
+ if (text) {
2221
+ elementFinalWidth = measureText(text, childEl);
2222
+ }
2223
+ else {
2224
+ elementFinalWidth = childEl.getBoundingClientRect().width;
2225
+ }
2226
+ }
2227
+ finalX += elementFinalWidth;
2228
+ }
2229
+ if (!foundSpan) {
2230
+ // Fallback to current position
2231
+ const spanRect = span.getBoundingClientRect();
2232
+ finalX = spanRect.left - parentRect.left;
2233
+ }
2234
+ // Get span dimensions
2235
+ const spanRect = span.getBoundingClientRect();
2236
+ const spanWidth = measureText(span.textContent ?? "", span);
2237
+ // Calculate x offset for animation (from old position to final position)
2238
+ const offsetX = oldX - finalX;
2239
+ cleanup();
2240
+ // Set final position immediately
2241
+ wheel.style.left = `${finalX}px`;
2242
+ wheel.style.top = `${spanRect.top - parentRect.top}px`;
2243
+ wheel.style.width = `${spanWidth}px`;
2244
+ wheel.style.height = `${spanRect.height}px`;
2245
+ // Animate x position if there's a significant change.
2246
+ // Match the digit-roll + data-width-animate timing (0.4s
2247
+ // ease-out-cubic) so the wheel reaches its final position at
2248
+ // the same moment the underlying span settles, avoiding any
2249
+ // visual jump when the wheel is removed.
2250
+ if (Math.abs(offsetX) > 1) {
2251
+ wheel.animate([
2252
+ { transform: `translateX(${offsetX}px)` },
2253
+ { transform: "translateX(0)" },
2254
+ ], {
2255
+ duration: 400,
2256
+ easing: "cubic-bezier(.215, .61, .355, 1)",
2257
+ fill: "forwards",
2258
+ });
2259
+ }
2260
+ // Ensure the span is transparent (barrel wheel is visible)
2261
+ span.style.color = "transparent";
2262
+ }
2263
+ });
2264
+ }
2265
+ });
2266
+ // Update the ref to match current formatted value
2267
+ prevFormattedValueRef.current = formattedDisplayValue;
2268
+ // Only run when format or locale changes, not on initial mount
2269
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2270
+ }, [format, locale, formattedDisplayValue, isRawChar]);
2271
+ // Cleanup ResizeObservers on unmount
2272
+ useEffect(() => {
2273
+ const observers = resizeObserversRef.current;
2274
+ return () => {
2275
+ observers.forEach((observer) => observer.disconnect());
2276
+ observers.clear();
2277
+ };
2278
+ }, []);
2279
+ const applyHistoryItemWithCursor = useCallback((historyItem, cursorPos) => {
2280
+ isUndoRedoRef.current = true;
2281
+ setDisplayValue(historyItem.text);
2282
+ setUncontrolledValue(historyItem.value);
2283
+ onChange?.(historyItem.value);
2284
+ setCursorPosition(cursorPos);
2285
+ if (spanRef.current) {
2286
+ clearBarrelWheelsAndSpans(spanRef.current, spanRef.current.parentElement);
2287
+ spanRef.current.textContent = historyItem.text;
2288
+ spanRef.current.focus();
2289
+ // History stores raw cursor positions (no separators). After the
2290
+ // format effect rebuilds the DOM with separators we need to land
2291
+ // the cursor at the FORMATTED equivalent — otherwise a raw index
2292
+ // like 4 in "1,234" lands between the "3" and the "4".
2293
+ const clampedRawPos = Math.min(cursorPos, historyItem.text.length);
2294
+ const formattedText = formatRawValue(historyItem.text);
2295
+ const formattedCursorPos = mapRawToFormattedIndex(historyItem.text, formattedText, clampedRawPos);
2296
+ // Run synchronously while the DOM still contains the plain raw
2297
+ // text we just wrote (no [data-char-index] spans yet) — use the
2298
+ // raw position so the cursor lands in a sane spot. The rAF call
2299
+ // runs after React's commit phase and the format effect have
2300
+ // rebuilt the spans, so we use the formatted position there.
2301
+ const restoreCursorRaw = () => {
2302
+ if (!spanRef.current) {
2303
+ return;
2304
+ }
2305
+ spanRef.current.focus();
2306
+ setCursorAtPosition(spanRef.current, clampedRawPos);
2307
+ };
2308
+ const restoreCursorFormatted = () => {
2309
+ if (!spanRef.current) {
2310
+ return;
2311
+ }
2312
+ spanRef.current.focus();
2313
+ setCursorPositionInElement(spanRef.current, formattedCursorPos);
2314
+ isUndoRedoRef.current = false;
2315
+ };
2316
+ restoreCursorRaw();
2317
+ requestAnimationFrame(restoreCursorFormatted);
2318
+ }
2319
+ }, [onChange, formatRawValue, mapRawToFormattedIndex]);
2320
+ const applyHistoryItem = useCallback((historyItem, isUndo) => {
2321
+ if (!historyItem) {
2322
+ return;
2323
+ }
2324
+ // For redo: use cursorPosAfter from the item
2325
+ const targetCursorPos = isUndo
2326
+ ? historyItem.cursorPosBefore
2327
+ : historyItem.cursorPosAfter;
2328
+ applyHistoryItemWithCursor(historyItem, targetCursorPos);
2329
+ }, [applyHistoryItemWithCursor]);
2330
+ const handleKeyDown = useCallback((event) => {
2331
+ const key = event.key;
2332
+ // Get current state (raw, unformatted)
2333
+ const currentText = displayValue;
2334
+ const currentFormattedText = formattedDisplayValue;
2335
+ const selection = window.getSelection();
2336
+ const range = selection?.getRangeAt(0);
2337
+ if (!range || !spanRef.current) {
2338
+ return;
2339
+ }
2340
+ if (!selection) {
2341
+ return;
2342
+ }
2343
+ // Get selection range in formatted positions
2344
+ const { start: formattedStart, end: formattedEnd } = getSelectionRange(spanRef.current, selection);
2345
+ // Convert to raw positions for working with displayValue
2346
+ const start = mapFormattedToRawIndex(currentText, currentFormattedText, formattedStart);
2347
+ const end = mapFormattedToRawIndex(currentText, currentFormattedText, formattedEnd);
2348
+ // Handle special keys
2349
+ if ((event.metaKey || event.ctrlKey) && key === "Backspace") {
2350
+ event.preventDefault();
2351
+ // Remove barrel wheels for all indices being deleted (from 0 to end)
2352
+ const indicesToRemove = [];
2353
+ for (let i = 0; i < end; i++) {
2354
+ indicesToRemove.push(i);
2355
+ }
2356
+ removeBarrelWheelsAtIndices(indicesToRemove);
2357
+ const newText = currentText.slice(end);
2358
+ updateValue(newText, 0, 0, end);
2359
+ return;
2360
+ }
2361
+ if (event.metaKey || event.ctrlKey) {
2362
+ // prevent rich text formatting shortcuts
2363
+ if (["b", "i", "u", "k"].includes(key.toLowerCase())) {
2364
+ event.preventDefault();
2365
+ return;
2366
+ }
2367
+ // Handle Undo (Cmd+Z / Ctrl+Z)
2368
+ if (key.toLowerCase() === "z" && !event.shiftKey) {
2369
+ event.preventDefault();
2370
+ if (historyIndexRef.current > 0) {
2371
+ // Get cursor position from current item BEFORE decrementing
2372
+ const cursorPos = historyRef.current[historyIndexRef.current]?.cursorPosBefore ??
2373
+ 0;
2374
+ historyIndexRef.current--;
2375
+ // Restore text from previous item, but use cursor position from current item
2376
+ const prevItem = historyRef.current[historyIndexRef.current];
2377
+ if (prevItem) {
2378
+ applyHistoryItemWithCursor(prevItem, cursorPos);
2379
+ }
2380
+ }
2381
+ return;
2382
+ }
2383
+ // Handle Redo (Cmd+Shift+Z / Ctrl+Y or Ctrl+Shift+Z)
2384
+ if ((key.toLowerCase() === "z" && event.shiftKey) ||
2385
+ key.toLowerCase() === "y") {
2386
+ event.preventDefault();
2387
+ if (historyIndexRef.current < historyRef.current.length - 1) {
2388
+ historyIndexRef.current++;
2389
+ // For redo, use cursorPosAfter from the item we're restoring to
2390
+ applyHistoryItem(historyRef.current[historyIndexRef.current], false);
2391
+ }
2392
+ return;
2393
+ }
2394
+ // Handle Cut (Cmd+X / Ctrl+X)
2395
+ if (key.toLowerCase() === "x") {
2396
+ event.preventDefault();
2397
+ // Copy to clipboard (browser handles this automatically, but we need to handle the deletion)
2398
+ if (start !== end) {
2399
+ const selectedText = currentText.slice(start, end);
2400
+ // Try to copy to clipboard, but don't fail if clipboard API is not available (e.g., in tests)
2401
+ if (typeof navigator !== "undefined" &&
2402
+ navigator.clipboard &&
2403
+ navigator.clipboard.writeText) {
2404
+ navigator.clipboard.writeText(selectedText).catch(() => {
2405
+ // Fallback if clipboard API fails
2406
+ });
2407
+ }
2408
+ // Delete the selected text
2409
+ const newText = currentText.slice(0, start) + currentText.slice(end);
2410
+ updateValue(newText, start, start, end);
2411
+ }
2412
+ return;
2413
+ }
2414
+ }
2415
+ // Handle Alt/Cmd+ArrowLeft/ArrowRight (move to start/end)
2416
+ if ((event.metaKey || event.ctrlKey || event.altKey) &&
2417
+ (key === "ArrowLeft" || key === "ArrowRight")) {
2418
+ event.preventDefault();
2419
+ if (!spanRef.current) {
2420
+ return;
2421
+ }
2422
+ const selection = window.getSelection();
2423
+ if (!selection) {
2424
+ return;
2425
+ }
2426
+ // Use formatted text length for target position
2427
+ const targetPos = key === "ArrowLeft" ? 0 : currentFormattedText.length;
2428
+ if (event.shiftKey) {
2429
+ // Extend selection to start/end
2430
+ // Use the selection's anchor point as the anchor (formatted position)
2431
+ let anchorPos = formattedStart;
2432
+ if (selection.anchorNode &&
2433
+ spanRef.current.contains(selection.anchorNode)) {
2434
+ const anchorRange = document.createRange();
2435
+ anchorRange.selectNodeContents(spanRef.current);
2436
+ anchorRange.setEnd(selection.anchorNode, selection.anchorOffset);
2437
+ anchorPos = anchorRange.toString().length;
2438
+ }
2439
+ // Find both anchor and target nodes/offsets
2440
+ let currentPos = 0;
2441
+ const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
2442
+ let anchorNode = null;
2443
+ let anchorOffset = 0;
2444
+ let targetNode = null;
2445
+ let targetOffset = 0;
2446
+ let node;
2447
+ while ((node = walker.nextNode())) {
2448
+ const nodeLength = node.textContent?.length ?? 0;
2449
+ // Find anchor node (selection anchor position)
2450
+ if (!anchorNode && currentPos + nodeLength >= anchorPos) {
2451
+ anchorNode = node;
2452
+ anchorOffset = anchorPos - currentPos;
2453
+ }
2454
+ // Find target node
2455
+ if (!targetNode && currentPos + nodeLength >= targetPos) {
2456
+ targetNode = node;
2457
+ targetOffset = targetPos - currentPos;
2458
+ }
2459
+ if (anchorNode && targetNode) {
2460
+ break;
2461
+ }
2462
+ currentPos += nodeLength;
2463
+ }
2464
+ if (anchorNode && targetNode) {
2465
+ const range = document.createRange();
2466
+ // Set range from anchor to target (direction matters for selection direction)
2467
+ if (key === "ArrowLeft") {
2468
+ // Selecting backwards - anchor stays, extend to start
2469
+ range.setStart(targetNode, targetOffset);
2470
+ range.setEnd(anchorNode, anchorOffset);
2471
+ }
2472
+ else {
2473
+ // Selecting forwards - anchor stays, extend to end
2474
+ range.setStart(anchorNode, anchorOffset);
2475
+ range.setEnd(targetNode, targetOffset);
2476
+ }
2477
+ selection.removeAllRanges();
2478
+ selection.addRange(range);
2479
+ }
2480
+ }
2481
+ else {
2482
+ // Move cursor to start/end
2483
+ let currentPos = 0;
2484
+ const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
2485
+ let node;
2486
+ while ((node = walker.nextNode())) {
2487
+ const nodeLength = node.textContent?.length ?? 0;
2488
+ if (currentPos + nodeLength >= targetPos) {
2489
+ const offset = targetPos - currentPos;
2490
+ const range = document.createRange();
2491
+ range.setStart(node, offset);
2492
+ range.collapse(true);
2493
+ selection.removeAllRanges();
2494
+ selection.addRange(range);
2495
+ return;
2496
+ }
2497
+ currentPos += nodeLength;
2498
+ }
2499
+ // Fallback
2500
+ const range = document.createRange();
2501
+ range.selectNodeContents(spanRef.current);
2502
+ range.collapse(key === "ArrowLeft");
2503
+ selection.removeAllRanges();
2504
+ selection.addRange(range);
2505
+ }
2506
+ return;
2507
+ }
2508
+ const allowedKeys = [
2509
+ "Backspace",
2510
+ "Delete",
2511
+ "ArrowLeft",
2512
+ "ArrowRight",
2513
+ "Tab",
2514
+ "Home",
2515
+ "End",
2516
+ ];
2517
+ if (allowedKeys.includes(key)) {
2518
+ // Handle Backspace and Delete ourselves
2519
+ if (key === "Backspace") {
2520
+ event.preventDefault();
2521
+ if (start === end) {
2522
+ // No selection, delete character before cursor
2523
+ if (start > 0) {
2524
+ // Remove barrel wheel at the position being deleted
2525
+ removeBarrelWheelsAtIndices([start - 1]);
2526
+ const newText = currentText.slice(0, start - 1) + currentText.slice(start);
2527
+ updateValue(newText, start - 1, start - 1, start);
2528
+ }
2529
+ }
2530
+ else {
2531
+ // Has selection, delete selected text
2532
+ // Remove barrel wheels for all indices in the selection range
2533
+ const indicesToRemove = [];
2534
+ for (let i = start; i < end; i++) {
2535
+ indicesToRemove.push(i);
2536
+ }
2537
+ removeBarrelWheelsAtIndices(indicesToRemove);
2538
+ const newText = currentText.slice(0, start) + currentText.slice(end);
2539
+ updateValue(newText, start, start, end);
2540
+ }
2541
+ return;
2542
+ }
2543
+ if (key === "Delete") {
2544
+ event.preventDefault();
2545
+ if (start === end) {
2546
+ // No selection
2547
+ if (event.metaKey || event.ctrlKey) {
2548
+ // Ctrl/Cmd+Delete: delete all characters after cursor
2549
+ if (start < currentText.length) {
2550
+ // Remove barrel wheels for all indices being deleted
2551
+ const indicesToRemove = [];
2552
+ for (let i = start; i < currentText.length; i++) {
2553
+ indicesToRemove.push(i);
2554
+ }
2555
+ removeBarrelWheelsAtIndices(indicesToRemove);
2556
+ const newText = currentText.slice(0, start);
2557
+ updateValue(newText, start, start, currentText.length);
2558
+ }
2559
+ }
2560
+ else {
2561
+ // Delete: delete one character after cursor
2562
+ if (start < currentText.length) {
2563
+ // Remove barrel wheel at the position being deleted
2564
+ removeBarrelWheelsAtIndices([start]);
2565
+ const newText = currentText.slice(0, start) + currentText.slice(start + 1);
2566
+ updateValue(newText, start, start, start + 1);
2567
+ }
2568
+ }
2569
+ }
2570
+ else {
2571
+ // Has selection, delete selected text
2572
+ // Remove barrel wheels for all indices in the selection range
2573
+ const indicesToRemove = [];
2574
+ for (let i = start; i < end; i++) {
2575
+ indicesToRemove.push(i);
2576
+ }
2577
+ removeBarrelWheelsAtIndices(indicesToRemove);
2578
+ const newText = currentText.slice(0, start) + currentText.slice(end);
2579
+ updateValue(newText, start, start, end);
2580
+ }
2581
+ return;
2582
+ }
2583
+ // Handle ArrowLeft and ArrowRight to move cursor by one character
2584
+ // For formatted numbers, we need to work with formatted positions and skip separators
2585
+ if (key === "ArrowLeft" || key === "ArrowRight") {
2586
+ event.preventDefault();
2587
+ if (!spanRef.current) {
2588
+ return;
2589
+ }
2590
+ const selection = window.getSelection();
2591
+ if (!selection) {
2592
+ return;
2593
+ }
2594
+ // Helper to check if a character is a separator (not digit, dot, or minus)
2595
+ const isSeparator = (char) => {
2596
+ if (!char) {
2597
+ return false;
2598
+ }
2599
+ return new RegExp(`[^\\d.${localeSeparators.decimal}-]`).test(char);
2600
+ };
2601
+ // Get current cursor position in formatted text
2602
+ const { start: formattedCursorStart, end: formattedCursorEnd } = getSelectionRange(spanRef.current, selection);
2603
+ // Calculate target position in formatted text
2604
+ let targetFormattedPos;
2605
+ if (event.shiftKey) {
2606
+ // Extend selection - use formatted positions directly
2607
+ const getPositionFromNode = (node, offset) => {
2608
+ if (!node || !spanRef.current?.contains(node)) {
2609
+ return formattedCursorStart;
2610
+ }
2611
+ const range = document.createRange();
2612
+ range.setStart(spanRef.current, 0);
2613
+ range.setEnd(node, offset);
2614
+ return range.toString().length;
2615
+ };
2616
+ let anchorPos = getPositionFromNode(selection.anchorNode, selection.anchorOffset);
2617
+ let focusPos = getPositionFromNode(selection.focusNode, selection.focusOffset);
2618
+ if (anchorPos === focusPos &&
2619
+ formattedCursorStart === formattedCursorEnd) {
2620
+ anchorPos = formattedCursorStart;
2621
+ focusPos = formattedCursorStart;
2622
+ }
2623
+ // Move focus, skipping separators
2624
+ if (key === "ArrowLeft") {
2625
+ targetFormattedPos = Math.max(0, focusPos - 1);
2626
+ // Skip over separators when moving left
2627
+ while (targetFormattedPos > 0 &&
2628
+ isSeparator(currentFormattedText[targetFormattedPos])) {
2629
+ targetFormattedPos--;
2630
+ }
2631
+ }
2632
+ else {
2633
+ targetFormattedPos = Math.min(currentFormattedText.length, focusPos + 1);
2634
+ // Skip over separators when moving right
2635
+ while (targetFormattedPos < currentFormattedText.length &&
2636
+ isSeparator(currentFormattedText[targetFormattedPos])) {
2637
+ targetFormattedPos++;
2638
+ }
2639
+ }
2640
+ // Find nodes for selection
2641
+ let currentPos = 0;
2642
+ const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
2643
+ let anchorNode = null;
2644
+ let anchorOffset = 0;
2645
+ let targetNode = null;
2646
+ let targetOffset = 0;
2647
+ let node;
2648
+ while ((node = walker.nextNode())) {
2649
+ const nodeLength = node.textContent?.length ?? 0;
2650
+ if (!anchorNode && currentPos + nodeLength >= anchorPos) {
2651
+ anchorNode = node;
2652
+ anchorOffset = anchorPos - currentPos;
2653
+ }
2654
+ if (!targetNode &&
2655
+ currentPos + nodeLength >= targetFormattedPos) {
2656
+ targetNode = node;
2657
+ targetOffset = targetFormattedPos - currentPos;
2658
+ }
2659
+ if (anchorNode && targetNode) {
2660
+ break;
2661
+ }
2662
+ currentPos += nodeLength;
2663
+ }
2664
+ if (targetNode) {
2665
+ try {
2666
+ selection.extend(targetNode, targetOffset);
2667
+ }
2668
+ catch {
2669
+ if (anchorNode) {
2670
+ const range = document.createRange();
2671
+ range.setStart(anchorNode, anchorOffset);
2672
+ range.setEnd(targetNode, targetOffset);
2673
+ selection.removeAllRanges();
2674
+ selection.addRange(range);
2675
+ }
2676
+ }
2677
+ }
2678
+ }
2679
+ else {
2680
+ // Move cursor (no shift key)
2681
+ if (formattedCursorStart !== formattedCursorEnd) {
2682
+ // There's a selection - move to start or end based on arrow direction
2683
+ targetFormattedPos =
2684
+ key === "ArrowLeft"
2685
+ ? formattedCursorStart
2686
+ : formattedCursorEnd;
2687
+ }
2688
+ else {
2689
+ // No selection - move cursor by one position, skipping separators
2690
+ if (key === "ArrowLeft") {
2691
+ targetFormattedPos = Math.max(0, formattedCursorStart - 1);
2692
+ // Skip over separators when moving left
2693
+ while (targetFormattedPos > 0 &&
2694
+ isSeparator(currentFormattedText[targetFormattedPos])) {
2695
+ targetFormattedPos--;
2696
+ }
2697
+ }
2698
+ else {
2699
+ targetFormattedPos = Math.min(currentFormattedText.length, formattedCursorStart + 1);
2700
+ // Skip over separators when moving right
2701
+ while (targetFormattedPos < currentFormattedText.length &&
2702
+ isSeparator(currentFormattedText[targetFormattedPos])) {
2703
+ targetFormattedPos++;
2704
+ }
2705
+ }
2706
+ }
2707
+ // Find target node using formatted position
2708
+ let currentPos = 0;
2709
+ const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
2710
+ let node;
2711
+ while ((node = walker.nextNode())) {
2712
+ const nodeLength = node.textContent?.length ?? 0;
2713
+ if (currentPos + nodeLength >= targetFormattedPos) {
2714
+ const offset = targetFormattedPos - currentPos;
2715
+ const range = document.createRange();
2716
+ range.setStart(node, offset);
2717
+ range.collapse(true);
2718
+ selection.removeAllRanges();
2719
+ selection.addRange(range);
2720
+ return;
2721
+ }
2722
+ currentPos += nodeLength;
2723
+ }
2724
+ // Fallback to start/end
2725
+ const range = document.createRange();
2726
+ range.selectNodeContents(spanRef.current);
2727
+ range.collapse(key === "ArrowLeft");
2728
+ selection.removeAllRanges();
2729
+ selection.addRange(range);
2730
+ }
2731
+ return;
2732
+ }
2733
+ // Allow other navigation keys
2734
+ return;
2735
+ }
2736
+ if (event.ctrlKey || event.metaKey) {
2737
+ return;
2738
+ }
2739
+ // Handle character input
2740
+ if (/^\d$/.test(key)) {
2741
+ // Prevent typing more decimals than allowed
2742
+ const decimalPart = currentText.split(".")[1];
2743
+ if (isNonNullable(decimalScale) &&
2744
+ decimalPart &&
2745
+ decimalPart.length >= decimalScale &&
2746
+ Math.abs(end - start) !== 1) {
2747
+ const decimalPosition = currentText.indexOf(".");
2748
+ const isCursorInDecimalPart = start > decimalPosition;
2749
+ if (isCursorInDecimalPart) {
2750
+ event.preventDefault();
2751
+ return;
2752
+ }
2753
+ }
2754
+ // Prevent typing digit when cursor is at position 0 and text starts with "-"
2755
+ if (currentText.startsWith("-") && start === 0 && end === 0) {
2756
+ event.preventDefault();
2757
+ return;
2758
+ }
2759
+ // Prevent adding 0 when there's already a leading 0 and cursor is before/after it
2760
+ // Also prevent adding 0 at the beginning of a number (would create leading zero)
2761
+ if (key === "0") {
2762
+ let shouldPrevent = false;
2763
+ let restorePos = start;
2764
+ // Check if text starts with "0" (including "0.")
2765
+ if (currentText.startsWith("0") && currentText.length > 0) {
2766
+ // Cursor is at position 0 (before the 0) - prevent typing another 0
2767
+ if (start === 0) {
2768
+ shouldPrevent = true;
2769
+ restorePos = start;
2770
+ }
2771
+ // Cursor is at position 1 (right after the 0) - prevent typing another 0
2772
+ // This applies even if followed by "." (e.g., "0.1121" should not become "00.1121")
2773
+ else if (start === 1 && end === 1) {
2774
+ shouldPrevent = true;
2775
+ restorePos = start;
2776
+ }
2777
+ }
2778
+ // Check if text starts with "-0" (including "-0.")
2779
+ else if (currentText.startsWith("-0") && currentText.length > 1) {
2780
+ // Cursor is at position 1 (right after "-") or 2 (right after "-0")
2781
+ // Prevent typing 0 at position 1 if we already have "-0" (whether followed by "." or not)
2782
+ if (start === 1) {
2783
+ shouldPrevent = true;
2784
+ restorePos = start;
2785
+ }
2786
+ else if (start === 2 && end === 2) {
2787
+ // Also prevent at position 2 (even if followed by ".")
2788
+ // This applies even if followed by "." (e.g., "-0.1121" should not become "-00.1121")
2789
+ shouldPrevent = true;
2790
+ restorePos = start;
2791
+ }
2792
+ }
2793
+ // Prevent adding 0 at the beginning of a number (would create leading zero like "012")
2794
+ // BUT allow it when text starts with "." (e.g., ".1121" -> "0.1121")
2795
+ else if (start === 0 &&
2796
+ currentText.length > 0 &&
2797
+ !currentText.startsWith("0") &&
2798
+ !currentText.startsWith("-") &&
2799
+ !currentText.startsWith(".")) {
2800
+ // Typing 0 at position 0 of a number like "12" would create "012" which gets cleaned to "12"
2801
+ // So we should prevent it (unless text starts with ".")
2802
+ shouldPrevent = true;
2803
+ restorePos = start;
2804
+ }
2805
+ // Prevent adding 0 after minus in negative number (would create leading zero like "-012")
2806
+ // BUT allow it when the next character is "." (e.g., "-.1121" -> "-0.1121")
2807
+ else if (currentText.startsWith("-") &&
2808
+ start === 1 &&
2809
+ currentText.length > 1 &&
2810
+ currentText[1] !== "0" &&
2811
+ currentText[1] !== ".") {
2812
+ // Typing 0 at position 1 after "-" in a number like "-12" would create "-012" which gets cleaned to "-12"
2813
+ // So we should prevent it (unless the next character is ".")
2814
+ shouldPrevent = true;
2815
+ restorePos = start;
2816
+ }
2817
+ if (shouldPrevent) {
2818
+ event.preventDefault();
2819
+ event.stopPropagation();
2820
+ // Mark that we should prevent the next input event
2821
+ shouldPreventInputRef.current = true;
2822
+ preventInputCursorPosRef.current = restorePos;
2823
+ // Restore cursor to original position - use both immediate and deferred restoration
2824
+ // to catch any browser default behavior
2825
+ if (spanRef.current) {
2826
+ const restoreCursor = () => {
2827
+ if (!spanRef.current) {
2828
+ return;
2829
+ }
2830
+ const selection = window.getSelection();
2831
+ if (!selection) {
2832
+ return;
2833
+ }
2834
+ let currentPos = 0;
2835
+ const walker = document.createTreeWalker(spanRef.current, NodeFilter.SHOW_TEXT, null);
2836
+ let node;
2837
+ while ((node = walker.nextNode())) {
2838
+ const nodeLength = node.textContent?.length ?? 0;
2839
+ if (currentPos + nodeLength >= restorePos) {
2840
+ const offset = Math.min(restorePos - currentPos, nodeLength);
2841
+ const range = document.createRange();
2842
+ range.setStart(node, offset);
2843
+ range.collapse(true);
2844
+ selection.removeAllRanges();
2845
+ selection.addRange(range);
2846
+ return;
2847
+ }
2848
+ currentPos += nodeLength;
2849
+ }
2850
+ // Fallback
2851
+ const range = document.createRange();
2852
+ range.selectNodeContents(spanRef.current);
2853
+ range.collapse(true);
2854
+ selection.removeAllRanges();
2855
+ selection.addRange(range);
2856
+ };
2857
+ // Try immediately
2858
+ restoreCursor();
2859
+ // Also try after a microtask to catch any delayed browser behavior
2860
+ Promise.resolve().then(restoreCursor);
2861
+ // And after a short timeout as a final safeguard
2862
+ setTimeout(restoreCursor, 0);
2863
+ requestAnimationFrame(restoreCursor);
2864
+ }
2865
+ return;
2866
+ }
2867
+ }
2868
+ event.preventDefault();
2869
+ // Check maxLength before inserting
2870
+ const newLength = currentText.length - (end - start) + 1;
2871
+ if (maxLength !== undefined && newLength > maxLength) {
2872
+ return;
2873
+ }
2874
+ const newText = currentText.slice(0, start) + key + currentText.slice(end);
2875
+ updateValue(newText, start + 1, start, end);
2876
+ return;
2877
+ }
2878
+ // Prevent default for other character inputs
2879
+ event.preventDefault();
2880
+ // Handle decimal point input - accept both '.' and locale decimal separator
2881
+ const { decimal } = localeSeparators;
2882
+ if (key === "." || key === decimal) {
2883
+ // Only allow one decimal point (internally stored as '.')
2884
+ if (!currentText.includes(".")) {
2885
+ // Prevent typing decimal when cursor is at position 0 and text starts with "-"
2886
+ if (currentText.startsWith("-") && start === 0 && end === 0) {
2887
+ return;
2888
+ }
2889
+ // Check maxLength before inserting
2890
+ const newLength = currentText.length - (end - start) + 1;
2891
+ if (maxLength !== undefined && newLength > maxLength) {
2892
+ return;
2893
+ }
2894
+ // Prevent if no decimals allowed
2895
+ if (decimalScale === 0) {
2896
+ return;
2897
+ }
2898
+ // Always insert '.' internally (will be displayed as locale decimal)
2899
+ const newText = currentText.slice(0, start) + "." + currentText.slice(end);
2900
+ const decimalPart = newText.split(".")[1];
2901
+ const shouldCropDecimalPart = isNonNullable(decimalScale) &&
2902
+ decimalPart &&
2903
+ decimalPart.length > decimalScale;
2904
+ if (shouldCropDecimalPart) {
2905
+ const decimalPosition = newText.indexOf(".");
2906
+ updateValue(newText.slice(0, decimalPosition + decimalScale + 1), start + 1, start, end);
2907
+ }
2908
+ else {
2909
+ updateValue(newText, start + 1, start, end);
2910
+ }
2911
+ }
2912
+ return;
2913
+ }
2914
+ if (key === "-") {
2915
+ // Only allow minus at the beginning, and only if there isn't already one
2916
+ const hasMinus = currentText.startsWith("-");
2917
+ if (start === 0 && !hasMinus && allowNegative) {
2918
+ // Check maxLength before inserting
2919
+ const newLength = currentText.length - (end - start) + 1;
2920
+ if (maxLength !== undefined && newLength > maxLength) {
2921
+ return;
2922
+ }
2923
+ // Insert minus at the beginning (can replace selection)
2924
+ const newText = key + currentText.slice(end);
2925
+ updateValue(newText, start + 1, start, end);
2926
+ }
2927
+ // If there's already a minus, ignore the input (don't toggle or insert)
2928
+ return;
2929
+ }
2930
+ }, [
2931
+ decimalScale,
2932
+ allowNegative,
2933
+ displayValue,
2934
+ formattedDisplayValue,
2935
+ mapFormattedToRawIndex,
2936
+ localeSeparators,
2937
+ removeBarrelWheelsAtIndices,
2938
+ updateValue,
2939
+ applyHistoryItemWithCursor,
2940
+ applyHistoryItem,
2941
+ maxLength,
2942
+ ]);
2943
+ const handleCopy = useCallback((event) => {
2944
+ const selection = window.getSelection();
2945
+ const range = selection?.getRangeAt(0);
2946
+ if (!range || !spanRef.current) {
2947
+ return;
2948
+ }
2949
+ if (!selection) {
2950
+ return;
2951
+ }
2952
+ const { start: formattedStart, end: formattedEnd } = getSelectionRange(spanRef.current, selection);
2953
+ const start = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedStart);
2954
+ const end = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedEnd);
2955
+ if (start === end) {
2956
+ return;
2957
+ }
2958
+ const selectedText = displayValue.slice(start, end);
2959
+ event.clipboardData.setData("text/plain", selectedText);
2960
+ event.preventDefault();
2961
+ }, [displayValue, formattedDisplayValue, mapFormattedToRawIndex]);
2962
+ const handleCut = useCallback((event) => {
2963
+ const selection = window.getSelection();
2964
+ const range = selection?.getRangeAt(0);
2965
+ if (!range || !spanRef.current) {
2966
+ return;
2967
+ }
2968
+ if (!selection) {
2969
+ return;
2970
+ }
2971
+ const { start: formattedStart, end: formattedEnd } = getSelectionRange(spanRef.current, selection);
2972
+ const start = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedStart);
2973
+ const end = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedEnd);
2974
+ if (start === end) {
2975
+ return;
2976
+ }
2977
+ const selectedText = displayValue.slice(start, end);
2978
+ event.clipboardData.setData("text/plain", selectedText);
2979
+ const newText = displayValue.slice(0, start) + displayValue.slice(end);
2980
+ setTimeout(() => {
2981
+ updateValue(newText, start, start, end);
2982
+ }, 0);
2983
+ }, [
2984
+ displayValue,
2985
+ formattedDisplayValue,
2986
+ mapFormattedToRawIndex,
2987
+ updateValue,
2988
+ ]);
2989
+ const handleBeforeInput = useCallback((event) => {
2990
+ if (shouldPreventInputRef.current) {
2991
+ event.preventDefault();
2992
+ const restorePos = preventInputCursorPosRef.current;
2993
+ shouldPreventInputRef.current = false;
2994
+ const restoreCursor = () => {
2995
+ if (spanRef.current) {
2996
+ setCursorPositionInElement(spanRef.current, restorePos);
2997
+ }
2998
+ };
2999
+ restoreCursor();
3000
+ Promise.resolve().then(restoreCursor);
3001
+ setTimeout(restoreCursor, 0);
3002
+ requestAnimationFrame(restoreCursor);
3003
+ }
3004
+ }, []);
3005
+ const handleInput = useCallback(() => {
3006
+ // Reset the prevent flag after input is processed
3007
+ if (shouldPreventInputRef.current) {
3008
+ shouldPreventInputRef.current = false;
3009
+ }
3010
+ }, []);
3011
+ const handlePaste = useCallback((event) => {
3012
+ event.preventDefault();
3013
+ let pastedText = event.clipboardData.getData("text");
3014
+ // Convert locale decimal separator to '.' for internal storage
3015
+ const { decimal } = localeSeparators;
3016
+ if (decimal !== ".") {
3017
+ // Replace locale decimal with '.' and also accept '.' as-is
3018
+ pastedText = pastedText.replace(new RegExp(`\\${decimal}`, "g"), ".");
3019
+ }
3020
+ // Validate: only allow digits, optional minus at start, optional single decimal
3021
+ if (!/^-?\d*\.?\d*$/.test(pastedText)) {
3022
+ return;
3023
+ }
3024
+ // Should we prevent negative numbers?
3025
+ if (!allowNegative && pastedText.startsWith("-")) {
3026
+ return;
3027
+ }
3028
+ // Prevent pasting more than requested decimal scale
3029
+ const decimalPart = pastedText.split(".")[1];
3030
+ if (isNonNullable(decimalScale) &&
3031
+ decimalPart &&
3032
+ decimalPart.length > decimalScale) {
3033
+ if (decimalScale === 0) {
3034
+ pastedText = pastedText.slice(0, pastedText.indexOf("."));
3035
+ }
3036
+ else {
3037
+ pastedText = pastedText.slice(0, pastedText.indexOf(".") + 1 + decimalScale);
3038
+ }
3039
+ }
3040
+ const selection = window.getSelection();
3041
+ const range = selection?.getRangeAt(0);
3042
+ if (!range || !spanRef.current) {
3043
+ return;
3044
+ }
3045
+ if (!selection) {
3046
+ return;
3047
+ }
3048
+ const { start: formattedStart, end: formattedEnd } = getSelectionRange(spanRef.current, selection);
3049
+ const start = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedStart);
3050
+ const end = mapFormattedToRawIndex(displayValue, formattedDisplayValue, formattedEnd);
3051
+ // Truncate pasted text if it would exceed maxLength
3052
+ if (maxLength !== undefined) {
3053
+ const availableLength = maxLength - (displayValue.length - (end - start));
3054
+ if (availableLength <= 0) {
3055
+ return;
3056
+ }
3057
+ if (pastedText.length > availableLength) {
3058
+ pastedText = pastedText.slice(0, availableLength);
3059
+ }
3060
+ }
3061
+ const newText = displayValue.slice(0, start) + pastedText + displayValue.slice(end);
3062
+ if (/^-?\d*\.?\d*$/.test(newText)) {
3063
+ updateValue(newText, start + pastedText.length, start, end);
3064
+ }
3065
+ }, [
3066
+ decimalScale,
3067
+ allowNegative,
3068
+ localeSeparators,
3069
+ mapFormattedToRawIndex,
3070
+ displayValue,
3071
+ formattedDisplayValue,
3072
+ maxLength,
3073
+ updateValue,
3074
+ ]);
3075
+ useEffect(() => {
3076
+ if (autoFocus) {
3077
+ spanRef.current?.focus();
3078
+ }
3079
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3080
+ }, []);
3081
+ return (_jsx(_Fragment, { children: _jsx("span", { className: className, "data-numberflow-input-root": "", style: {
3082
+ display: "inline-flex",
3083
+ ...style,
3084
+ }, children: _jsxs("span", { "data-numberflow-input-wrapper": "", style: {
3085
+ display: "inline-flex",
3086
+ overflow: "hidden",
3087
+ }, children: [_jsx("span", { role: "textbox", tabIndex: 0, ref: combineRefs(spanRef, ref), contentEditable: true, inputMode: "decimal", suppressContentEditableWarning: true, onKeyDown: handleKeyDown, onBeforeInput: handleBeforeInput, onInput: handleInput, onCopy: handleCopy, onPaste: handlePaste, onCut: handleCut, onFocus: onFocus, onBlur: onBlur, "data-numberflow-input-contenteditable": "", style: {
3088
+ display: "inline-block",
3089
+ }, "data-placeholder": placeholder }), _jsx("input", { ref: inputRef, ...inputProps, type: "string", inputMode: "decimal", readOnly: true, tabIndex: -1, "data-numberflow-input-real-input": "", value: actualValue?.toString() ?? "", style: {
3090
+ height: "1px",
3091
+ left: "-9999px",
3092
+ opacity: 0,
3093
+ pointerEvents: "none",
3094
+ position: "absolute",
3095
+ width: "1px",
3096
+ } })] }) }) }));
3097
+ });
3098
+ NumberFlowInput.displayName = "NumberFlowInput";
3099
+ //# sourceMappingURL=NumberFlowInput.js.map