ngxsmk-tel-input 1.0.8 → 1.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.
package/README.md CHANGED
@@ -1,51 +1,52 @@
1
- # ngxsmk-tel-input — International Telephone Input for Angular
1
+ # ngxsmk-tel-input
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/ngxsmk-tel-input)](https://www.npmjs.com/package/ngxsmk-tel-input)
4
- [![npm downloads](https://img.shields.io/npm/dm/ngxsmk-tel-input)](https://www.npmjs.com/package/ngxsmk-tel-input)
5
- [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/toozuuu/ngxsmk-tel-input/blob/main/LICENSE)
6
- [![GitHub stars](https://img.shields.io/github/stars/toozuuu/ngxsmk-tel-input?style=social)](https://github.com/toozuuu/ngxsmk-tel-input)
3
+ An Angular **telephone input** component with country dropdown, flags, and robust validation/formatting.
4
+ Wraps [`intl-tel-input`](https://github.com/jackocnr/intl-tel-input) for the UI and [`libphonenumber-js`](https://github.com/catamphetamine/libphonenumber-js) for parsing/validation. Implements `ControlValueAccessor` so it plugs into Angular Forms.
7
5
 
8
- An Angular component for entering and validating **international telephone numbers**. It adds a country flag dropdown, formats input, and validates using real numbering rules.
9
-
10
- * UI powered by [`intl-tel-input`](https://github.com/jackocnr/intl-tel-input)
11
- * Parsing & validation via [`libphonenumber-js`](https://github.com/catamphetamine/libphonenumber-js)
12
- * Implements Angular **ControlValueAccessor** (works with Reactive & Template‑driven Forms)
13
-
14
- > Emits **E.164** by default (e.g. `+14155550123`). SSR‑safe via lazy, browser‑only import.
6
+ > Emits **E.164** by default (e.g. `+14155550123`). SSR‑safe via lazy browser‑only import.
15
7
 
16
8
  ---
17
9
 
18
10
  ## Screenshots
19
11
 
20
12
  <p align="left">
21
- <img src="https://unpkg.com/ngxsmk-tel-input@1.0.4/docs/valid.png" alt="Angular international phone input - valid" width="420" />
13
+ <img src="https://unpkg.com/ngxsmk-tel-input@latest/docs/valid.png" alt="Angular international phone input - valid" width="420" />
22
14
  &nbsp;&nbsp;
23
- <img src="https://unpkg.com/ngxsmk-tel-input@1.0.4/docs/invalid.png" alt="Angular international phone input - Invalid" width="420" />
15
+ <img src="https://unpkg.com/ngxsmk-tel-input@latest/docs/invalid.png" alt="Angular international phone input - Invalid" width="420" />
24
16
  </p>
25
17
 
26
18
  ---
27
19
 
28
- ## Compatibility
29
-
30
- | ngxsmk-tel-input | Angular |
31
- |------------------| --------------- |
32
- | `1.0.x` | `17.x` – `19.x` |
20
+ ## ✨ Features
33
21
 
34
- > The library declares `peerDependencies` of `@angular/* >=17 <20`.
22
+ * Country dropdown with flags
23
+ * E.164 output (display can be national with `nationalMode`)
24
+ * Reactive & template‑driven Forms support (CVA)
25
+ * Built‑in validation using libphonenumber‑js
26
+ * SSR‑friendly (no `window` on the server)
27
+ * Easy theming via CSS variables
28
+ * Nice UX options: label/hint/error text, sizes, variants, clear button, autofocus, select-on-focus
35
29
 
36
30
  ---
37
31
 
38
- ## Installation
32
+ ## ✅ Requirements
33
+
34
+ * Angular **17 – 19**
35
+ * Node **18** or **20**
36
+
37
+ > Library `peerDependencies` target Angular `>=17 <20`. Your app can be 17, 18, or 19.
38
+
39
+ ---
39
40
 
40
- ### 1) Install dependencies
41
+ ## 📦 Install
41
42
 
42
43
  ```bash
43
44
  npm i ngxsmk-tel-input intl-tel-input libphonenumber-js
44
45
  ```
45
46
 
46
- ### 2) Add styles & flag assets (in your **app**, not the library)
47
+ ### Add styles & flag assets (in your **app**, not the library)
47
48
 
48
- Add intl‑tel‑input CSS and copy its flag images via `angular.json`:
49
+ Update your app’s `angular.json`:
49
50
 
50
51
  ```jsonc
51
52
  {
@@ -55,11 +56,9 @@ Add intl‑tel‑input CSS and copy its flag images via `angular.json`:
55
56
  "build": {
56
57
  "options": {
57
58
  "styles": [
58
- "node_modules/intl-tel-input/build/css/intlTelInput.css",
59
- "src/styles.css"
59
+ "node_modules/intl-tel-input/build/css/intlTelInput.css"
60
60
  ],
61
61
  "assets": [
62
- { "glob": "**/*", "input": "src/assets" },
63
62
  { "glob": "**/*", "input": "node_modules/intl-tel-input/build/img", "output": "assets/intl-tel-input/img" }
64
63
  ]
65
64
  }
@@ -70,7 +69,7 @@ Add intl‑tel‑input CSS and copy its flag images via `angular.json`:
70
69
  }
71
70
  ```
72
71
 
73
- Optional (helps some bundlers resolve flags): add to your global styles
72
+ Optional override to ensure flags resolve (e.g., Vite/Angular 17+): add to your global styles
74
73
 
75
74
  ```css
76
75
  .iti__flag { background-image: url("/assets/intl-tel-input/img/flags.png"); }
@@ -79,197 +78,276 @@ Optional (helps some bundlers resolve flags): add to your global styles
79
78
  }
80
79
  ```
81
80
 
82
- Restart your dev server after editing `angular.json`.
81
+ Restart the dev server after changes.
83
82
 
84
83
  ---
85
84
 
86
- ## Usage
87
-
88
- ### Reactive Forms
85
+ ## 🚀 Quick start (Reactive Forms)
89
86
 
90
87
  ```ts
91
88
  // app.component.ts
92
- import { Component } from '@angular/core';
93
- import { ReactiveFormsModule, FormBuilder, Validators, FormGroup } from '@angular/forms';
94
- import { CommonModule } from '@angular/common';
95
- import { NgxsmkTelInputComponent } from '../../../ngxsmk-tel-input/src/lib/ngxsmk-tel-input.component';
89
+ import { Component, inject } from '@angular/core';
90
+ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
91
+ import { JsonPipe } from '@angular/common';
92
+ import { NgxsmkTelInputComponent, IntlTelI18n, CountryMap } from 'ngxsmk-tel-input';
96
93
 
97
94
  @Component({
98
95
  selector: 'app-root',
99
96
  standalone: true,
100
- imports: [ReactiveFormsModule, CommonModule, NgxsmkTelInputComponent],
97
+ imports: [ReactiveFormsModule, NgxsmkTelInputComponent, JsonPipe],
101
98
  template: `
102
99
  <form [formGroup]="fg" style="max-width:420px;display:grid;gap:12px">
103
100
  <ngxsmk-tel-input
104
101
  formControlName="phone"
105
102
  label="Phone"
106
103
  hint="Include area code"
104
+ dir="ltr"
107
105
  [initialCountry]="'US'"
108
106
  [preferredCountries]="['US','GB','AU']"
109
- (countryChange)="onCountry($event)">
107
+ [i18n]="enLabels"
108
+ [localizedCountries]="enCountries"
109
+ [autoPlaceholder]="'off'"
110
+ [clearAriaLabel]="'Clear phone number'">
110
111
  </ngxsmk-tel-input>
112
+
111
113
  <pre>Value: {{ fg.value | json }}</pre>
112
114
  </form>
113
115
  `
114
116
  })
115
117
  export class AppComponent {
116
- fg: FormGroup;
117
-
118
- constructor(private readonly fb: FormBuilder) {
119
- this.fg = this.fb.group({
120
- phone: ['', Validators.required]
121
- });
122
- }
123
-
124
- onCountry(e: { iso2: any }) { console.log('Country changed:', e.iso2); }
125
-
118
+ private readonly fb = inject(FormBuilder);
119
+ fg = this.fb.group({ phone: ['', Validators.required] });
120
+
121
+ // English UI labels (dropdown/search/ARIA)
122
+ enLabels: IntlTelI18n = {
123
+ selectedCountryAriaLabel: 'Selected country',
124
+ countryListAriaLabel: 'Country list',
125
+ searchPlaceholder: 'Search country',
126
+ zeroSearchResults: 'No results',
127
+ noCountrySelected: 'No country selected'
128
+ };
129
+
130
+ // Optional: only override the names you care about
131
+ enCountries: CountryMap = {
132
+ US: 'United States',
133
+ GB: 'United Kingdom',
134
+ AU: 'Australia',
135
+ CA: 'Canada'
136
+ };
126
137
  }
138
+
127
139
  ```
128
140
 
129
- **Value semantics:** The control emits **E.164** when valid, or `null` when empty/invalid.
141
+ **Value semantics:** the form control value is **E.164** (e.g., `+14155550123`) when valid, or `null` when empty/invalid.
142
+
143
+ ---
130
144
 
131
- ### Template‑driven
145
+ ## 📝 Template‑driven usage
132
146
 
133
147
  ```html
134
148
  <form #f="ngForm">
135
149
  <ngxsmk-tel-input name="phone" [(ngModel)]="phone"></ngxsmk-tel-input>
136
150
  </form>
151
+ <!-- phone is an E.164 string or null -->
137
152
  ```
138
153
 
139
154
  ---
140
155
 
141
- ## Options (Inputs)
142
-
143
- | Option | Type | Default | Description |
144
- | ---------------------- | -------------------------------------- | ---------------------- | ---------------------------------------------------------------------- |
145
- | `initialCountry` | `CountryCode \| 'auto'` | `'US'` | Starting country. `'auto'` uses a simple geo‑IP stub (defaults to US). |
146
- | `preferredCountries` | `CountryCode[]` | `['US','GB']` | Countries pinned at the top. |
147
- | `onlyCountries` | `CountryCode[]` | — | Restrict selectable countries. |
148
- | `nationalMode` | `boolean` | `false` | Display national format in the box. Value still emits E.164. |
149
- | `separateDialCode` | `boolean` | `false` | Show dial code separately to the left. |
150
- | `allowDropdown` | `boolean` | `true` | Enable/disable country dropdown. |
151
- | `placeholder` | `string` | `'Enter phone number'` | Input placeholder. |
152
- | `autocomplete` | `string` | `'tel'` | Native autocomplete attribute. |
153
- | `disabled` | `boolean` | `false` | Disable the control. |
154
- | `label` | `string` | — | Optional label text. |
155
- | `hint` | `string` | — | Helper text shown below. |
156
- | `errorText` | `string` | — | Custom error message. |
157
- | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Preset sizing. |
158
- | `variant` | `'outline' \| 'filled' \| 'underline'` | `'outline'` | Visual style. |
159
- | `showClear` | `boolean` | `true` | Show a clear (×) button when not empty. |
160
- | `autoFocus` | `boolean` | `false` | Focus on init. |
161
- | `selectOnFocus` | `boolean` | `false` | Select text on focus. |
162
- | `formatOnBlur` | `boolean` | `true` | Pretty‑print on blur (national if `nationalMode`). |
163
- | `showErrorWhenTouched` | `boolean` | `true` | Only show error styles after the control is touched. |
164
- | `dropdownAttachToBody` | `boolean` | `true` | Attach dropdown to `<body>` to avoid clipping. |
165
- | `dropdownZIndex` | `number` | `2000` | Z‑index for dropdown panel. |
166
-
167
- > `CountryCode` is the ISO‑2 code from `libphonenumber-js` (e.g. `US`, `GB`).
168
-
169
- ### Outputs (Events)
170
-
171
- | Event | Payload | Description |
172
- | ---------------- | ---------------------------------------------------------- | ---------------------------------------- |
173
- | `countryChange` | `{ iso2: CountryCode }` | Fires when the selected country changes. |
174
- | `validityChange` | `boolean` | Emits when validity toggles. |
175
- | `inputChange` | `{ raw: string; e164: string \| null; iso2: CountryCode }` | Emitted on each input. |
176
-
177
- ### Public Methods
156
+ ## 🈺 Localization & RTL
178
157
 
179
- * `focus(): void`
180
- * `selectCountry(iso2: CountryCode): void`
158
+ You can localize the dropdown/search labels and override country names.
181
159
 
182
- ---
160
+ <img src="https://unpkg.com/ngxsmk-tel-input@latest/docs/kr.png" alt="Angular international phone input - Korean Localization & RTL" width="420" />
161
+
162
+ Korean example
163
+
164
+ ```ts
165
+
166
+ <ngxsmk-tel-input
167
+ [initialCountry]="'KR'"
168
+ [preferredCountries]="['KR','US','JP']"
169
+ [i18n]="koLabels"
170
+ [localizedCountries]="koCountries">
171
+ </ngxsmk-tel-input>
172
+
173
+ // in component
174
+ koLabels = {
175
+ selectedCountryAriaLabel: '선택한 국가',
176
+ countryListAriaLabel: '국가 목록',
177
+ searchPlaceholder: '국가 검색',
178
+ zeroSearchResults: '결과 없음',
179
+ noCountrySelected: '선택된 국가 없음'
180
+ };
181
+
182
+ koCountries = {
183
+ KR: '대한민국',
184
+ US: '미국',
185
+ JP: '일본',
186
+ CN: '중국'
187
+ };
188
+
189
+ ```
190
+
191
+ Arabic + RTL example
192
+
193
+ ```ts
194
+ <ngxsmk-tel-input
195
+ dir="rtl"
196
+ label="الهاتف"
197
+ hint="اكتب رمز المنطقة"
198
+ [initialCountry]="'AE'"
199
+ [preferredCountries]="['AE','SA','EG']"
200
+ [i18n]="arLabels"
201
+ [localizedCountries]="arCountries"
202
+ [dropdownAttachToBody]="false" <!-- so popup inherits rtl from host -->
203
+ ></ngxsmk-tel-input>
183
204
 
184
- ## Supported Formats
205
+ ```
185
206
 
186
- * **E164** → `+41446681800`
187
- * **International** (display) → `+41 44 668 18 00`
188
- * **National** (display) → `044 668 18 00`
189
207
 
190
- > The control **emits E.164** by default. If you need the currently typed format too, use `(inputChange)`.
208
+ ## ⚙️ API
209
+
210
+ ### Inputs
211
+
212
+ | Name | Type | Default | Description |
213
+ |------------------------|---------------------------------------------|-------------------------|-------------------------------------------------------------------------------|
214
+ | `initialCountry` | `CountryCode \| 'auto'` | `'US'` | Starting country. `'auto'` uses geoIp stub (`US` by default). |
215
+ | `preferredCountries` | `CountryCode[]` | `['US','GB']` | Pin these at the top. |
216
+ | `onlyCountries` | `CountryCode[]` | — | Limit selectable countries. |
217
+ | `nationalMode` | `boolean` | `false` | If `true`, **display** national format in the input. Value still emits E.164. |
218
+ | `separateDialCode` | `boolean` | `false` | Show dial code outside the input. |
219
+ | `allowDropdown` | `boolean` | `true` | Enable/disable dropdown. |
220
+ | `placeholder` | `string` | `'Enter phone number'` | Input placeholder. |
221
+ | `autocomplete` | `string` | `'tel'` | Native autocomplete. |
222
+ | `disabled` | `boolean` | `false` | Disable the control. |
223
+ | `label` | `string` | — | Optional floating label text. |
224
+ | `hint` | `string` | — | Helper text below the control. |
225
+ | `errorText` | `string` | — | Custom error text. |
226
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Control height/typography. |
227
+ | `variant` | `'outline' \| 'filled' \| 'underline'` | `'outline'` | Visual variant. |
228
+ | `showClear` | `boolean` | `true` | Show a clear (×) button when not empty. |
229
+ | `autoFocus` | `boolean` | `false` | Focus on init. |
230
+ | `selectOnFocus` | `boolean` | `false` | Select all text on focus. |
231
+ | `formatOnBlur` | `boolean` | `true` | Pretty‑print on blur (national if `nationalMode`). |
232
+ | `showErrorWhenTouched` | `boolean` | `true` | Show error styles only after blur. |
233
+ | `dropdownAttachToBody` | `boolean` | `true` | Attach dropdown to `<body>` (avoids clipping/overflow). |
234
+ | `dropdownZIndex` | `number` | `2000` | Z‑index for dropdown panel. |
235
+ | `i18n` | `IntlTelI18n` | — | Localize dropdown/search/ARIA labels. |
236
+ | `localizedCountries` | `Partial<Record<CountryCode, string>>` | — | Override country display names (ISO-2 keys). |
237
+ | `dir` | `'ltr' \| 'rtl'` | `'ltr'` | Text direction for the control. |
238
+ | `autoPlaceholder` | `'off' \| 'polite' \| 'aggressive'` | `'polite'` | Example placeholders. Requires `utilsScript` unless `off`. |
239
+ | `utilsScript` | `string` | — | Path/URL to `utils.js` (needed for example placeholders). |
240
+ | `customPlaceholder` | `(example: string, country: any) => string` | — | Transform the example placeholder. |
241
+ | `clearAriaLabel` | `string` | `'Clear phone number'` | ARIA label for the clear button. |
242
+
243
+ > `CountryCode` is the ISO‑2 uppercase code from `libphonenumber-js` (e.g. `US`, `GB`).
244
+
245
+ ### Outputs
246
+
247
+ | Event | Payload | Description |
248
+ | ---------------- | ---------------------------------------------------------- | ------------------------------------ |
249
+ | `countryChange` | `{ iso2: CountryCode }` | Fired when selected country changes. |
250
+ | `validityChange` | `boolean` | Fired when validity flips. |
251
+ | `inputChange` | `{ raw: string; e164: string \| null; iso2: CountryCode }` | Emitted on every keystroke. |
252
+
253
+ ### Public methods
254
+
255
+ * `focus(): void`
256
+ * `selectCountry(iso2: CountryCode): void`
191
257
 
192
258
  ---
193
259
 
194
- ## Theming & Styling
260
+ ## 🎨 Theming (CSS variables)
195
261
 
196
- This component exposes CSS variables for quick theming:
262
+ Override on the element or a parent container:
197
263
 
198
264
  ```html
199
265
  <ngxsmk-tel-input style="
200
266
  --tel-border:#cbd5e1;
201
- --tel-ring:#2563eb;
202
- --tel-radius:12px;
203
- --tel-dd-item-hover: rgba(37,99,235,.08);
267
+ --tel-ring:#22c55e;
268
+ --tel-radius:14px;
269
+ --tel-dd-item-hover: rgba(34,197,94,.12);
204
270
  --tel-dd-z: 3000;
205
271
  "></ngxsmk-tel-input>
206
272
  ```
207
273
 
208
- Key variables: `--tel-bg`, `--tel-fg`, `--tel-border`, `--tel-border-hover`, `--tel-ring`, `--tel-placeholder`, `--tel-error`, `--tel-radius`, `--tel-focus-shadow`, `--tel-dd-bg`, `--tel-dd-border`, `--tel-dd-shadow`, `--tel-dd-radius`, `--tel-dd-item-hover`, `--tel-dd-search-bg`, `--tel-dd-z`.
274
+ Available tokens:
275
+
276
+ * Input: `--tel-bg`, `--tel-fg`, `--tel-border`, `--tel-border-hover`, `--tel-ring`, `--tel-placeholder`, `--tel-error`, `--tel-radius`, `--tel-focus-shadow`
277
+ * Dropdown: `--tel-dd-bg`, `--tel-dd-border`, `--tel-dd-shadow`, `--tel-dd-radius`, `--tel-dd-item-hover`, `--tel-dd-search-bg`, `--tel-dd-z`
278
+
279
+ Dark mode: wrap in a `.dark` parent — tokens adapt automatically.
280
+
281
+ ---
282
+
283
+ ## ✔️ Validation patterns
284
+
285
+ ```html
286
+ <ngxsmk-tel-input formControlName="phone"></ngxsmk-tel-input>
287
+
288
+ <div class="error" *ngIf="fg.get('phone')?.hasError('required')">Phone is required</div>
289
+ <div class="error" *ngIf="fg.get('phone')?.hasError('phoneInvalid')">Please enter a valid phone number</div>
290
+ ```
291
+
292
+ * When **valid** → control value = **E.164** string
293
+ * When **invalid/empty** → value = **null**, and validator sets `{ phoneInvalid: true }`
209
294
 
210
- Dark mode: wrap in a `.dark` container; tokens adapt.
295
+ > Need national string instead of E.164? Use `(inputChange)` and store `raw`/`national` yourself, or adapt the emitter to output national.
211
296
 
212
297
  ---
213
298
 
214
- ## SSR
299
+ ## 🌐 SSR notes
215
300
 
216
- The library guards `intl-tel-input` import behind `isPlatformBrowser`, so Angular Universal builds won’t try to access `window` during SSR.
301
+ * The library lazy‑imports `intl-tel-input` only in the **browser** (guards with `isPlatformBrowser`).
302
+ * No `window`/`document` usage on the server path.
217
303
 
218
304
  ---
219
305
 
220
- ## Local Development
306
+ ## 🧪 Local development
221
307
 
222
- This repository is an Angular workspace with a library.
308
+ This repo is an Angular workspace with a library.
223
309
 
224
310
  ```bash
225
311
  # Build the library
226
312
  ng build ngxsmk-tel-input
227
313
 
228
- # Try it in a demo app inside the workspace
314
+ # Option A: use it inside a demo app in the same workspace
229
315
  ng serve demo
230
316
 
231
- # Or pack and install in another app locally
317
+ # Option B: install locally via tarball in another app
232
318
  cd dist/ngxsmk-tel-input && npm pack
233
-
234
319
  # in your other app
235
- npm i ../path/to/dist/ngxsmk-tel-input/ngxsmk-tel-input-<version>.tgz
320
+ npm i ../path-to-workspace/dist/ngxsmk-tel-input/ngxsmk-tel-input-<version>.tgz
236
321
  ```
237
322
 
238
- Tip: you can also map a workspace path alias in `tsconfig.base.json`:
239
-
240
- ```jsonc
241
- {
242
- "compilerOptions": {
243
- "paths": {
244
- "ngxsmk-tel-input": ["dist/ngxsmk-tel-input"]
245
- }
246
- }
247
- }
248
- ```
323
+ > Workspace aliasing via `tsconfig.paths` also works (map `"ngxsmk-tel-input": ["dist/ngxsmk-tel-input"]`).
249
324
 
250
325
  ---
251
326
 
252
- ## Troubleshooting
327
+ ## 🧯 Troubleshooting
253
328
 
254
- * **Unstyled dropdown / bullets / missing flags** → Ensure CSS & assets are added in your app’s `angular.json`, then restart the dev server.
255
- * **`TS2307: Cannot find module 'ngxsmk-tel-input'`** → Build the library first so `dist/ngxsmk-tel-input` exists (or install from npm).
256
- * **Peer dependency conflict** → Your app must be Angular 17–19 to satisfy peers.
257
- * **Dropdown clipped by parent** → Keep `dropdownAttachToBody=true` or raise `dropdownZIndex`.
258
-
259
- ---
329
+ **UI looks unstyled / bullets in dropdown**
330
+ Add the CSS and assets in `angular.json` (see Install). Restart the dev server.
260
331
 
261
- ## Contributing
332
+ **Flags don’t show**
333
+ Ensure the assets copy exists under `/assets/intl-tel-input/img` and add the CSS override block above.
262
334
 
263
- PRs welcome! Please:
335
+ **`TS2307: Cannot find module 'ngxsmk-tel-input'`**
336
+ Build the library first so `dist/ngxsmk-tel-input` exists. If using workspace aliasing, add a `paths` entry to the root `tsconfig.base.json`.
264
337
 
265
- 1. `npm ci` and `ng build`
266
- 2. Cover behavior changes with tests if possible
267
- 3. Update README for any API changes
338
+ **Peer dependency conflict when installing**
339
+ The lib peers are `@angular/* >=17 <20`. Upgrade your app or install a compatible version.
268
340
 
269
- This project is open to using the [all-contributors](https://github.com/all-contributors/all-contributors) spec. Contributions of any kind welcome!
341
+ **Vite/Angular “Failed to resolve import …”**
342
+ Clear `.angular/cache`, rebuild the lib, and restart `ng serve`.
270
343
 
271
344
  ---
272
345
 
273
- ## License
346
+ ## 📃 License
274
347
 
275
348
  [MIT](./LICENSE)
349
+
350
+ ## 🙌 Credits
351
+
352
+ * UI powered by [`intl-tel-input`](https://github.com/jackocnr/intl-tel-input)
353
+ * Parsing & validation by [`libphonenumber-js`](https://github.com/catamphetamine/libphonenumber-js)
package/docs/kr.png ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ngxsmk-tel-input",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Angular international telephone input with country flag dropdown, formatting & validation (intl-tel-input + libphonenumber). ControlValueAccessor. Supports Angular 17–19.",
5
5
  "keywords": [
6
6
  "angular",
@@ -20,19 +20,33 @@ import {
20
20
  ControlValueAccessor,
21
21
  NG_VALIDATORS,
22
22
  NG_VALUE_ACCESSOR,
23
- ValidationErrors
23
+ ValidationErrors,
24
+ Validator
24
25
  } from '@angular/forms';
25
26
  import type {CountryCode} from 'libphonenumber-js';
26
27
  import {NgxsmkTelInputService} from './ngxsmk-tel-input.service';
27
28
 
28
29
  type IntlTelInstance = any;
30
+ export type CountryMap = Partial<Record<CountryCode, string>>;
31
+
32
+ export interface IntlTelI18n {
33
+ selectedCountryAriaLabel?: string;
34
+ countryListAriaLabel?: string;
35
+ searchPlaceholder?: string;
36
+ zeroSearchResults?: string;
37
+ noCountrySelected?: string;
38
+ }
29
39
 
30
40
  @Component({
31
41
  selector: 'ngxsmk-tel-input',
32
42
  standalone: true,
33
43
  imports: [],
34
44
  template: `
35
- <div class="ngxsmk-tel" [class.disabled]="disabled" [attr.data-size]="size" [attr.data-variant]="variant">
45
+ <div class="ngxsmk-tel"
46
+ [class.disabled]="disabled"
47
+ [attr.data-size]="size"
48
+ [attr.data-variant]="variant"
49
+ [attr.dir]="dir">
36
50
  @if (label) {
37
51
  <label class="ngxsmk-tel__label" [for]="resolvedId">{{ label }}</label>
38
52
  }
@@ -47,6 +61,8 @@ type IntlTelInstance = any;
47
61
  [attr.name]="name || null"
48
62
  [attr.placeholder]="placeholder || null"
49
63
  [attr.autocomplete]="autocomplete"
64
+ [attr.inputmode]="digitsOnly ? 'numeric' : 'tel'"
65
+ [attr.pattern]="digitsOnly ? (allowLeadingPlus ? '\\\\+?[0-9]*' : '[0-9]*') : null"
50
66
  [disabled]="disabled"
51
67
  [attr.aria-invalid]="showError ? 'true' : 'false'"
52
68
  (blur)="onBlur()"
@@ -58,11 +74,10 @@ type IntlTelInstance = any;
58
74
  <button type="button"
59
75
  class="ngxsmk-tel__clear"
60
76
  (click)="clearInput()"
61
- [attr.aria-label]="'Clear phone number'">
77
+ [attr.aria-label]="clearAriaLabel">
62
78
  ×
63
79
  </button>
64
80
  }
65
-
66
81
  </div>
67
82
 
68
83
  @if (hint && !showError) {
@@ -72,7 +87,6 @@ type IntlTelInstance = any;
72
87
  @if (showError) {
73
88
  <div class="ngxsmk-tel__error">{{ errorText || 'Please enter a valid phone number.' }}</div>
74
89
  }
75
-
76
90
  </div>
77
91
  `,
78
92
  providers: [
@@ -92,7 +106,6 @@ type IntlTelInstance = any;
92
106
  --tel-radius: 12px;
93
107
  --tel-focus-shadow: 0 0 0 3px rgba(37, 99, 235, .25);
94
108
 
95
- /* dropdown tokens */
96
109
  --tel-dd-bg: var(--tel-bg);
97
110
  --tel-dd-border: var(--tel-border);
98
111
  --tel-dd-shadow: 0 24px 60px rgba(0, 0, 0, .18);
@@ -102,7 +115,6 @@ type IntlTelInstance = any;
102
115
  --tel-dd-search-bg: rgba(148, 163, 184, .08);
103
116
 
104
117
  display: block;
105
-
106
118
  }
107
119
 
108
120
  :host-context(.dark) {
@@ -140,8 +152,7 @@ type IntlTelInstance = any;
140
152
  position: relative;
141
153
  }
142
154
 
143
- .ngxsmk-tel-input__wrapper,
144
- :host ::ng-deep .iti {
155
+ .ngxsmk-tel-input__wrapper, :host ::ng-deep .iti {
145
156
  width: 100%;
146
157
  }
147
158
 
@@ -153,9 +164,9 @@ type IntlTelInstance = any;
153
164
  background: var(--tel-bg);
154
165
  border: 1px solid var(--tel-border);
155
166
  border-radius: var(--tel-radius);
156
- padding: 10px 40px 10px 12px; /* room for clear button */
167
+ padding: 10px 40px 10px 12px;
157
168
  outline: none;
158
- transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
169
+ transition: border-color .15s, box-shadow .15s, background .15s;
159
170
  }
160
171
 
161
172
  .ngxsmk-tel-input__control::placeholder {
@@ -171,7 +182,6 @@ type IntlTelInstance = any;
171
182
  box-shadow: var(--tel-focus-shadow);
172
183
  }
173
184
 
174
- /* Size presets */
175
185
  [data-size="sm"] .ngxsmk-tel-input__control {
176
186
  height: 34px;
177
187
  font-size: 13px;
@@ -186,7 +196,6 @@ type IntlTelInstance = any;
186
196
  border-radius: 14px;
187
197
  }
188
198
 
189
- /* Variants */
190
199
  [data-variant="filled"] .ngxsmk-tel-input__control {
191
200
  background: rgba(148, 163, 184, .08);
192
201
  }
@@ -220,7 +229,6 @@ type IntlTelInstance = any;
220
229
  align-items: center;
221
230
  }
222
231
 
223
- /* Core dropdown panel */
224
232
  :host ::ng-deep .iti__country-list {
225
233
  background: var(--tel-dd-bg);
226
234
  border: 1px solid var(--tel-dd-border);
@@ -233,12 +241,10 @@ type IntlTelInstance = any;
233
241
  z-index: var(--tel-dd-z);
234
242
  }
235
243
 
236
- /* When attached to <body>, it's wrapped in .iti--container */
237
244
  :host ::ng-deep .iti--container .iti__country-list {
238
245
  z-index: var(--tel-dd-z);
239
246
  }
240
247
 
241
- /* Search input (sticky header) */
242
248
  :host ::ng-deep .iti__search-input {
243
249
  position: sticky;
244
250
  top: 0;
@@ -252,11 +258,6 @@ type IntlTelInstance = any;
252
258
  color: var(--tel-fg);
253
259
  }
254
260
 
255
- :host ::ng-deep .iti__search-input::placeholder {
256
- color: var(--tel-placeholder);
257
- }
258
-
259
- /* Rows: flag | country name | dial code (right) */
260
261
  :host ::ng-deep .iti__country {
261
262
  display: grid;
262
263
  grid-template-columns: 28px 1fr auto;
@@ -266,65 +267,12 @@ type IntlTelInstance = any;
266
267
  cursor: pointer;
267
268
  }
268
269
 
269
- :host ::ng-deep .iti__flag-box {
270
- width: 28px;
271
- display: inline-flex;
272
- justify-content: center;
273
- }
274
-
275
- :host ::ng-deep .iti__country-name {
276
- color: var(--tel-fg);
277
- }
278
-
279
270
  :host ::ng-deep .iti__dial-code {
280
271
  color: var(--tel-placeholder);
281
272
  font-weight: 600;
282
273
  margin-left: 10px;
283
274
  }
284
275
 
285
- :host ::ng-deep .iti__country:hover,
286
- :host ::ng-deep .iti__country.iti__highlight {
287
- background: var(--tel-dd-item-hover);
288
- }
289
-
290
- :host ::ng-deep .iti__country:focus {
291
- outline: 2px solid var(--tel-ring);
292
- outline-offset: -2px;
293
- }
294
-
295
- :host ::ng-deep .iti__divider {
296
- margin: 6px 0;
297
- border-top: 1px dashed var(--tel-dd-border);
298
- }
299
-
300
- /* Separate dial code pushes input text */
301
- :host ::ng-deep .iti--separate-dial-code .ngxsmk-tel-input__control {
302
- padding-left: 56px;
303
- }
304
-
305
- /* Custom scrollbar (WebKit/Chromium) */
306
- :host ::ng-deep .iti__country-list::-webkit-scrollbar {
307
- width: 10px;
308
- }
309
-
310
- :host ::ng-deep .iti__country-list::-webkit-scrollbar-thumb {
311
- background: rgba(148, 163, 184, .4);
312
- border-radius: 8px;
313
- }
314
-
315
- :host ::ng-deep .iti__country-list::-webkit-scrollbar-track {
316
- background: transparent;
317
- }
318
-
319
- /* Mobile tweak: make it breathe */
320
- @media (max-width: 480px) {
321
- :host ::ng-deep .iti__country-list {
322
- width: 100vw;
323
- max-width: 100vw;
324
- }
325
- }
326
-
327
- /* Clear button */
328
276
  .ngxsmk-tel__clear {
329
277
  position: absolute;
330
278
  right: 8px;
@@ -345,7 +293,6 @@ type IntlTelInstance = any;
345
293
  background: rgba(148, 163, 184, .15);
346
294
  }
347
295
 
348
- /* Hint & Error */
349
296
  .ngxsmk-tel__hint {
350
297
  margin-top: 6px;
351
298
  font-size: 12px;
@@ -364,10 +311,10 @@ type IntlTelInstance = any;
364
311
  }
365
312
  `]
366
313
  })
367
- export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDestroy, ControlValueAccessor {
314
+ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDestroy, ControlValueAccessor, Validator {
368
315
  @ViewChild('telInput', {static: true}) inputRef!: ElementRef<HTMLInputElement>;
369
316
 
370
- /* Existing inputs */
317
+ /* Core config */
371
318
  @Input() initialCountry: CountryCode | 'auto' = 'US';
372
319
  @Input() preferredCountries: CountryCode[] = ['US', 'GB'];
373
320
  @Input() onlyCountries?: CountryCode[];
@@ -375,13 +322,13 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
375
322
  @Input() separateDialCode = false;
376
323
  @Input() allowDropdown = true;
377
324
 
378
- @Input() placeholder = 'Enter phone number';
325
+ /* UX */
326
+ @Input() placeholder?: string; // keep undefined to let plugin set example placeholders (requires utilsScript)
379
327
  @Input() autocomplete = 'tel';
380
328
  @Input() name?: string;
381
329
  @Input() inputId?: string;
382
330
  @Input() disabled = false;
383
331
 
384
- /* New UI/UX inputs */
385
332
  @Input() label?: string;
386
333
  @Input() hint?: string;
387
334
  @Input() errorText?: string;
@@ -393,9 +340,34 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
393
340
  @Input() formatOnBlur = true;
394
341
  @Input() showErrorWhenTouched = true;
395
342
 
396
- /** Dropdown plumbing */
397
- @Input() dropdownAttachToBody = true; // append dropdown to <body> (escapes overflow/clip)
398
- @Input() dropdownZIndex = 2000; // used by CSS var --tel-dd-z
343
+ /* Dropdown plumbing */
344
+ @Input() dropdownAttachToBody = true;
345
+ @Input() dropdownZIndex = 2000;
346
+
347
+ /* Localization + RTL */
348
+ @Input('i18n') i18n?: IntlTelI18n;
349
+
350
+ @Input('telI18n') set telI18n(v: IntlTelI18n | undefined) {
351
+ this.i18n = v;
352
+ }
353
+
354
+ @Input('localizedCountries') localizedCountries?: CountryMap;
355
+
356
+ @Input('telLocalizedCountries') set telLocalizedCountries(v: CountryMap | undefined) {
357
+ this.localizedCountries = v;
358
+ }
359
+
360
+ @Input() clearAriaLabel = 'Clear phone number';
361
+ @Input() dir: 'ltr' | 'rtl' = 'ltr';
362
+
363
+ /* Placeholders (intl-tel-input) */
364
+ @Input() autoPlaceholder: 'off' | 'polite' | 'aggressive' = 'off'; // default OFF since no utils fallback
365
+ @Input() utilsScript?: string;
366
+ @Input() customPlaceholder?: (example: string, country: any) => string;
367
+
368
+ /* Digits-only controls */
369
+ @Input() digitsOnly = true;
370
+ @Input() allowLeadingPlus = true;
399
371
 
400
372
  /* Outputs */
401
373
  @Output() countryChange = new EventEmitter<{ iso2: CountryCode }>();
@@ -408,11 +380,12 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
408
380
  };
409
381
  private onTouchedCb: () => void = () => {
410
382
  };
383
+ private validatorChange?: () => void;
411
384
  private lastEmittedValid = false;
412
385
  private pendingWrite: string | null = null;
413
386
  private touched = false;
414
387
 
415
- readonly resolvedId = (() => 'tel-' + Math.random().toString(36).slice(2))();
388
+ readonly resolvedId = this.inputId || ('tel-' + Math.random().toString(36).slice(2));
416
389
 
417
390
  constructor(
418
391
  private readonly zone: NgZone,
@@ -423,11 +396,6 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
423
396
 
424
397
  async ngAfterViewInit() {
425
398
  if (!isPlatformBrowser(this.platformId)) return;
426
-
427
- // set z-index via CSS var
428
- (this as any).constructor; // no-op to keep TS calm
429
- (this.inputRef.nativeElement.closest(':host') as any);
430
-
431
399
  await this.initIntlTelInput();
432
400
  this.bindDomListeners();
433
401
 
@@ -441,9 +409,17 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
441
409
 
442
410
  ngOnChanges(changes: SimpleChanges): void {
443
411
  if (!isPlatformBrowser(this.platformId)) return;
444
- const configChanged = ['initialCountry', 'preferredCountries', 'onlyCountries', 'separateDialCode', 'allowDropdown', 'nationalMode']
445
- .some(k => k in changes && !changes[k].firstChange);
446
- if (configChanged && this.iti) this.reinitPlugin();
412
+ const configChanged = [
413
+ 'initialCountry', 'preferredCountries', 'onlyCountries',
414
+ 'separateDialCode', 'allowDropdown', 'nationalMode',
415
+ 'i18n', 'localizedCountries', 'dir',
416
+ 'autoPlaceholder', 'utilsScript', 'customPlaceholder',
417
+ 'digitsOnly', 'allowLeadingPlus'
418
+ ].some(k => k in changes && !changes[k]?.firstChange);
419
+ if (configChanged && this.iti) {
420
+ this.reinitPlugin();
421
+ this.validatorChange?.();
422
+ }
447
423
  }
448
424
 
449
425
  ngOnDestroy(): void {
@@ -485,6 +461,10 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
485
461
  return valid ? null : {phoneInvalid: true};
486
462
  }
487
463
 
464
+ registerOnValidatorChange(fn: () => void): void {
465
+ this.validatorChange = fn;
466
+ }
467
+
488
468
  // ----- Public helpers -----
489
469
  focus(): void {
490
470
  this.inputRef?.nativeElement.focus();
@@ -510,6 +490,10 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
510
490
  // ----- Plugin wiring -----
511
491
  private async initIntlTelInput() {
512
492
  const [{default: intlTelInput}] = await Promise.all([import('intl-tel-input')]);
493
+
494
+ const toLowerKeys = (m?: CountryMap) =>
495
+ m ? Object.fromEntries(Object.entries(m).map(([k, v]) => [k.toLowerCase(), v])) : undefined;
496
+
513
497
  const config: any = {
514
498
  initialCountry: this.initialCountry === 'auto' ? 'auto' : (this.initialCountry?.toLowerCase() || 'us'),
515
499
  preferredCountries: (this.preferredCountries ?? []).map(c => c.toLowerCase()),
@@ -518,14 +502,25 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
518
502
  allowDropdown: this.allowDropdown,
519
503
  separateDialCode: this.separateDialCode,
520
504
  geoIpLookup: (cb: (iso2: string) => void) => cb('us'),
521
- utilsScript: undefined,
505
+
506
+ // placeholders — NOTE: no CDN fallback anymore
507
+ autoPlaceholder: this.autoPlaceholder, // 'off' | 'polite' | 'aggressive'
508
+ utilsScript: this.utilsScript,
509
+ customPlaceholder: this.customPlaceholder,
510
+
511
+ // localization
512
+ i18n: this.i18n,
513
+ localizedCountries: toLowerKeys(this.localizedCountries),
514
+
515
+ // dropdown container
522
516
  dropdownContainer: this.dropdownAttachToBody && typeof document !== 'undefined' ? document.body : undefined
523
517
  };
518
+
524
519
  this.zone.runOutsideAngular(() => {
525
520
  this.iti = intlTelInput(this.inputRef.nativeElement, config);
526
521
  });
527
522
 
528
- // expose z-index var to host (so CSS picks it up)
523
+ // z-index for dropdown
529
524
  (this.inputRef.nativeElement as HTMLElement).style.setProperty('--tel-dd-z', String(this.dropdownZIndex));
530
525
  }
531
526
 
@@ -553,16 +548,74 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
553
548
  }
554
549
  }
555
550
 
551
+ // ----- Input filtering (digits-only) -----
552
+ private sanitizeDigits(value: string): string {
553
+ if (!this.digitsOnly) return value;
554
+ let v = value.replace(/[^\d+]/g, ''); // keep digits and pluses
555
+ // allow only ONE leading + (if enabled)
556
+ if (this.allowLeadingPlus) {
557
+ const hasLeadingPlus = v.startsWith('+');
558
+ v = (hasLeadingPlus ? '+' : '') + v.replace(/\+/g, '');
559
+ } else {
560
+ v = v.replace(/\+/g, '');
561
+ }
562
+ return v;
563
+ }
564
+
556
565
  private bindDomListeners() {
557
566
  const el = this.inputRef.nativeElement;
567
+
558
568
  this.zone.runOutsideAngular(() => {
559
- el.addEventListener('input', () => this.handleInput());
569
+ // prevent invalid chars while typing
570
+ el.addEventListener('beforeinput', (ev: InputEvent) => {
571
+ if (!this.digitsOnly) return;
572
+ const data = (ev as any).data as string | null;
573
+
574
+ // allow deletions, cuts, etc.
575
+ if (!data || ev.inputType !== 'insertText') return;
576
+
577
+ const pos = el.selectionStart ?? 0;
578
+ const isDigit = data >= '0' && data <= '9';
579
+ const isPlusAtStart = this.allowLeadingPlus && data === '+' && pos === 0 && !el.value.includes('+');
580
+
581
+ if (!isDigit && !isPlusAtStart) ev.preventDefault();
582
+ });
583
+
584
+ // sanitize pastes
585
+ el.addEventListener('paste', (e: ClipboardEvent) => {
586
+ if (!this.digitsOnly) return;
587
+ e.preventDefault();
588
+ const text = (e.clipboardData || (window as any).clipboardData).getData('text');
589
+ const sanitized = this.sanitizeDigits(text);
590
+ const start = el.selectionStart ?? el.value.length;
591
+ const end = el.selectionEnd ?? el.value.length;
592
+ el.setRangeText(sanitized, start, end, 'end');
593
+ queueMicrotask(() => this.handleInput());
594
+ });
595
+
596
+ // catch any remaining non-digit changes (e.g., programmatic)
597
+ el.addEventListener('input', () => {
598
+ if (this.digitsOnly) {
599
+ const val = el.value;
600
+ const sanitized = this.sanitizeDigits(val);
601
+ if (val !== sanitized) {
602
+ const caret = el.selectionStart ?? sanitized.length;
603
+ el.value = sanitized;
604
+ el.setSelectionRange(caret, caret);
605
+ }
606
+ }
607
+ this.handleInput();
608
+ });
609
+
560
610
  el.addEventListener('countrychange', () => {
561
611
  const iso2 = this.currentIso2();
562
- this.zone.run(() => this.countryChange.emit({iso2}));
612
+ this.zone.run(() => {
613
+ this.countryChange.emit({iso2});
614
+ this.validatorChange?.();
615
+ });
563
616
  this.handleInput();
564
617
  });
565
- el.addEventListener('paste', () => queueMicrotask(() => this.handleInput()));
618
+
566
619
  el.addEventListener('blur', () => this.onBlur());
567
620
  });
568
621
  }
@@ -575,7 +628,7 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
575
628
  if (!raw) return;
576
629
  const parsed = this.tel.parse(raw, this.currentIso2());
577
630
  if (this.nationalMode && parsed.national) {
578
- this.setInputValue(parsed.national.replace(/\s{2,}/g, ' '));
631
+ this.setInputValue((parsed.national || '').replace(/\s{2,}/g, ' '));
579
632
  }
580
633
  }
581
634
 
@@ -603,7 +656,8 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
603
656
  }
604
657
 
605
658
  private currentIso2(): CountryCode {
606
- const iso2 = (this.iti?.getSelectedCountryData?.().iso2 ?? this.initialCountry ?? 'US').toString().toUpperCase();
659
+ const iso2 = (this.iti?.getSelectedCountryData?.().iso2 ?? this.initialCountry ?? 'US')
660
+ .toString().toUpperCase();
607
661
  return iso2 as CountryCode;
608
662
  }
609
663
 
@@ -4,14 +4,14 @@ import { parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js'
4
4
  @Injectable({ providedIn: 'root' })
5
5
  export class NgxsmkTelInputService {
6
6
  parse(input: string, iso2: CountryCode): { e164: string | null; national: string | null; isValid: boolean } {
7
- const phone = parsePhoneNumberFromString(input, iso2);
7
+ const phone = parsePhoneNumberFromString(input || '', iso2);
8
8
  if (!phone) return { e164: null, national: null, isValid: false };
9
9
  const isValid = phone.isValid();
10
10
  return { e164: isValid ? phone.number : null, national: phone.formatNational(), isValid };
11
11
  }
12
12
 
13
13
  isValid(input: string, iso2: CountryCode): boolean {
14
- const phone = parsePhoneNumberFromString(input, iso2);
14
+ const phone = parsePhoneNumberFromString(input || '', iso2);
15
15
  return !!phone && phone.isValid();
16
16
  }
17
17
  }