svelte-tel-input 4.3.0 → 4.3.1

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.
@@ -2,10 +2,16 @@
2
2
  import { onMount, untrack } from 'svelte';
3
3
  import { ParseError } from 'libphonenumber-js/max/es6';
4
4
  import { generatePlaceholder } from '../../utils/index.js';
5
- import { parsePhoneInput } from '../../utils/helpers.js';
5
+ import { parsePhoneInput, toNationalFormat } from '../../utils/helpers.js';
6
6
  import { telInputAction } from '../../utils/directives/telInputAction.js';
7
7
  import { getCountryByIso2, guessCountryByPartialNumber } from '../../utils/countryHelpers.js';
8
- import type { CountryCode, TelInputOptions, Props, ValidationError } from '../../types';
8
+ import type {
9
+ CountryCode,
10
+ TelInputOptions,
11
+ Props,
12
+ ValidationError,
13
+ PreParsed
14
+ } from '../../types';
9
15
 
10
16
  const defaultOptions = {
11
17
  autoPlaceholder: true,
@@ -145,13 +151,14 @@
145
151
  if (
146
152
  initialFormat === 'national' &&
147
153
  details.formattedNumber &&
148
- details.countryCallingCode
154
+ details.countryCallingCode &&
155
+ details.formattedNumber.startsWith(`+${details.countryCallingCode} `)
149
156
  ) {
150
- const prefix = `+${details.countryCallingCode} `;
151
- if (details.formattedNumber.startsWith(prefix)) {
152
- const national = details.formattedNumber.slice(prefix.length);
153
- return spaces ? national : national.replace(/\s/g, '');
154
- }
157
+ return toNationalFormat(
158
+ details.formattedNumber,
159
+ details.countryCallingCode,
160
+ spaces
161
+ );
155
162
  }
156
163
  return spaces ? (details.formattedNumber ?? value) : value;
157
164
  } catch {
@@ -185,11 +192,11 @@
185
192
  ...options
186
193
  });
187
194
 
