react-native-transformer-text-input 0.1.0-alpha.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +17 -1
  2. package/android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputDecoratorView.kt +30 -0
  3. package/cpp/TransformerTextInputDecoratorViewShadowNode.cpp +24 -1
  4. package/ios/TransformerTextInputDecoratorView.mm +42 -3
  5. package/lib/module/TransformerTextInput.js +24 -0
  6. package/lib/module/TransformerTextInput.js.map +1 -1
  7. package/lib/module/formatters/currency.js +122 -0
  8. package/lib/module/formatters/currency.js.map +1 -0
  9. package/lib/module/formatters/phone-data.js +4844 -0
  10. package/lib/module/formatters/phone-data.js.map +1 -0
  11. package/lib/module/formatters/phone-number.js +323 -61
  12. package/lib/module/formatters/phone-number.js.map +1 -1
  13. package/lib/module/registry.js +4 -3
  14. package/lib/module/registry.js.map +1 -1
  15. package/lib/typescript/scripts/generate-phone-data.d.ts +7 -0
  16. package/lib/typescript/scripts/generate-phone-data.d.ts.map +1 -0
  17. package/lib/typescript/src/TransformerTextInput.d.ts.map +1 -1
  18. package/lib/typescript/src/formatters/currency.d.ts +17 -0
  19. package/lib/typescript/src/formatters/currency.d.ts.map +1 -0
  20. package/lib/typescript/src/formatters/phone-data.d.ts +15 -0
  21. package/lib/typescript/src/formatters/phone-data.d.ts.map +1 -0
  22. package/lib/typescript/src/formatters/phone-number.d.ts +10 -4
  23. package/lib/typescript/src/formatters/phone-number.d.ts.map +1 -1
  24. package/lib/typescript/src/registry.d.ts.map +1 -1
  25. package/package.json +14 -1
  26. package/src/TransformerTextInput.tsx +23 -1
  27. package/src/formatters/currency.ts +142 -0
  28. package/src/formatters/phone-data.ts +5665 -0
  29. package/src/formatters/phone-number.ts +326 -68
  30. package/src/registry.ts +4 -3
@@ -1,13 +1,19 @@
1
1
  import { Transformer, type Selection } from '../Transformer';
2
- import { PatternTransformer } from './pattern';
2
+ import { COUNTRY_PHONE_DATA, type PhoneFormat } from './phone-data';
3
3
 
