ngxsmk-tel-input 1.0.7 → 1.0.9
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 +209 -128
- package/docs/kr.png +0 -0
- package/package.json +1 -1
- package/src/lib/ngxsmk-tel-input.component.ts +194 -265
- package/src/lib/ngxsmk-tel-input.service.ts +2 -2
package/README.md
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
# ngxsmk-tel-input
|
|
1
|
+
# ngxsmk-tel-input
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[
|
|
5
|
-
[](https://github.com/toozuuu/ngxsmk-tel-input/blob/main/LICENSE)
|
|
6
|
-
[](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
|
-
|
|
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
|
|
|
@@ -25,27 +17,36 @@ An Angular component for entering and validating **international telephone numbe
|
|
|
25
17
|
|
|
26
18
|
---
|
|
27
19
|
|
|
28
|
-
##
|
|
29
|
-
|
|
30
|
-
| ngxsmk-tel-input | Angular |
|
|
31
|
-
|------------------| --------------- |
|
|
32
|
-
| `1.0.x` | `17.x` – `19.x` |
|
|
20
|
+
## ✨ Features
|
|
33
21
|
|
|
34
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
41
|
+
## 📦 Install
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
44
|
npm i ngxsmk-tel-input intl-tel-input libphonenumber-js
|
|
44
45
|
```
|
|
45
46
|
|
|
46
|
-
###
|
|
47
|
+
### Add styles & flag assets (in your **app**, not the library)
|
|
47
48
|
|
|
48
|
-
|
|
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
|
|
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,194 +78,276 @@ Optional (helps some bundlers resolve flags): add to your global styles
|
|
|
79
78
|
}
|
|
80
79
|
```
|
|
81
80
|
|
|
82
|
-
Restart
|
|
81
|
+
Restart the dev server after changes.
|
|
83
82
|
|
|
84
83
|
---
|
|
85
84
|
|
|
86
|
-
##
|
|
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 {
|
|
94
|
-
import {
|
|
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';
|
|
95
93
|
|
|
96
94
|
@Component({
|
|
97
95
|
selector: 'app-root',
|
|
98
96
|
standalone: true,
|
|
99
|
-
imports: [ReactiveFormsModule, NgxsmkTelInputComponent],
|
|
97
|
+
imports: [ReactiveFormsModule, NgxsmkTelInputComponent, JsonPipe],
|
|
100
98
|
template: `
|
|
101
99
|
<form [formGroup]="fg" style="max-width:420px;display:grid;gap:12px">
|
|
102
100
|
<ngxsmk-tel-input
|
|
103
101
|
formControlName="phone"
|
|
104
102
|
label="Phone"
|
|
105
103
|
hint="Include area code"
|
|
104
|
+
dir="ltr"
|
|
106
105
|
[initialCountry]="'US'"
|
|
107
106
|
[preferredCountries]="['US','GB','AU']"
|
|
108
|
-
|
|
107
|
+
[i18n]="enLabels"
|
|
108
|
+
[localizedCountries]="enCountries"
|
|
109
|
+
[autoPlaceholder]="'off'"
|
|
110
|
+
[clearAriaLabel]="'Clear phone number'">
|
|
109
111
|
</ngxsmk-tel-input>
|
|
110
112
|
|
|
111
|
-
<p *ngIf="fg.get('phone')?.hasError('phoneInvalid') && fg.get('phone')?.touched">
|
|
112
|
-
Please enter a valid phone number.
|
|
113
|
-
</p>
|
|
114
|
-
|
|
115
113
|
<pre>Value: {{ fg.value | json }}</pre>
|
|
116
114
|
</form>
|
|
117
115
|
`
|
|
118
116
|
})
|
|
119
117
|
export class AppComponent {
|
|
118
|
+
private readonly fb = inject(FormBuilder);
|
|
120
119
|
fg = this.fb.group({ phone: ['', Validators.required] });
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
};
|
|
123
137
|
}
|
|
138
|
+
|
|
124
139
|
```
|
|
125
140
|
|
|
126
|
-
**Value semantics:**
|
|
141
|
+
**Value semantics:** the form control value is **E.164** (e.g., `+14155550123`) when valid, or `null` when empty/invalid.
|
|
142
|
+
|
|
143
|
+
---
|
|
127
144
|
|
|
128
|
-
|
|
145
|
+
## 📝 Template‑driven usage
|
|
129
146
|
|
|
130
147
|
```html
|
|
131
148
|
<form #f="ngForm">
|
|
132
149
|
<ngxsmk-tel-input name="phone" [(ngModel)]="phone"></ngxsmk-tel-input>
|
|
133
150
|
</form>
|
|
151
|
+
<!-- phone is an E.164 string or null -->
|
|
134
152
|
```
|
|
135
153
|
|
|
136
154
|
---
|
|
137
155
|
|
|
138
|
-
##
|
|
139
|
-
|
|
140
|
-
| Option | Type | Default | Description |
|
|
141
|
-
| ---------------------- | -------------------------------------- | ---------------------- | ---------------------------------------------------------------------- |
|
|
142
|
-
| `initialCountry` | `CountryCode \| 'auto'` | `'US'` | Starting country. `'auto'` uses a simple geo‑IP stub (defaults to US). |
|
|
143
|
-
| `preferredCountries` | `CountryCode[]` | `['US','GB']` | Countries pinned at the top. |
|
|
144
|
-
| `onlyCountries` | `CountryCode[]` | — | Restrict selectable countries. |
|
|
145
|
-
| `nationalMode` | `boolean` | `false` | Display national format in the box. Value still emits E.164. |
|
|
146
|
-
| `separateDialCode` | `boolean` | `false` | Show dial code separately to the left. |
|
|
147
|
-
| `allowDropdown` | `boolean` | `true` | Enable/disable country dropdown. |
|
|
148
|
-
| `placeholder` | `string` | `'Enter phone number'` | Input placeholder. |
|
|
149
|
-
| `autocomplete` | `string` | `'tel'` | Native autocomplete attribute. |
|
|
150
|
-
| `disabled` | `boolean` | `false` | Disable the control. |
|
|
151
|
-
| `label` | `string` | — | Optional label text. |
|
|
152
|
-
| `hint` | `string` | — | Helper text shown below. |
|
|
153
|
-
| `errorText` | `string` | — | Custom error message. |
|
|
154
|
-
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Preset sizing. |
|
|
155
|
-
| `variant` | `'outline' \| 'filled' \| 'underline'` | `'outline'` | Visual style. |
|
|
156
|
-
| `showClear` | `boolean` | `true` | Show a clear (×) button when not empty. |
|
|
157
|
-
| `autoFocus` | `boolean` | `false` | Focus on init. |
|
|
158
|
-
| `selectOnFocus` | `boolean` | `false` | Select text on focus. |
|
|
159
|
-
| `formatOnBlur` | `boolean` | `true` | Pretty‑print on blur (national if `nationalMode`). |
|
|
160
|
-
| `showErrorWhenTouched` | `boolean` | `true` | Only show error styles after the control is touched. |
|
|
161
|
-
| `dropdownAttachToBody` | `boolean` | `true` | Attach dropdown to `<body>` to avoid clipping. |
|
|
162
|
-
| `dropdownZIndex` | `number` | `2000` | Z‑index for dropdown panel. |
|
|
163
|
-
|
|
164
|
-
> `CountryCode` is the ISO‑2 code from `libphonenumber-js` (e.g. `US`, `GB`).
|
|
165
|
-
|
|
166
|
-
### Outputs (Events)
|
|
167
|
-
|
|
168
|
-
| Event | Payload | Description |
|
|
169
|
-
| ---------------- | ---------------------------------------------------------- | ---------------------------------------- |
|
|
170
|
-
| `countryChange` | `{ iso2: CountryCode }` | Fires when the selected country changes. |
|
|
171
|
-
| `validityChange` | `boolean` | Emits when validity toggles. |
|
|
172
|
-
| `inputChange` | `{ raw: string; e164: string \| null; iso2: CountryCode }` | Emitted on each input. |
|
|
173
|
-
|
|
174
|
-
### Public Methods
|
|
156
|
+
## 🈺 Localization & RTL
|
|
175
157
|
|
|
176
|
-
|
|
177
|
-
* `selectCountry(iso2: CountryCode): void`
|
|
158
|
+
You can localize the dropdown/search labels and override country names.
|
|
178
159
|
|
|
179
|
-
|
|
160
|
+
<img src="https://unpkg.com/ngxsmk-tel-input@1.0.4/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
|
+
};
|
|
180
188
|
|
|
181
|
-
|
|
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>
|
|
204
|
+
|
|
205
|
+
```
|
|
182
206
|
|
|
183
|
-
* **E164** → `+41446681800`
|
|
184
|
-
* **International** (display) → `+41 44 668 18 00`
|
|
185
|
-
* **National** (display) → `044 668 18 00`
|
|
186
207
|
|
|
187
|
-
|
|
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`
|
|
188
257
|
|
|
189
258
|
---
|
|
190
259
|
|
|
191
|
-
## Theming
|
|
260
|
+
## 🎨 Theming (CSS variables)
|
|
192
261
|
|
|
193
|
-
|
|
262
|
+
Override on the element or a parent container:
|
|
194
263
|
|
|
195
264
|
```html
|
|
196
265
|
<ngxsmk-tel-input style="
|
|
197
266
|
--tel-border:#cbd5e1;
|
|
198
|
-
--tel-ring:#
|
|
199
|
-
--tel-radius:
|
|
200
|
-
--tel-dd-item-hover: rgba(
|
|
267
|
+
--tel-ring:#22c55e;
|
|
268
|
+
--tel-radius:14px;
|
|
269
|
+
--tel-dd-item-hover: rgba(34,197,94,.12);
|
|
201
270
|
--tel-dd-z: 3000;
|
|
202
271
|
"></ngxsmk-tel-input>
|
|
203
272
|
```
|
|
204
273
|
|
|
205
|
-
|
|
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 }`
|
|
206
294
|
|
|
207
|
-
|
|
295
|
+
> Need national string instead of E.164? Use `(inputChange)` and store `raw`/`national` yourself, or adapt the emitter to output national.
|
|
208
296
|
|
|
209
297
|
---
|
|
210
298
|
|
|
211
|
-
## SSR
|
|
299
|
+
## 🌐 SSR notes
|
|
212
300
|
|
|
213
|
-
The library
|
|
301
|
+
* The library lazy‑imports `intl-tel-input` only in the **browser** (guards with `isPlatformBrowser`).
|
|
302
|
+
* No `window`/`document` usage on the server path.
|
|
214
303
|
|
|
215
304
|
---
|
|
216
305
|
|
|
217
|
-
## Local
|
|
306
|
+
## 🧪 Local development
|
|
218
307
|
|
|
219
|
-
This
|
|
308
|
+
This repo is an Angular workspace with a library.
|
|
220
309
|
|
|
221
310
|
```bash
|
|
222
311
|
# Build the library
|
|
223
312
|
ng build ngxsmk-tel-input
|
|
224
313
|
|
|
225
|
-
#
|
|
314
|
+
# Option A: use it inside a demo app in the same workspace
|
|
226
315
|
ng serve demo
|
|
227
316
|
|
|
228
|
-
#
|
|
317
|
+
# Option B: install locally via tarball in another app
|
|
229
318
|
cd dist/ngxsmk-tel-input && npm pack
|
|
230
|
-
|
|
231
319
|
# in your other app
|
|
232
|
-
npm i ../path
|
|
320
|
+
npm i ../path-to-workspace/dist/ngxsmk-tel-input/ngxsmk-tel-input-<version>.tgz
|
|
233
321
|
```
|
|
234
322
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
```jsonc
|
|
238
|
-
{
|
|
239
|
-
"compilerOptions": {
|
|
240
|
-
"paths": {
|
|
241
|
-
"ngxsmk-tel-input": ["dist/ngxsmk-tel-input"]
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
```
|
|
323
|
+
> Workspace aliasing via `tsconfig.paths` also works (map `"ngxsmk-tel-input": ["dist/ngxsmk-tel-input"]`).
|
|
246
324
|
|
|
247
325
|
---
|
|
248
326
|
|
|
249
|
-
## Troubleshooting
|
|
250
|
-
|
|
251
|
-
* **Unstyled dropdown / bullets / missing flags** → Ensure CSS & assets are added in your app’s `angular.json`, then restart the dev server.
|
|
252
|
-
* **`TS2307: Cannot find module 'ngxsmk-tel-input'`** → Build the library first so `dist/ngxsmk-tel-input` exists (or install from npm).
|
|
253
|
-
* **Peer dependency conflict** → Your app must be Angular 17–19 to satisfy peers.
|
|
254
|
-
* **Dropdown clipped by parent** → Keep `dropdownAttachToBody=true` or raise `dropdownZIndex`.
|
|
327
|
+
## 🧯 Troubleshooting
|
|
255
328
|
|
|
256
|
-
|
|
329
|
+
**UI looks unstyled / bullets in dropdown**
|
|
330
|
+
Add the CSS and assets in `angular.json` (see Install). Restart the dev server.
|
|
257
331
|
|
|
258
|
-
|
|
332
|
+
**Flags don’t show**
|
|
333
|
+
Ensure the assets copy exists under `/assets/intl-tel-input/img` and add the CSS override block above.
|
|
259
334
|
|
|
260
|
-
|
|
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`.
|
|
261
337
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
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.
|
|
265
340
|
|
|
266
|
-
|
|
341
|
+
**Vite/Angular “Failed to resolve import …”**
|
|
342
|
+
Clear `.angular/cache`, rebuild the lib, and restart `ng serve`.
|
|
267
343
|
|
|
268
344
|
---
|
|
269
345
|
|
|
270
|
-
## License
|
|
346
|
+
## 📃 License
|
|
271
347
|
|
|
272
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.
|
|
3
|
+
"version": "1.0.9",
|
|
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",
|
|
@@ -14,30 +14,44 @@ import {
|
|
|
14
14
|
SimpleChanges,
|
|
15
15
|
ViewChild
|
|
16
16
|
} from '@angular/core';
|
|
17
|
-
import {isPlatformBrowser} from '@angular/common';
|
|
17
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
18
18
|
import {
|
|
19
19
|
AbstractControl,
|
|
20
20
|
ControlValueAccessor,
|
|
21
21
|
NG_VALIDATORS,
|
|
22
22
|
NG_VALUE_ACCESSOR,
|
|
23
|
-
ValidationErrors
|
|
23
|
+
ValidationErrors,
|
|
24
|
+
Validator
|
|
24
25
|
} from '@angular/forms';
|
|
25
|
-
import type {CountryCode} from 'libphonenumber-js';
|
|
26
|
-
import {NgxsmkTelInputService} from './ngxsmk-tel-input.service';
|
|
26
|
+
import type { CountryCode } from 'libphonenumber-js';
|
|
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="
|
|
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
|
-
<label class="
|
|
51
|
+
<label class="ngxsmk-tel__label" [for]="resolvedId">{{ label }}</label>
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
<div class="
|
|
54
|
+
<div class="ngxsmk-tel__wrap" [class.has-error]="showError">
|
|
41
55
|
<div class="ngxsmk-tel-input__wrapper">
|
|
42
56
|
<input
|
|
43
57
|
#telInput
|
|
@@ -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()"
|
|
@@ -56,28 +72,26 @@ type IntlTelInstance = any;
|
|
|
56
72
|
|
|
57
73
|
@if (showClear && currentRaw()) {
|
|
58
74
|
<button type="button"
|
|
59
|
-
class="
|
|
75
|
+
class="ngxsmk-tel__clear"
|
|
60
76
|
(click)="clearInput()"
|
|
61
|
-
[attr.aria-label]="
|
|
77
|
+
[attr.aria-label]="clearAriaLabel">
|
|
62
78
|
×
|
|
63
79
|
</button>
|
|
64
80
|
}
|
|
65
|
-
|
|
66
81
|
</div>
|
|
67
82
|
|
|
68
83
|
@if (hint && !showError) {
|
|
69
|
-
<div class="
|
|
84
|
+
<div class="ngxsmk-tel__hint">{{ hint }}</div>
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
@if (showError) {
|
|
73
|
-
<div class="
|
|
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: [
|
|
79
|
-
{provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NgxsmkTelInputComponent), multi: true},
|
|
80
|
-
{provide: NG_VALIDATORS, useExisting: forwardRef(() => NgxsmkTelInputComponent), multi: true}
|
|
93
|
+
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NgxsmkTelInputComponent), multi: true },
|
|
94
|
+
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => NgxsmkTelInputComponent), multi: true }
|
|
81
95
|
],
|
|
82
96
|
styles: [`
|
|
83
97
|
/* ---------- Theme tokens ---------- */
|
|
@@ -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) {
|
|
@@ -119,90 +131,37 @@ type IntlTelInstance = any;
|
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
/* ---------- Structure ---------- */
|
|
122
|
-
.
|
|
123
|
-
|
|
124
|
-
color: var(--tel-fg);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
.ngx-tel.disabled {
|
|
128
|
-
opacity: .7;
|
|
129
|
-
cursor: not-allowed;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
.ngx-tel__label {
|
|
133
|
-
display: inline-block;
|
|
134
|
-
margin-bottom: 6px;
|
|
135
|
-
font-size: .875rem;
|
|
136
|
-
font-weight: 500;
|
|
137
|
-
}
|
|
134
|
+
.ngxsmk-tel { width: 100%; color: var(--tel-fg); }
|
|
135
|
+
.ngxsmk-tel.disabled { opacity: .7; cursor: not-allowed; }
|
|
138
136
|
|
|
139
|
-
.
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
.ngxsmk-tel-input__wrapper,
|
|
144
|
-
:host ::ng-deep .iti {
|
|
145
|
-
width: 100%;
|
|
146
|
-
}
|
|
137
|
+
.ngxsmk-tel__label { display: inline-block; margin-bottom: 6px; font-size: .875rem; font-weight: 500; }
|
|
138
|
+
.ngxsmk-tel__wrap { position: relative; }
|
|
139
|
+
.ngxsmk-tel-input__wrapper, :host ::ng-deep .iti { width: 100%; }
|
|
147
140
|
|
|
148
141
|
.ngxsmk-tel-input__control {
|
|
149
|
-
width: 100%;
|
|
150
|
-
|
|
151
|
-
font: inherit;
|
|
152
|
-
color: var(--tel-fg);
|
|
153
|
-
background: var(--tel-bg);
|
|
154
|
-
border: 1px solid var(--tel-border);
|
|
142
|
+
width: 100%; height: 40px; font: inherit; color: var(--tel-fg);
|
|
143
|
+
background: var(--tel-bg); border: 1px solid var(--tel-border);
|
|
155
144
|
border-radius: var(--tel-radius);
|
|
156
|
-
padding: 10px 40px 10px 12px;
|
|
145
|
+
padding: 10px 40px 10px 12px;
|
|
157
146
|
outline: none;
|
|
158
|
-
transition: border-color .15s
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
.ngxsmk-tel-input__control::placeholder {
|
|
162
|
-
color: var(--tel-placeholder);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
.ngxsmk-tel-input__control:hover {
|
|
166
|
-
border-color: var(--tel-border-hover);
|
|
147
|
+
transition: border-color .15s, box-shadow .15s, background .15s;
|
|
167
148
|
}
|
|
149
|
+
.ngxsmk-tel-input__control::placeholder { color: var(--tel-placeholder); }
|
|
150
|
+
.ngxsmk-tel-input__control:hover { border-color: var(--tel-border-hover); }
|
|
151
|
+
.ngxsmk-tel-input__control:focus { border-color: var(--tel-ring); box-shadow: var(--tel-focus-shadow); }
|
|
168
152
|
|
|
169
|
-
.ngxsmk-tel-input__control:focus {
|
|
170
|
-
border-color: var(--tel-ring);
|
|
171
|
-
box-shadow: var(--tel-focus-shadow);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/* Size presets */
|
|
175
153
|
[data-size="sm"] .ngxsmk-tel-input__control {
|
|
176
|
-
height: 34px;
|
|
177
|
-
font-size: 13px;
|
|
178
|
-
padding: 6px 36px 6px 10px;
|
|
179
|
-
border-radius: 10px;
|
|
154
|
+
height: 34px; font-size: 13px; padding: 6px 36px 6px 10px; border-radius: 10px;
|
|
180
155
|
}
|
|
181
|
-
|
|
182
156
|
[data-size="lg"] .ngxsmk-tel-input__control {
|
|
183
|
-
height: 46px;
|
|
184
|
-
font-size: 16px;
|
|
185
|
-
padding: 12px 44px 12px 14px;
|
|
186
|
-
border-radius: 14px;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/* Variants */
|
|
190
|
-
[data-variant="filled"] .ngxsmk-tel-input__control {
|
|
191
|
-
background: rgba(148, 163, 184, .08);
|
|
157
|
+
height: 46px; font-size: 16px; padding: 12px 44px 12px 14px; border-radius: 14px;
|
|
192
158
|
}
|
|
193
159
|
|
|
160
|
+
[data-variant="filled"] .ngxsmk-tel-input__control { background: rgba(148, 163, 184, .08); }
|
|
194
161
|
[data-variant="underline"] .ngxsmk-tel-input__control {
|
|
195
|
-
border: 0;
|
|
196
|
-
border-bottom: 2px solid var(--tel-border);
|
|
197
|
-
border-radius: 0;
|
|
198
|
-
padding-left: 0;
|
|
199
|
-
padding-right: 34px;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
[data-variant="underline"] .ngxsmk-tel-input__control:focus {
|
|
203
|
-
border-bottom-color: var(--tel-ring);
|
|
204
|
-
box-shadow: none;
|
|
162
|
+
border: 0; border-bottom: 2px solid var(--tel-border); border-radius: 0; padding-left: 0; padding-right: 34px;
|
|
205
163
|
}
|
|
164
|
+
[data-variant="underline"] .ngxsmk-tel-input__control:focus { border-bottom-color: var(--tel-ring); box-shadow: none; }
|
|
206
165
|
|
|
207
166
|
/* ---------- intl-tel-input dropdown (deep selectors) ---------- */
|
|
208
167
|
:host ::ng-deep .iti__flag-container {
|
|
@@ -212,162 +171,48 @@ type IntlTelInstance = any;
|
|
|
212
171
|
border-right: none;
|
|
213
172
|
background: var(--tel-bg);
|
|
214
173
|
}
|
|
174
|
+
:host ::ng-deep .iti__selected-flag { height: 100%; padding: 0 10px; display: inline-flex; align-items: center; }
|
|
215
175
|
|
|
216
|
-
:host ::ng-deep .iti__selected-flag {
|
|
217
|
-
height: 100%;
|
|
218
|
-
padding: 0 10px;
|
|
219
|
-
display: inline-flex;
|
|
220
|
-
align-items: center;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/* Core dropdown panel */
|
|
224
176
|
:host ::ng-deep .iti__country-list {
|
|
225
|
-
background: var(--tel-dd-bg);
|
|
226
|
-
border:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
max-height: min(50vh, 360px);
|
|
230
|
-
overflow: auto;
|
|
231
|
-
padding: 6px 0;
|
|
232
|
-
width: max(280px, 100%);
|
|
233
|
-
z-index: var(--tel-dd-z);
|
|
177
|
+
background: var(--tel-dd-bg); border: 1px solid var(--tel-dd-border);
|
|
178
|
+
border-radius: var(--tel-dd-radius); box-shadow: var(--tel-dd-shadow);
|
|
179
|
+
max-height: min(50vh, 360px); overflow: auto; padding: 6px 0;
|
|
180
|
+
width: max(280px, 100%); z-index: var(--tel-dd-z);
|
|
234
181
|
}
|
|
182
|
+
:host ::ng-deep .iti--container .iti__country-list { z-index: var(--tel-dd-z); }
|
|
235
183
|
|
|
236
|
-
/* When attached to <body>, it's wrapped in .iti--container */
|
|
237
|
-
:host ::ng-deep .iti--container .iti__country-list {
|
|
238
|
-
z-index: var(--tel-dd-z);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/* Search input (sticky header) */
|
|
242
184
|
:host ::ng-deep .iti__search-input {
|
|
243
|
-
position: sticky;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
padding: 10px 12px;
|
|
247
|
-
width: 100%;
|
|
248
|
-
border: 0;
|
|
249
|
-
border-bottom: 1px solid var(--tel-dd-border);
|
|
250
|
-
outline: none;
|
|
251
|
-
background: var(--tel-dd-search-bg);
|
|
252
|
-
color: var(--tel-fg);
|
|
185
|
+
position: sticky; top: 0; margin: 0; padding: 10px 12px; width: 100%;
|
|
186
|
+
border: 0; border-bottom: 1px solid var(--tel-dd-border); outline: none;
|
|
187
|
+
background: var(--tel-dd-search-bg); color: var(--tel-fg);
|
|
253
188
|
}
|
|
254
189
|
|
|
255
|
-
:host ::ng-deep .iti__search-input::placeholder {
|
|
256
|
-
color: var(--tel-placeholder);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/* Rows: flag | country name | dial code (right) */
|
|
260
190
|
:host ::ng-deep .iti__country {
|
|
261
|
-
display: grid;
|
|
262
|
-
|
|
263
|
-
align-items: center;
|
|
264
|
-
column-gap: .5rem;
|
|
265
|
-
padding: 10px 12px;
|
|
266
|
-
cursor: pointer;
|
|
267
|
-
}
|
|
268
|
-
|
|
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);
|
|
191
|
+
display: grid; grid-template-columns: 28px 1fr auto; align-items: center;
|
|
192
|
+
column-gap: .5rem; padding: 10px 12px; cursor: pointer;
|
|
277
193
|
}
|
|
194
|
+
:host ::ng-deep .iti__dial-code { color: var(--tel-placeholder); font-weight: 600; margin-left: 10px; }
|
|
278
195
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
font-
|
|
282
|
-
|
|
196
|
+
.ngxsmk-tel__clear {
|
|
197
|
+
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
|
198
|
+
border: 0; background: transparent; font-size: 18px; line-height: 1;
|
|
199
|
+
width: 28px; height: 28px; border-radius: 50%; cursor: pointer; color: var(--tel-placeholder);
|
|
283
200
|
}
|
|
201
|
+
.ngxsmk-tel__clear:hover { background: rgba(148, 163, 184, .15); }
|
|
284
202
|
|
|
285
|
-
:
|
|
286
|
-
:
|
|
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
|
-
}
|
|
203
|
+
.ngxsmk-tel__hint { margin-top: 6px; font-size: 12px; color: var(--tel-placeholder); }
|
|
204
|
+
.ngxsmk-tel__error { margin-top: 6px; font-size: 12px; color: var(--tel-error); }
|
|
309
205
|
|
|
310
|
-
|
|
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
|
-
.ngx-tel__clear {
|
|
329
|
-
position: absolute;
|
|
330
|
-
right: 8px;
|
|
331
|
-
top: 50%;
|
|
332
|
-
transform: translateY(-50%);
|
|
333
|
-
border: 0;
|
|
334
|
-
background: transparent;
|
|
335
|
-
font-size: 18px;
|
|
336
|
-
line-height: 1;
|
|
337
|
-
width: 28px;
|
|
338
|
-
height: 28px;
|
|
339
|
-
border-radius: 50%;
|
|
340
|
-
cursor: pointer;
|
|
341
|
-
color: var(--tel-placeholder);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
.ngx-tel__clear:hover {
|
|
345
|
-
background: rgba(148, 163, 184, .15);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/* Hint & Error */
|
|
349
|
-
.ngx-tel__hint {
|
|
350
|
-
margin-top: 6px;
|
|
351
|
-
font-size: 12px;
|
|
352
|
-
color: var(--tel-placeholder);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
.ngx-tel__error {
|
|
356
|
-
margin-top: 6px;
|
|
357
|
-
font-size: 12px;
|
|
358
|
-
color: var(--tel-error);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
.ngx-tel__wrap.has-error .ngxsmk-tel-input__control {
|
|
206
|
+
.ngxsmk-tel__wrap.has-error .ngxsmk-tel-input__control {
|
|
362
207
|
border-color: var(--tel-error);
|
|
363
208
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, .15);
|
|
364
209
|
}
|
|
365
210
|
`]
|
|
366
211
|
})
|
|
367
|
-
export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDestroy, ControlValueAccessor {
|
|
368
|
-
@ViewChild('telInput', {static: true}) inputRef!: ElementRef<HTMLInputElement>;
|
|
212
|
+
export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDestroy, ControlValueAccessor, Validator {
|
|
213
|
+
@ViewChild('telInput', { static: true }) inputRef!: ElementRef<HTMLInputElement>;
|
|
369
214
|
|
|
370
|
-
/*
|
|
215
|
+
/* Core config */
|
|
371
216
|
@Input() initialCountry: CountryCode | 'auto' = 'US';
|
|
372
217
|
@Input() preferredCountries: CountryCode[] = ['US', 'GB'];
|
|
373
218
|
@Input() onlyCountries?: CountryCode[];
|
|
@@ -375,13 +220,13 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
375
220
|
@Input() separateDialCode = false;
|
|
376
221
|
@Input() allowDropdown = true;
|
|
377
222
|
|
|
378
|
-
|
|
223
|
+
/* UX */
|
|
224
|
+
@Input() placeholder?: string; // keep undefined to let plugin set example placeholders (requires utilsScript)
|
|
379
225
|
@Input() autocomplete = 'tel';
|
|
380
226
|
@Input() name?: string;
|
|
381
227
|
@Input() inputId?: string;
|
|
382
228
|
@Input() disabled = false;
|
|
383
229
|
|
|
384
|
-
/* New UI/UX inputs */
|
|
385
230
|
@Input() label?: string;
|
|
386
231
|
@Input() hint?: string;
|
|
387
232
|
@Input() errorText?: string;
|
|
@@ -393,9 +238,26 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
393
238
|
@Input() formatOnBlur = true;
|
|
394
239
|
@Input() showErrorWhenTouched = true;
|
|
395
240
|
|
|
396
|
-
|
|
397
|
-
@Input() dropdownAttachToBody = true;
|
|
398
|
-
@Input() dropdownZIndex = 2000;
|
|
241
|
+
/* Dropdown plumbing */
|
|
242
|
+
@Input() dropdownAttachToBody = true;
|
|
243
|
+
@Input() dropdownZIndex = 2000;
|
|
244
|
+
|
|
245
|
+
/* Localization + RTL */
|
|
246
|
+
@Input('i18n') i18n?: IntlTelI18n;
|
|
247
|
+
@Input('telI18n') set telI18n(v: IntlTelI18n | undefined) { this.i18n = v; }
|
|
248
|
+
@Input('localizedCountries') localizedCountries?: CountryMap;
|
|
249
|
+
@Input('telLocalizedCountries') set telLocalizedCountries(v: CountryMap | undefined) { this.localizedCountries = v; }
|
|
250
|
+
@Input() clearAriaLabel = 'Clear phone number';
|
|
251
|
+
@Input() dir: 'ltr' | 'rtl' = 'ltr';
|
|
252
|
+
|
|
253
|
+
/* Placeholders (intl-tel-input) */
|
|
254
|
+
@Input() autoPlaceholder: 'off' | 'polite' | 'aggressive' = 'off'; // default OFF since no utils fallback
|
|
255
|
+
@Input() utilsScript?: string;
|
|
256
|
+
@Input() customPlaceholder?: (example: string, country: any) => string;
|
|
257
|
+
|
|
258
|
+
/* Digits-only controls */
|
|
259
|
+
@Input() digitsOnly = true;
|
|
260
|
+
@Input() allowLeadingPlus = true;
|
|
399
261
|
|
|
400
262
|
/* Outputs */
|
|
401
263
|
@Output() countryChange = new EventEmitter<{ iso2: CountryCode }>();
|
|
@@ -404,30 +266,23 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
404
266
|
|
|
405
267
|
/* Internal */
|
|
406
268
|
private iti: IntlTelInstance | null = null;
|
|
407
|
-
private onChange: (val: string | null) => void = () => {
|
|
408
|
-
};
|
|
409
|
-
private
|
|
410
|
-
};
|
|
269
|
+
private onChange: (val: string | null) => void = () => {};
|
|
270
|
+
private onTouchedCb: () => void = () => {};
|
|
271
|
+
private validatorChange?: () => void;
|
|
411
272
|
private lastEmittedValid = false;
|
|
412
273
|
private pendingWrite: string | null = null;
|
|
413
274
|
private touched = false;
|
|
414
275
|
|
|
415
|
-
readonly resolvedId =
|
|
276
|
+
readonly resolvedId = this.inputId || ('tel-' + Math.random().toString(36).slice(2));
|
|
416
277
|
|
|
417
278
|
constructor(
|
|
418
279
|
private readonly zone: NgZone,
|
|
419
280
|
private readonly tel: NgxsmkTelInputService,
|
|
420
281
|
@Inject(PLATFORM_ID) private readonly platformId: Object
|
|
421
|
-
) {
|
|
422
|
-
}
|
|
282
|
+
) {}
|
|
423
283
|
|
|
424
284
|
async ngAfterViewInit() {
|
|
425
285
|
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
286
|
await this.initIntlTelInput();
|
|
432
287
|
this.bindDomListeners();
|
|
433
288
|
|
|
@@ -441,9 +296,17 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
441
296
|
|
|
442
297
|
ngOnChanges(changes: SimpleChanges): void {
|
|
443
298
|
if (!isPlatformBrowser(this.platformId)) return;
|
|
444
|
-
const configChanged = [
|
|
445
|
-
|
|
446
|
-
|
|
299
|
+
const configChanged = [
|
|
300
|
+
'initialCountry','preferredCountries','onlyCountries',
|
|
301
|
+
'separateDialCode','allowDropdown','nationalMode',
|
|
302
|
+
'i18n','localizedCountries','dir',
|
|
303
|
+
'autoPlaceholder','utilsScript','customPlaceholder',
|
|
304
|
+
'digitsOnly','allowLeadingPlus'
|
|
305
|
+
].some(k => k in changes && !changes[k]?.firstChange);
|
|
306
|
+
if (configChanged && this.iti) {
|
|
307
|
+
this.reinitPlugin();
|
|
308
|
+
this.validatorChange?.();
|
|
309
|
+
}
|
|
447
310
|
}
|
|
448
311
|
|
|
449
312
|
ngOnDestroy(): void {
|
|
@@ -459,15 +322,8 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
459
322
|
}
|
|
460
323
|
this.setInputValue(val ?? '');
|
|
461
324
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
this.onChange = fn;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
registerOnTouched(fn: any): void {
|
|
468
|
-
this.onTouchedCb = fn;
|
|
469
|
-
}
|
|
470
|
-
|
|
325
|
+
registerOnChange(fn: any): void { this.onChange = fn; }
|
|
326
|
+
registerOnTouched(fn: any): void { this.onTouchedCb = fn; }
|
|
471
327
|
setDisabledState(isDisabled: boolean): void {
|
|
472
328
|
this.disabled = isDisabled;
|
|
473
329
|
if (this.inputRef) this.inputRef.nativeElement.disabled = isDisabled;
|
|
@@ -482,8 +338,9 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
482
338
|
this.lastEmittedValid = valid;
|
|
483
339
|
this.validityChange.emit(valid);
|
|
484
340
|
}
|
|
485
|
-
return valid ? null : {phoneInvalid: true};
|
|
341
|
+
return valid ? null : { phoneInvalid: true };
|
|
486
342
|
}
|
|
343
|
+
registerOnValidatorChange(fn: () => void): void { this.validatorChange = fn; }
|
|
487
344
|
|
|
488
345
|
// ----- Public helpers -----
|
|
489
346
|
focus(): void {
|
|
@@ -493,14 +350,12 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
493
350
|
queueMicrotask(() => el.setSelectionRange(0, el.value.length));
|
|
494
351
|
}
|
|
495
352
|
}
|
|
496
|
-
|
|
497
353
|
selectCountry(iso2: CountryCode): void {
|
|
498
354
|
if (this.iti) {
|
|
499
355
|
this.iti.setCountry(iso2.toLowerCase());
|
|
500
356
|
this.handleInput();
|
|
501
357
|
}
|
|
502
358
|
}
|
|
503
|
-
|
|
504
359
|
clearInput() {
|
|
505
360
|
this.setInputValue('');
|
|
506
361
|
this.handleInput();
|
|
@@ -509,7 +364,11 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
509
364
|
|
|
510
365
|
// ----- Plugin wiring -----
|
|
511
366
|
private async initIntlTelInput() {
|
|
512
|
-
const [{default: intlTelInput}] = await Promise.all([import('intl-tel-input')]);
|
|
367
|
+
const [{ default: intlTelInput }] = await Promise.all([import('intl-tel-input')]);
|
|
368
|
+
|
|
369
|
+
const toLowerKeys = (m?: CountryMap) =>
|
|
370
|
+
m ? Object.fromEntries(Object.entries(m).map(([k, v]) => [k.toLowerCase(), v])) : undefined;
|
|
371
|
+
|
|
513
372
|
const config: any = {
|
|
514
373
|
initialCountry: this.initialCountry === 'auto' ? 'auto' : (this.initialCountry?.toLowerCase() || 'us'),
|
|
515
374
|
preferredCountries: (this.preferredCountries ?? []).map(c => c.toLowerCase()),
|
|
@@ -518,14 +377,25 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
518
377
|
allowDropdown: this.allowDropdown,
|
|
519
378
|
separateDialCode: this.separateDialCode,
|
|
520
379
|
geoIpLookup: (cb: (iso2: string) => void) => cb('us'),
|
|
521
|
-
|
|
380
|
+
|
|
381
|
+
// placeholders — NOTE: no CDN fallback anymore
|
|
382
|
+
autoPlaceholder: this.autoPlaceholder, // 'off' | 'polite' | 'aggressive'
|
|
383
|
+
utilsScript: this.utilsScript,
|
|
384
|
+
customPlaceholder: this.customPlaceholder,
|
|
385
|
+
|
|
386
|
+
// localization
|
|
387
|
+
i18n: this.i18n,
|
|
388
|
+
localizedCountries: toLowerKeys(this.localizedCountries),
|
|
389
|
+
|
|
390
|
+
// dropdown container
|
|
522
391
|
dropdownContainer: this.dropdownAttachToBody && typeof document !== 'undefined' ? document.body : undefined
|
|
523
392
|
};
|
|
393
|
+
|
|
524
394
|
this.zone.runOutsideAngular(() => {
|
|
525
395
|
this.iti = intlTelInput(this.inputRef.nativeElement, config);
|
|
526
396
|
});
|
|
527
397
|
|
|
528
|
-
//
|
|
398
|
+
// z-index for dropdown
|
|
529
399
|
(this.inputRef.nativeElement as HTMLElement).style.setProperty('--tel-dd-z', String(this.dropdownZIndex));
|
|
530
400
|
}
|
|
531
401
|
|
|
@@ -553,16 +423,74 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
553
423
|
}
|
|
554
424
|
}
|
|
555
425
|
|
|
426
|
+
// ----- Input filtering (digits-only) -----
|
|
427
|
+
private sanitizeDigits(value: string): string {
|
|
428
|
+
if (!this.digitsOnly) return value;
|
|
429
|
+
let v = value.replace(/[^\d+]/g, ''); // keep digits and pluses
|
|
430
|
+
// allow only ONE leading + (if enabled)
|
|
431
|
+
if (this.allowLeadingPlus) {
|
|
432
|
+
const hasLeadingPlus = v.startsWith('+');
|
|
433
|
+
v = (hasLeadingPlus ? '+' : '') + v.replace(/\+/g, '');
|
|
434
|
+
} else {
|
|
435
|
+
v = v.replace(/\+/g, '');
|
|
436
|
+
}
|
|
437
|
+
return v;
|
|
438
|
+
}
|
|
439
|
+
|
|
556
440
|
private bindDomListeners() {
|
|
557
441
|
const el = this.inputRef.nativeElement;
|
|
442
|
+
|
|
558
443
|
this.zone.runOutsideAngular(() => {
|
|
559
|
-
|
|
444
|
+
// prevent invalid chars while typing
|
|
445
|
+
el.addEventListener('beforeinput', (ev: InputEvent) => {
|
|
446
|
+
if (!this.digitsOnly) return;
|
|
447
|
+
const data = (ev as any).data as string | null;
|
|
448
|
+
|
|
449
|
+
// allow deletions, cuts, etc.
|
|
450
|
+
if (!data || ev.inputType !== 'insertText') return;
|
|
451
|
+
|
|
452
|
+
const pos = el.selectionStart ?? 0;
|
|
453
|
+
const isDigit = data >= '0' && data <= '9';
|
|
454
|
+
const isPlusAtStart = this.allowLeadingPlus && data === '+' && pos === 0 && !el.value.includes('+');
|
|
455
|
+
|
|
456
|
+
if (!isDigit && !isPlusAtStart) ev.preventDefault();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// sanitize pastes
|
|
460
|
+
el.addEventListener('paste', (e: ClipboardEvent) => {
|
|
461
|
+
if (!this.digitsOnly) return;
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
const text = (e.clipboardData || (window as any).clipboardData).getData('text');
|
|
464
|
+
const sanitized = this.sanitizeDigits(text);
|
|
465
|
+
const start = el.selectionStart ?? el.value.length;
|
|
466
|
+
const end = el.selectionEnd ?? el.value.length;
|
|
467
|
+
el.setRangeText(sanitized, start, end, 'end');
|
|
468
|
+
queueMicrotask(() => this.handleInput());
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// catch any remaining non-digit changes (e.g., programmatic)
|
|
472
|
+
el.addEventListener('input', () => {
|
|
473
|
+
if (this.digitsOnly) {
|
|
474
|
+
const val = el.value;
|
|
475
|
+
const sanitized = this.sanitizeDigits(val);
|
|
476
|
+
if (val !== sanitized) {
|
|
477
|
+
const caret = el.selectionStart ?? sanitized.length;
|
|
478
|
+
el.value = sanitized;
|
|
479
|
+
el.setSelectionRange(caret, caret);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
this.handleInput();
|
|
483
|
+
});
|
|
484
|
+
|
|
560
485
|
el.addEventListener('countrychange', () => {
|
|
561
486
|
const iso2 = this.currentIso2();
|
|
562
|
-
this.zone.run(() =>
|
|
487
|
+
this.zone.run(() => {
|
|
488
|
+
this.countryChange.emit({ iso2 });
|
|
489
|
+
this.validatorChange?.();
|
|
490
|
+
});
|
|
563
491
|
this.handleInput();
|
|
564
492
|
});
|
|
565
|
-
|
|
493
|
+
|
|
566
494
|
el.addEventListener('blur', () => this.onBlur());
|
|
567
495
|
});
|
|
568
496
|
}
|
|
@@ -575,7 +503,7 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
575
503
|
if (!raw) return;
|
|
576
504
|
const parsed = this.tel.parse(raw, this.currentIso2());
|
|
577
505
|
if (this.nationalMode && parsed.national) {
|
|
578
|
-
this.setInputValue(parsed.national.replace(/\s{2,}/g, ' '));
|
|
506
|
+
this.setInputValue((parsed.national || '').replace(/\s{2,}/g, ' '));
|
|
579
507
|
}
|
|
580
508
|
}
|
|
581
509
|
|
|
@@ -591,7 +519,7 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
591
519
|
const iso2 = this.currentIso2();
|
|
592
520
|
const parsed = this.tel.parse(raw, iso2);
|
|
593
521
|
this.zone.run(() => this.onChange(parsed.e164)); // E.164 or null
|
|
594
|
-
this.zone.run(() => this.inputChange.emit({raw, e164: parsed.e164, iso2}));
|
|
522
|
+
this.zone.run(() => this.inputChange.emit({ raw, e164: parsed.e164, iso2 }));
|
|
595
523
|
if (raw && this.nationalMode && parsed.national) {
|
|
596
524
|
const normalized = parsed.national.replace(/\s{2,}/g, ' ');
|
|
597
525
|
if (normalized !== raw) this.setInputValue(normalized);
|
|
@@ -603,7 +531,8 @@ export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDest
|
|
|
603
531
|
}
|
|
604
532
|
|
|
605
533
|
private currentIso2(): CountryCode {
|
|
606
|
-
const iso2 = (this.iti?.getSelectedCountryData?.().iso2 ?? this.initialCountry ?? 'US')
|
|
534
|
+
const iso2 = (this.iti?.getSelectedCountryData?.().iso2 ?? this.initialCountry ?? 'US')
|
|
535
|
+
.toString().toUpperCase();
|
|
607
536
|
return iso2 as CountryCode;
|
|
608
537
|
}
|
|
609
538
|
|
|
@@ -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
|
}
|