188
- const handleInputAction = (value: string) => {
195
+ const handleInputAction = (value: string, preParsed?: PreParsed) => {
189
196
  if (disabled || readonly) return;
190
197
  // First user keystroke exits the "initial national format" display mode.
191
198
  _showNationalFormat = false;
192
- handleParsePhoneNumber(value, country, combinedOptions.validateOn !== 'blur');
199
+ handleParsePhoneNumber(value, country, combinedOptions.validateOn !== 'blur', preParsed);
193
200
  };
194
201
 
195
202
  const getValidationError = (
@@ -267,7 +274,8 @@
267
274
  const handleParsePhoneNumber = (
268
275
  rawInput: string | null,
269
276
  currCountry: CountryCode | null = null,
270
- shouldValidate = true
277
+ shouldValidate = true,
278
+ preParsed?: PreParsed
271
279
  ) => {
272
280
  // Country-only change: reset state unless option says to keep valid.
273
281
  if (rawInput === null && currCountry !== null) {
@@ -354,8 +362,14 @@
354
362
  ? numberHasCountry
355
363
  : selectedCountry;
356
364
 
365
+ // Reuse the action's parse when it used the same country we resolved to;
366
+ // only the dial-code country-switch path needs a re-parse.
367
+ const canReuse =
368
+ preParsed !== undefined && (normalizerCountry?.iso2 ?? null) === preParsed.countryIso2;
357
369
  try {
358
- detailedValue = parsePhoneInput(rawInput, normalizerCountry);
370
+ detailedValue = canReuse
371
+ ? preParsed.detail
372
+ : parsePhoneInput(rawInput, normalizerCountry);
359
373
  } catch (err) {
360
374
  if (err instanceof ParseError) {
361
375
  detailedValue = {
@@ -394,17 +408,14 @@
394
408
  if (
395
409
  _showNationalFormat &&
396
410
  detailedValue?.formattedNumber &&
397
- detailedValue?.countryCallingCode
411
+ detailedValue?.countryCallingCode &&
412
+ detailedValue.formattedNumber.startsWith(`+${detailedValue.countryCallingCode} `)
398
413
  ) {
399
- const _prefix = `+${detailedValue.countryCallingCode} `;
400
- if (detailedValue.formattedNumber.startsWith(_prefix)) {
401
- const _national = detailedValue.formattedNumber.slice(_prefix.length);
402
- inputValue = combinedOptions.spaces ? _national : _national.replace(/\s/g, '');
403
- } else {
404
- inputValue = combinedOptions.spaces
405
- ? (detailedValue.formattedNumber ?? rawInput)
406
- : rawInput;
407
- }
414
+ inputValue = toNationalFormat(
415
+ detailedValue.formattedNumber,
416
+ detailedValue.countryCallingCode,
417
+ combinedOptions.spaces
418
+ );
408
419
  } else {
409
420
  inputValue = combinedOptions.spaces
410
421
  ? (detailedValue?.formattedNumber ?? rawInput)
@@ -447,14 +458,17 @@
447
458
  if (
448
459
  _showNationalFormat &&
449
460
  detailedValue.formattedNumber &&
450
- detailedValue.countryCallingCode
461
+ detailedValue.countryCallingCode &&
462
+ detailedValue.formattedNumber.startsWith(
463
+ `+${detailedValue.countryCallingCode} `
464
+ )
451
465
  ) {
452
- const prefix = `+${detailedValue.countryCallingCode} `;
453
- if (detailedValue.formattedNumber.startsWith(prefix)) {
454
- const national = detailedValue.formattedNumber.slice(prefix.length);
455
- inputValue = spaces ? national : national.replace(/\s/g, '');
456
- return;
457
- }
466
+ inputValue = toNationalFormat(
467
+ detailedValue.formattedNumber,
468
+ detailedValue.countryCallingCode,
469
+ spaces
470
+ );
471
+ return;
458
472
  }
459
473
  inputValue = spaces
460
474
  ? (detailedValue.formattedNumber ?? inputValue)
@@ -41,6 +41,16 @@ export interface DetailedValue {
41
41
  validationError: ValidationError;
42
42
  }
43
43
 
44
+ /**
45
+ * A parse result carried from the input action to the component, alongside the
46
+ * country it was parsed with, so the component can reuse it instead of parsing
47
+ * the same input a second time.
48
+ */
49
+ export interface PreParsed {
50
+ detail: DetailedValue;
51
+ countryIso2: CountryCode | null;
52
+ }
53
+
44
54
  /**
45
55
  * The reason the current phone number input is invalid.
46
56
  * - `'REQUIRED'` — field is empty and `required` is `true`
@@ -1,6 +1,6 @@
1
- import type { CountryCode } from '../../types/index.js';
1
+ import type { CountryCode, PreParsed } from '../../types/index.js';
2
2
  interface TelInputActionParams {
3
- handler: (val: string) => void;
3
+ handler: (val: string, preParsed?: PreParsed) => void;
4
4
  spaces: boolean;
5
5
  country: CountryCode | null | undefined;
6
6
  value: string;
@@ -1,21 +1,7 @@
1
1
  import { getCountryByIso2 } from '../countryHelpers.js';
2
- import { parsePhoneInput } from '../helpers.js';
2
+ import { parsePhoneInput, normalizeForLibphonenumber } from '../helpers.js';
3
3
  import { calculateCursorPosition } from '../cursorPosition.js';
4
4
  let inputState = null;
5
- const normalizeUserInput = (input) => {
6
- let value = '';
7
- for (let i = 0; i < input.length; i++) {
8
- const ch = input[i];
9
- if (ch >= '0' && ch <= '9') {
10
- value += ch;
11
- continue;
12
- }
13
- if (ch === '+' && value.length === 0) {
14
- value += ch;
15
- }
16
- }
17
- return value;
18
- };
19
5
  const isFormattingChar = (ch) => {
20
6
  if (!ch)
21
7
  return false;
@@ -108,7 +94,7 @@ const onInput = (event, params, node) => {
108
94
  };
109
95
  const userInput = node.value;
110
96
  const currentCursor = node.selectionStart ?? 0;
111
- const normalized = normalizeUserInput(userInput);
97
+ const normalized = normalizeForLibphonenumber(userInput);
112
98
  const countryObj = params.country ? getCountryByIso2(params.country) : undefined;
113
99
  const details = parsePhoneInput(normalized, countryObj);
114
100
  // Display formatting:
@@ -131,8 +117,9 @@ const onInput = (event, params, node) => {
131
117
  // Update value and cursor
132
118
  node.value = formattedInput;
133
119
  node.setSelectionRange(newPosition, newPosition);
134
- // Notify parent component
135
- params.handler(formattedInput);
120
+ // Notify parent component, passing the already-computed parse so the
121
+ // component can reuse it instead of re-parsing the same input.
122
+ params.handler(formattedInput, { detail: details, countryIso2: params.country ?? null });
136
123
  // Clear state
137
124
  inputState = null;
138
125
  };
@@ -4,6 +4,8 @@ export declare const generatePlaceholder: (country: CountryCode, { spaces, forma
4
4
  spaces: boolean;
5
5
  format?: "international" | "national";
6
6
  }) => string;
7
+ export declare const normalizeForLibphonenumber: (input: string) => string;
8
+ export declare const toNationalFormat: (internationalFormat: string, countryCallingCode: string, spaces: boolean) => string;
7
9
  export declare const parsePhoneInput: (input: string, country: Country | undefined) => DetailedValue;
8
10
  /**
9
11
  * Parse a raw phone number string into a `DetailedValue`.
@@ -8,12 +8,7 @@ export const generatePlaceholder = (country, { spaces, format } = {
8
8
  const examplePhoneNumber = getExampleNumber(country, examplePhoneNumbers);
9
9
  if (examplePhoneNumber) {
10
10
  if (format === 'national') {
11
- const international = examplePhoneNumber.formatInternational().trim();
12
- const prefix = `+${examplePhoneNumber.countryCallingCode} `;
13
- const national = international.startsWith(prefix)
14
- ? international.slice(prefix.length)
15
- : international;
16
- return spaces ? national : national.replace(/\s/g, '');
11
+ return toNationalFormat(examplePhoneNumber.formatInternational().trim(), examplePhoneNumber.countryCallingCode, spaces);
17
12
  }
18
13
  return spaces ? examplePhoneNumber.formatInternational().trim() : examplePhoneNumber.number;
19
14
  }
@@ -25,7 +20,7 @@ export const generatePlaceholder = (country, { spaces, format } = {
25
20
  // ---------------------------------------------------------------------------
26
21
  // Phone-number normalizer
27
22
  // ---------------------------------------------------------------------------
28
- const normalizeForLibphonenumber = (input) => {
23
+ export const normalizeForLibphonenumber = (input) => {
29
24
  let value = '';
30
25
  for (let i = 0; i < input.length; i++) {
31
26
  const ch = input[i];
@@ -82,6 +77,14 @@ const stripSpecialChars = (s) => s
82
77
  .replace(/[()-]/g, ' ')
83
78
  .replace(/\s{2,}/g, ' ')
84
79
  .trim();
80
+ const stripCallingCode = (internationalFormat, countryCallingCode) => internationalFormat.slice(countryCallingCode.length + 1).trim();
81
+ export const toNationalFormat = (internationalFormat, countryCallingCode, spaces) => {
82
+ const prefix = `+${countryCallingCode} `;
83
+ const national = internationalFormat.startsWith(prefix)
84
+ ? internationalFormat.slice(prefix.length)
85
+ : internationalFormat;
86
+ return spaces ? national : national.replace(/\s/g, '');
87
+ };
85
88
  export const parsePhoneInput = (input, country) => {
86
89
  const normalized = normalizeForLibphonenumber(input);
87
90
  const defaultCountryIso2 = country?.iso2;
@@ -109,13 +112,22 @@ export const parsePhoneInput = (input, country) => {
109
112
  else if (defaultCountryIso2) {
110
113
  const formatted = formatIncompletePhoneNumber(capped, defaultCountryIso2);
111
114
  if (formatted === capped && country?.dialCode) {
112
- if (phone && phone.nationalNumber !== capped) {
115
+ // Calling-code leak: libphonenumber consumed a leading digit that
116
+ // equals the country calling code as the code itself, so
117
+ // nationalNumber is shorter than what the user typed (e.g. US "11"
118
+ // → nationalNumber "1"). Those digits are national input, not a
119
+ // trunk prefix — fall through to the synthesis branch below so the
120
+ // typed digit is preserved instead of being sliced off.
121
+ const isCallingCodeLeak = !!countryCallingCode &&
122
+ capped.startsWith(countryCallingCode) &&
123
+ phone?.nationalNumber === capped.slice(countryCallingCode.length);
124
+ if (phone && phone.nationalNumber !== capped && !isCallingCodeLeak) {
113
125
  // Trunk-prefixed complete number (e.g. GB "07947…" → nationalNumber
114
126
  // "7947…"). Synthesis would be invalid; use the actual international
115
127
  // format which correctly omits the trunk prefix.
116
128
  formattedNumber =
117
129
  formatInternational && countryCallingCode
118
- ? formatInternational.slice(countryCallingCode.length + 1).trim()
130
+ ? stripCallingCode(formatInternational, countryCallingCode)
119
131
  : stripSpecialChars(capped);
120
132
  }
121
133
  else {
@@ -123,7 +135,7 @@ export const parsePhoneInput = (input, country) => {
123
135
  // Synthesize an international string to get consistent spacing from
124
136
  // the first digit, then strip "+{dialCode} ".
125
137
  const intl = formatIncompletePhoneNumber(`+${country.dialCode}${capped}`);
126
- formattedNumber = stripSpecialChars(intl.slice(country.dialCode.length + 1).trim());
138
+ formattedNumber = stripSpecialChars(stripCallingCode(intl, country.dialCode));
127
139
  }
128
140
  }
129
141
  else {
@@ -151,7 +163,7 @@ export const parsePhoneInput = (input, country) => {
151
163
  formatInternational,
152
164
  formatNational,
153
165
  formatOriginal: formatInternational && countryCallingCode
154
- ? formatInternational.slice(countryCallingCode.length + 1).trim()
166
+ ? stripCallingCode(formatInternational, countryCallingCode)
155
167
  : null,
156
168
  formattedNumber,
157
169
  isPossible: asYouType.isPossible(),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "svelte-tel-input",
3
3
  "description": "svelte-tel-input",
4
- "version": "4.3.0",
4
+ "version": "4.3.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/gyurielf/svelte-tel-input.git"
@@ -30,33 +30,33 @@
30
30
  "svelte": "^5.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "libphonenumber-js": "1.13.6"
33
+ "libphonenumber-js": "1.13.7"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@sveltejs/adapter-auto": "7.0.1",
37
- "@sveltejs/kit": "^2.57.1",
37
+ "@sveltejs/kit": "^2.67.0",
38
38
  "@sveltejs/package": "^2.5.8",
39
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
39
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
40
40
  "@tailwindcss/vite": "^4.3.1",
41
41
  "@testing-library/jest-dom": "6.9.1",
42
- "@testing-library/svelte": "^5.3.1",
42
+ "@testing-library/svelte": "^5.4.2",
43
43
  "@testing-library/user-event": "^14.6.1",
44
44
  "@types/micromatch": "^4.0.10",
45
45
  "@types/node": "^22.17.0",
46
46
  "dotenv": "^17.4.2",
47
47
  "jsdom": "^29.1.1",
48
48
  "micromatch": "^4.0.8",
49
- "postcss": "^8.5.10",
49
+ "postcss": "^8.5.15",
50
50
  "publint": "^0.3.21",
51
- "svelte": "^5.55.4",
52
- "svelte-check": "^4.6.0",
53
- "svelte2tsx": "^0.7.56",
51
+ "svelte": "^5.56.4",
52
+ "svelte-check": "^4.7.1",
53
+ "svelte2tsx": "^0.7.57",
54
54
  "tailwindcss": "^4.3.1",
55
55
  "tslib": "^2.8.1",
56
- "typescript": "^5.9.3",
56
+ "typescript": "^6.0.3",
57
57
  "valibot": "^1.4.1",
58
- "vite": "^7.3.2",
59
- "vitest": "^4.1.5",
58
+ "vite": "^8.1.0",
59
+ "vitest": "^4.1.9",
60
60
  "zod": "^4.4.3"
61
61
  },
62
62
  "type": "module",