4
4
  export type PhoneNumberTransformerOptions = {
5
5
  /**
6
- * Country code for phone number formatting.
7
- * Currently only 'US' is supported.
6
+ * ISO 3166-1 alpha-2 country code for phone number formatting.
8
7
  * @default 'US'
9
8
  */
10
- country?: 'US';
9
+ country?: string;
10
+ /**
11
+ * When false, the output only contains the formatted national number
12
+ * (without the "+{callingCode} " prefix). Useful when the calling code
13
+ * is displayed separately (e.g., in a flag button).
14
+ * @default true
15
+ */
16
+ includeCallingCode?: boolean;
11
17
  /**
12
18
  * Enable debug logging for transformer operations.
13
19
  * @default false
@@ -15,6 +21,12 @@ export type PhoneNumberTransformerOptions = {
15
21
  debug?: boolean;
16
22
  };
17
23
 
24
+ // Extract all digits from text
25
+ const extractDigits = (text: string): string => {
26
+ 'worklet';
27
+ return text.replace(/\D/g, '');
28
+ };
29
+
18
30
  // Count digits before a position in text
19
31
  const countDigitsBefore = (text: string, pos: number): number => {
20
32
  'worklet';
@@ -28,36 +40,237 @@ const countDigitsBefore = (text: string, pos: number): number => {
28
40
  return count;
29
41
  };
30
42
 
31
- // Extract all digits from text
32
- const extractDigits = (text: string): string => {
43
+ // Select the best format for a given national number based on leading digits.
44
+ // Iterates formats and tests the leadingDigits regex against the start of digits.
45
+ // Returns the first match, or the last format as fallback.
46
+ const selectFormat = (
47
+ nationalDigits: string,
48
+ formats: PhoneFormat[],
49
+ ): PhoneFormat | null => {
33
50
  'worklet';
34
- return text.replace(/\D/g, '');
51
+ if (formats.length === 0) return null;
52
+ if (nationalDigits.length === 0) return formats[formats.length - 1]!;
53
+
54
+ for (const format of formats) {
55
+ if (!format.leadingDigits) {
56
+ // No leading digits constraint — matches everything
57
+ return format;
58
+ }
59
+ // Test leading digits regex against the national digits.
60
+ // The regex should match from the start of the digits.
61
+ const re = new RegExp('^(?:' + format.leadingDigits + ')');
62
+ if (re.test(nationalDigits)) {
63
+ return format;
64
+ }
65
+ }
66
+
67
+ // Fallback to last format
68
+ return formats[formats.length - 1]!;
69
+ };
70
+
71
+ // Count the max digits the format pattern can consume by counting \d occurrences
72
+ // in the capture groups.
73
+ const getFormatMaxDigits = (pattern: string): number => {
74
+ 'worklet';
75
+ let count = 0;
76
+ // Each \\d or \d in the pattern represents one digit slot.
77
+ // But patterns use quantifiers like {3} or {3,4} — we want the max.
78
+ // Simple approach: count the max digits by examining the pattern groups.
79
+ // Groups look like (\d{3}) or (\d{2,4}) or (\d{3,12})
80
+ // We'll parse {n} and {n,m} to get max values.
81
+ let i = 0;
82
+ while (i < pattern.length) {
83
+ if (
84
+ pattern[i] === '\\' &&
85
+ i + 1 < pattern.length &&
86
+ pattern[i + 1] === 'd'
87
+ ) {
88
+ i += 2;
89
+ if (i < pattern.length && pattern[i] === '{') {
90
+ // Parse {n} or {n,m}
91
+ const closeBrace = pattern.indexOf('}', i);
92
+ if (closeBrace !== -1) {
93
+ const inner = pattern.slice(i + 1, closeBrace);
94
+ const commaIdx = inner.indexOf(',');
95
+ if (commaIdx !== -1) {
96
+ // {n,m} — use max
97
+ count += parseInt(inner.slice(commaIdx + 1), 10) || 0;
98
+ } else {
99
+ count += parseInt(inner, 10) || 0;
100
+ }
101
+ i = closeBrace + 1;
102
+ }
103
+ } else {
104
+ // Just \d without quantifier — 1 digit
105
+ count += 1;
106
+ }
107
+ } else {
108
+ i++;
109
+ }
110
+ }
111
+ return count;
112
+ };
113
+
114
+ // Build a partial format for when we don't have enough digits to match the full pattern.
115
+ // We expand digit groups one at a time and fill what we can.
116
+ const buildPartialFormat = (digits: string, format: PhoneFormat): string => {
117
+ 'worklet';
118
+ // Parse the pattern to extract groups
119
+ // Pattern like (\d{3})(\d{3})(\d{4}) with template ($1) $2-$3
120
+ // We need to figure out group sizes and fill them progressively
121
+
122
+ const groups: number[] = [];
123
+ let i = 0;
124
+ const pattern = format.pattern;
125
+
126
+ while (i < pattern.length) {
127
+ if (pattern[i] === '(' && i + 1 < pattern.length) {
128
+ // Find matching close paren
129
+ let depth = 1;
130
+ let j = i + 1;
131
+ while (j < pattern.length && depth > 0) {
132
+ if (pattern[j] === '(') depth++;
133
+ else if (pattern[j] === ')') depth--;
134
+ j++;
135
+ }
136
+ // Extract group content
137
+ const groupContent = pattern.slice(i + 1, j - 1);
138
+ // Count max digits in this group
139
+ const groupMax = getFormatMaxDigits(groupContent);
140
+ if (groupMax > 0) {
141
+ groups.push(groupMax);
142
+ }
143
+ i = j;
144
+ } else {
145
+ i++;
146
+ }
147
+ }
148
+
149
+ if (groups.length === 0) return digits;
150
+
151
+ // Build the result by walking the template and replacing placeholders.
152
+ // We track which groups got filled and which didn't.
153
+ let digitIdx = 0;
154
+ let result = '';
155
+ let lastGroupComplete = false;
156
+ let allGroupsFilled = true;
157
+
158
+ // Walk through the template character by character
159
+ let ti = 0;
160
+ while (ti < format.template.length) {
161
+ const tc = format.template[ti];
162
+ if (tc === '$' && ti + 1 < format.template.length) {
163
+ const groupNum = parseInt(format.template[ti + 1]!, 10);
164
+ if (!isNaN(groupNum) && groupNum >= 1 && groupNum <= groups.length) {
165
+ const groupSize = groups[groupNum - 1]!;
166
+ const available = digits.length - digitIdx;
167
+ if (available <= 0) {
168
+ allGroupsFilled = false;
169
+ break;
170
+ }
171
+ const chunk = digits.slice(digitIdx, digitIdx + groupSize);
172
+ digitIdx += chunk.length;
173
+ result += chunk;
174
+ lastGroupComplete = chunk.length === groupSize;
175
+ if (chunk.length < groupSize) {
176
+ allGroupsFilled = false;
177
+ break;
178
+ }
179
+ ti += 2;
180
+ continue;
181
+ }
182
+ }
183
+ // Literal character — only include if we haven't run out of digits
184
+ result += tc;
185
+ ti++;
186
+ }
187
+
188
+ if (allGroupsFilled) {
189
+ return result;
190
+ }
191
+
192
+ // Handle trailing: if last group was fully filled, keep trailing separators
193
+ // (like "555" → "(555) " with template "($1) $2-$3")
194
+ // If last group was NOT fully filled, strip trailing non-digit chars
195
+ if (lastGroupComplete) {
196
+ // Keep trailing separators up to the next placeholder
197
+ return result;
198
+ }
199
+
200
+ // Strip trailing non-digit chars for incomplete groups
201
+ return result.replace(/[^\d]+$/, '');
202
+ };
203
+
204
+ // Apply a format to national digits. Returns the formatted national number.
205
+ const applyFormat = (nationalDigits: string, format: PhoneFormat): string => {
206
+ 'worklet';
207
+ const re = new RegExp(format.pattern);
208
+ const maxDigits = getFormatMaxDigits(format.pattern);
209
+ const clamped = nationalDigits.slice(0, maxDigits);
210
+
211
+ // Only apply template if we have enough digits for at least the first group
212
+ const match = clamped.match(re);
213
+ if (match) {
214
+ return clamped.replace(re, format.template);
215
+ }
216
+
217
+ // Partial input — build a partial format by expanding the pattern groups progressively.
218
+ return buildPartialFormat(clamped, format);
35
219
  };
36
220
 
37
- // Get national digits (strip leading 1 if present)
38
- const getNationalDigits = (allDigits: string): string => {
221
+ // Map cursor position from digit-space to formatted-space.
222
+ // Given a count of digits the cursor is after, find the position
223
+ // in the formatted string after that many digits.
224
+ const mapCursorToFormatted = (
225
+ formatted: string,
226
+ digitCount: number,
227
+ ): number => {
39
228
  'worklet';
40
- return allDigits.startsWith('1') ? allDigits.slice(1) : allDigits;
229
+ if (digitCount <= 0) {
230
+ // Find position of first digit in formatted string
231
+ for (let i = 0; i < formatted.length; i++) {
232
+ const c = formatted[i];
233
+ if (c !== undefined && c >= '0' && c <= '9') {
234
+ return i;
235
+ }
236
+ }
237
+ return 0;
238
+ }
239
+
240
+ let count = 0;
241
+ for (let i = 0; i < formatted.length; i++) {
242
+ const c = formatted[i];
243
+ if (c !== undefined && c >= '0' && c <= '9') {
244
+ count++;
245
+ if (count === digitCount) {
246
+ return i + 1;
247
+ }
248
+ }
249
+ }
250
+ return formatted.length;
41
251
  };
42
252
 
43
253
  export class PhoneNumberTransformer extends Transformer {
44
254
  constructor({
45
255
  country = 'US',
256
+ includeCallingCode = true,
46
257
  debug = false,
47
258
  }: PhoneNumberTransformerOptions = {}) {
48
- if (country !== 'US') {
259
+ const countryData = COUNTRY_PHONE_DATA[country];
260
+ if (!countryData) {
49
261
  throw new Error(
50
- `[PhoneNumberTransformer] Country "${country}" is not supported. Only "US" is currently supported.`,
262
+ `[PhoneNumberTransformer] Country "${country}" is not supported.`,
51
263
  );
52
264
  }
53
265
 
54
- const patternTransformer = new PatternTransformer({
55
- pattern: '+1 (###) ###-####',
56
- showTrailingLiterals: true,
57
- debug,
58
- });
59
-
60
- const patternTransformerWorklet = patternTransformer.worklet;
266
+ const callingCode = countryData.callingCode;
267
+ const formats = countryData.formats;
268
+ const prefix = '+' + callingCode + ' ';
269
+ // Pre-compute these outside the worklet so only simple string/number
270
+ // values are captured in the closure (more reliable across worklet runtimes).
271
+ const outputPrefix = includeCallingCode ? prefix : '';
272
+ const outputPrefixLen = outputPrefix.length;
273
+ const codeToStrip = includeCallingCode ? callingCode : '';
61
274
 
62
275
  const worklet = (input: {
63
276
  value: string;
@@ -73,34 +286,56 @@ export class PhoneNumberTransformer extends Transformer {
73
286
  const allDigits = extractDigits(value);
74
287
  const prevAllDigits = extractDigits(previousValue);
75
288
 
76
- const strippedLeading1 = allDigits.startsWith('1');
77
- const prevStrippedLeading1 = prevAllDigits.startsWith('1');
289
+ // Handle completely empty
290
+ if (allDigits.length === 0) {
291
+ return { value: '', selection: { start: 0, end: 0 } };
292
+ }
293
+
294
+ let nationalDigits: string;
295
+ let prevNationalDigits: string;
296
+ let strippedCallingCode = false;
78
297
 
79
- let nationalDigits = getNationalDigits(allDigits);
80
- const prevNationalDigits = getNationalDigits(prevAllDigits);
298
+ if (codeToStrip.length > 0) {
299
+ // Strip calling code from front to get national digits
300
+ if (allDigits.startsWith(codeToStrip)) {
301
+ nationalDigits = allDigits.slice(codeToStrip.length);
302
+ strippedCallingCode = true;
303
+ } else {
304
+ nationalDigits = allDigits;
305
+ }
306
+
307
+ if (prevAllDigits.startsWith(codeToStrip)) {
308
+ prevNationalDigits = prevAllDigits.slice(codeToStrip.length);
309
+ } else {
310
+ prevNationalDigits = prevAllDigits;
311
+ }
81
312
 
82
- // Special case: only country code "1" remains
83
- if (nationalDigits.length === 0 && allDigits === '1') {
84
- // If deleting, clear everything
85
- if (value.length < previousValue.length) {
86
- return { value: '', selection: { start: 0, end: 0 } };
313
+ // Special case: only calling code digits remain
314
+ if (nationalDigits.length === 0 && strippedCallingCode) {
315
+ if (value.length < previousValue.length) {
316
+ // Deleting clear everything
317
+ return { value: '', selection: { start: 0, end: 0 } };
318
+ }
319
+ // Show prefix
320
+ return {
321
+ value: outputPrefix,
322
+ selection: { start: outputPrefixLen, end: outputPrefixLen },
323
+ };
87
324
  }
88
- // Otherwise user just typed "1", show "+1 " prefix
89
- return { value: '+1 ', selection: { start: 3, end: 3 } };
325
+ } else {
326
+ // National-only mode: all digits are national, no calling code handling
327
+ nationalDigits = allDigits;
328
+ prevNationalDigits = prevAllDigits;
90
329
  }
91
330
 
92
- // Calculate digit positions for selection start and end
331
+ // Calculate digit positions for cursor mapping
93
332
  const digitsBeforeStart = countDigitsBefore(value, selection.start);
94
333
  const digitsBeforeEnd = countDigitsBefore(value, selection.end);
95
- const adjustedStart = strippedLeading1
96
- ? Math.max(0, digitsBeforeStart - 1)
97
- : digitsBeforeStart;
98
- const adjustedEnd = strippedLeading1
99
- ? Math.max(0, digitsBeforeEnd - 1)
100
- : digitsBeforeEnd;
101
-
102
- // Detect formatting char deletion at this level
103
- // (PatternTransformer can't detect it because we pass only digits)
334
+ const callingCodeLen = strippedCallingCode ? codeToStrip.length : 0;
335
+ const adjustedStart = Math.max(0, digitsBeforeStart - callingCodeLen);
336
+ const adjustedEnd = Math.max(0, digitsBeforeEnd - callingCodeLen);
337
+
338
+ // Detect formatting char deletion
104
339
  const isCaret = selection.start === selection.end;
105
340
  const deletedFormattingChar =
106
341
  isCaret &&
@@ -108,7 +343,6 @@ export class PhoneNumberTransformer extends Transformer {
108
343
  nationalDigits.length === prevNationalDigits.length &&
109
344
  nationalDigits.length > 0;
110
345
 
111
- // If formatting char was deleted, remove the digit before cursor
112
346
  let finalStart = adjustedStart;
113
347
  let finalEnd = adjustedEnd;
114
348
  if (deletedFormattingChar && adjustedStart > 0) {
@@ -119,33 +353,57 @@ export class PhoneNumberTransformer extends Transformer {
119
353
  finalEnd = adjustedStart - 1;
120
354
  }
121
355
 
122
- // For previous value selection
123
- const prevDigitsBeforeStart = countDigitsBefore(
124
- previousValue,
125
- previousSelection.start,
126
- );
127
- const prevDigitsBeforeEnd = countDigitsBefore(
128
- previousValue,
129
- previousSelection.end,
130
- );
131
- const prevAdjustedStart = prevStrippedLeading1
132
- ? Math.max(0, prevDigitsBeforeStart - 1)
133
- : prevDigitsBeforeStart;
134
- const prevAdjustedEnd = prevStrippedLeading1
135
- ? Math.max(0, prevDigitsBeforeEnd - 1)
136
- : prevDigitsBeforeEnd;
137
-
138
- // Call pattern transformer
139
- // Pass the modified nationalDigits directly as the "extracted" value
140
- // Use same length for prev to avoid triggering PatternTransformer's deletion logic
141
- return patternTransformerWorklet({
142
- value: nationalDigits,
143
- selection: { start: finalStart, end: finalEnd },
144
- previousValue: deletedFormattingChar
145
- ? nationalDigits
146
- : prevNationalDigits,
147
- previousSelection: { start: prevAdjustedStart, end: prevAdjustedEnd },
148
- });
356
+ // Select format based on leading digits
357
+ const format = selectFormat(nationalDigits, formats);
358
+ if (!format) {
359
+ // No format available — just show digits
360
+ const result = outputPrefix + nationalDigits;
361
+ const pos = outputPrefixLen + finalStart;
362
+ return { value: result, selection: { start: pos, end: pos } };
363
+ }
364
+
365
+ // Apply the selected format
366
+ const formatted = applyFormat(nationalDigits, format);
367
+ const result = outputPrefix + formatted;
368
+
369
+ // Map cursor position
370
+ const cursorAtEnd = selection.end >= value.length;
371
+ const prevCursorAtEnd = previousSelection.end >= previousValue.length;
372
+
373
+ if (debug) {
374
+ console.log('[PhoneNumberTransformer]', {
375
+ input: { value, selection },
376
+ nationalDigits,
377
+ format: format.template,
378
+ formatted,
379
+ result,
380
+ cursor: {
381
+ finalStart,
382
+ finalEnd,
383
+ deletedFormattingChar,
384
+ },
385
+ });
386
+ }
387
+
388
+ // Cursor at end — put at end
389
+ if (isCaret && cursorAtEnd && prevCursorAtEnd) {
390
+ return {
391
+ value: result,
392
+ selection: { start: result.length, end: result.length },
393
+ };
394
+ }
395
+
396
+ // Map cursor through the formatted output
397
+ // We need to find where digit N is in the formatted result
398
+ const newStart =
399
+ outputPrefixLen + mapCursorToFormatted(formatted, finalStart);
400
+ const newEnd =
401
+ outputPrefixLen + mapCursorToFormatted(formatted, finalEnd);
402
+
403
+ return {
404
+ value: result,
405
+ selection: { start: newStart, end: newEnd },
406
+ };
149
407
  };
150
408
 
151
409
  super(worklet);
package/src/registry.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { runOnUI } from 'react-native-worklets';
1
+ import { runOnUI, executeOnUIRuntimeSync } from 'react-native-worklets';
2
2
  import NativeTransformerTextInputModule from './NativeTransformerTextInputModule';
3
3
  import { type Selection, type Transformer } from './Transformer';
4
4
  import { computeUncontrolledSelection, validateSelection } from './selection';
@@ -29,8 +29,9 @@ function initializeIfNeeded() {
29
29
  return;
30
30
  }
31
31
 
32
- // Important that `runOnUI` is called first to make sure the UI runtime is initialized.
33
- runOnUI(() => {
32
+ // Set up registry on UI runtime synchronously so it is guaranteed to exist
33
+ // when native code accesses it after install().
34
+ executeOnUIRuntimeSync(() => {
34
35
  'worklet';
35
36
 
36
37
  const transformersMap = new Map<number, TransformerWrapper>();