ngxsmk-tel-input 0.0.8 → 0.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 +132 -101
- package/dist/README.md +263 -0
- package/ng-package.json +10 -0
- package/package.json +22 -33
- package/src/lib/ngxsmk-tel-input.component.spec.ts +23 -0
- package/src/lib/ngxsmk-tel-input.component.ts +444 -0
- package/src/lib/ngxsmk-tel-input.service.spec.ts +16 -0
- package/src/lib/ngxsmk-tel-input.service.ts +17 -0
- package/src/public-api.ts +2 -0
- package/tsconfig.lib.json +13 -0
- package/tsconfig.lib.prod.json +9 -0
- package/tsconfig.spec.json +13 -0
- /package/{fesm2022 → dist/fesm2022}/ngxsmk-tel-input.mjs +0 -0
- /package/{fesm2022 → dist/fesm2022}/ngxsmk-tel-input.mjs.map +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{lib → dist/lib}/ngxsmk-tel-input.component.d.ts +0 -0
- /package/{lib → dist/lib}/ngxsmk-tel-input.service.d.ts +0 -0
- /package/{public-api.d.ts → dist/public-api.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,44 +1,56 @@
|
|
|
1
|
-
# ngxsmk-tel-input
|
|
1
|
+
# ngxsmk-tel-input — International Telephone Input for Angular
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/ngxsmk-tel-input)
|
|
4
|
+
[](https://www.npmjs.com/package/ngxsmk-tel-input)
|
|
5
|
+
[](https://github.com/toozuuu/ngxsmk-tel-input/blob/main/LICENSE)
|
|
6
|
+
[](https://bundlephobia.com/package/ngxsmk-tel-input)
|
|
7
|
+
[](https://github.com/toozuuu/ngxsmk-tel-input)
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
An Angular component for entering and validating **international telephone numbers**. It adds a country flag dropdown, formats input, and validates using real numbering rules.
|
|
10
|
+
|
|
11
|
+
* UI powered by [`intl-tel-input`](https://github.com/jackocnr/intl-tel-input)
|
|
12
|
+
* Parsing & validation via [`libphonenumber-js`](https://github.com/catamphetamine/libphonenumber-js)
|
|
13
|
+
* Implements Angular **ControlValueAccessor** (works with Reactive & Template‑driven Forms)
|
|
14
|
+
|
|
15
|
+
> Emits **E.164** by default (e.g. `+14155550123`). SSR‑safe via lazy, browser‑only import.
|
|
9
16
|
|
|
10
17
|
---
|
|
11
18
|
|
|
12
|
-
##
|
|
19
|
+
## Compatibility
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
| ngxsmk-tel-input | Angular |
|
|
22
|
+
| ---------------- | --------------- |
|
|
23
|
+
| `0.0.x` | `17.x` – `19.x` |
|
|
16
24
|
|
|
17
|
-
>
|
|
25
|
+
> The library declares `peerDependencies` of `@angular/* >=17 <20`.
|
|
18
26
|
|
|
19
27
|
---
|
|
20
28
|
|
|
21
|
-
##
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### 1) Install dependencies
|
|
22
32
|
|
|
23
33
|
```bash
|
|
24
34
|
npm i ngxsmk-tel-input intl-tel-input libphonenumber-js
|
|
25
35
|
```
|
|
26
36
|
|
|
27
|
-
### Add styles &
|
|
37
|
+
### 2) Add styles & flag assets (in your **app**, not the library)
|
|
28
38
|
|
|
29
|
-
Add
|
|
39
|
+
Add intl‑tel‑input CSS and copy its flag images via `angular.json`:
|
|
30
40
|
|
|
31
41
|
```jsonc
|
|
32
42
|
{
|
|
33
43
|
"projects": {
|
|
34
|
-
"
|
|
44
|
+
"your-app": {
|
|
35
45
|
"architect": {
|
|
36
46
|
"build": {
|
|
37
47
|
"options": {
|
|
38
48
|
"styles": [
|
|
39
|
-
"node_modules/intl-tel-input/build/css/intlTelInput.css"
|
|
49
|
+
"node_modules/intl-tel-input/build/css/intlTelInput.css",
|
|
50
|
+
"src/styles.css"
|
|
40
51
|
],
|
|
41
52
|
"assets": [
|
|
53
|
+
{ "glob": "**/*", "input": "src/assets" },
|
|
42
54
|
{ "glob": "**/*", "input": "node_modules/intl-tel-input/build/img", "output": "assets/intl-tel-input/img" }
|
|
43
55
|
]
|
|
44
56
|
}
|
|
@@ -49,7 +61,7 @@ Add `intl-tel-input` CSS and copy flag images via `angular.json`:
|
|
|
49
61
|
}
|
|
50
62
|
```
|
|
51
63
|
|
|
52
|
-
Optional
|
|
64
|
+
Optional (helps some bundlers resolve flags): add to your global styles
|
|
53
65
|
|
|
54
66
|
```css
|
|
55
67
|
.iti__flag { background-image: url("/assets/intl-tel-input/img/flags.png"); }
|
|
@@ -58,11 +70,13 @@ Optional flag URL override (put in your app’s global styles):
|
|
|
58
70
|
}
|
|
59
71
|
```
|
|
60
72
|
|
|
61
|
-
Restart
|
|
73
|
+
Restart your dev server after editing `angular.json`.
|
|
62
74
|
|
|
63
75
|
---
|
|
64
76
|
|
|
65
|
-
##
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
### Reactive Forms
|
|
66
80
|
|
|
67
81
|
```ts
|
|
68
82
|
// app.component.ts
|
|
@@ -85,7 +99,7 @@ import { NgxsmkTelInputComponent } from 'ngxsmk-tel-input';
|
|
|
85
99
|
(countryChange)="onCountry($event)">
|
|
86
100
|
</ngxsmk-tel-input>
|
|
87
101
|
|
|
88
|
-
<p
|
|
102
|
+
<p *ngIf="fg.get('phone')?.hasError('phoneInvalid') && fg.get('phone')?.touched">
|
|
89
103
|
Please enter a valid phone number.
|
|
90
104
|
</p>
|
|
91
105
|
|
|
@@ -96,137 +110,154 @@ import { NgxsmkTelInputComponent } from 'ngxsmk-tel-input';
|
|
|
96
110
|
export class AppComponent {
|
|
97
111
|
fg = this.fb.group({ phone: ['', Validators.required] });
|
|
98
112
|
constructor(private readonly fb: FormBuilder) {}
|
|
99
|
-
onCountry(e: { iso2:
|
|
113
|
+
onCountry(e: { iso2: any }) { console.log('Country changed:', e.iso2); }
|
|
100
114
|
}
|
|
101
115
|
```
|
|
102
116
|
|
|
103
|
-
**Value semantics**
|
|
104
|
-
|
|
105
|
-
* Valid → control value is **E.164** string (e.g. `+14155550123`)
|
|
106
|
-
* Empty/invalid → value is **`null`**; validator sets `{ phoneInvalid: true }`
|
|
117
|
+
**Value semantics:** The control emits **E.164** when valid, or `null` when empty/invalid.
|
|
107
118
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
## Template‑driven
|
|
119
|
+
### Template‑driven
|
|
111
120
|
|
|
112
121
|
```html
|
|
113
122
|
<form #f="ngForm">
|
|
114
123
|
<ngxsmk-tel-input name="phone" [(ngModel)]="phone"></ngxsmk-tel-input>
|
|
115
124
|
</form>
|
|
116
|
-
<!-- phone is an E.164 string or null -->
|
|
117
125
|
```
|
|
118
126
|
|
|
119
127
|
---
|
|
120
128
|
|
|
121
|
-
##
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
|
154
|
-
|
|
|
155
|
-
| `
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
### Public methods
|
|
129
|
+
## Options (Inputs)
|
|
130
|
+
|
|
131
|
+
| Option | Type | Default | Description |
|
|
132
|
+
| ---------------------- | -------------------------------------- | ---------------------- | ---------------------------------------------------------------------- |
|
|
133
|
+
| `initialCountry` | `CountryCode \| 'auto'` | `'US'` | Starting country. `'auto'` uses a simple geo‑IP stub (defaults to US). |
|
|
134
|
+
| `preferredCountries` | `CountryCode[]` | `['US','GB']` | Countries pinned at the top. |
|
|
135
|
+
| `onlyCountries` | `CountryCode[]` | — | Restrict selectable countries. |
|
|
136
|
+
| `nationalMode` | `boolean` | `false` | Display national format in the box. Value still emits E.164. |
|
|
137
|
+
| `separateDialCode` | `boolean` | `false` | Show dial code separately to the left. |
|
|
138
|
+
| `allowDropdown` | `boolean` | `true` | Enable/disable country dropdown. |
|
|
139
|
+
| `placeholder` | `string` | `'Enter phone number'` | Input placeholder. |
|
|
140
|
+
| `autocomplete` | `string` | `'tel'` | Native autocomplete attribute. |
|
|
141
|
+
| `disabled` | `boolean` | `false` | Disable the control. |
|
|
142
|
+
| `label` | `string` | — | Optional label text. |
|
|
143
|
+
| `hint` | `string` | — | Helper text shown below. |
|
|
144
|
+
| `errorText` | `string` | — | Custom error message. |
|
|
145
|
+
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Preset sizing. |
|
|
146
|
+
| `variant` | `'outline' \| 'filled' \| 'underline'` | `'outline'` | Visual style. |
|
|
147
|
+
| `showClear` | `boolean` | `true` | Show a clear (×) button when not empty. |
|
|
148
|
+
| `autoFocus` | `boolean` | `false` | Focus on init. |
|
|
149
|
+
| `selectOnFocus` | `boolean` | `false` | Select text on focus. |
|
|
150
|
+
| `formatOnBlur` | `boolean` | `true` | Pretty‑print on blur (national if `nationalMode`). |
|
|
151
|
+
| `showErrorWhenTouched` | `boolean` | `true` | Only show error styles after the control is touched. |
|
|
152
|
+
| `dropdownAttachToBody` | `boolean` | `true` | Attach dropdown to `<body>` to avoid clipping. |
|
|
153
|
+
| `dropdownZIndex` | `number` | `2000` | Z‑index for dropdown panel. |
|
|
154
|
+
|
|
155
|
+
> `CountryCode` is the ISO‑2 code from `libphonenumber-js` (e.g. `US`, `GB`).
|
|
156
|
+
|
|
157
|
+
### Outputs (Events)
|
|
158
|
+
|
|
159
|
+
| Event | Payload | Description |
|
|
160
|
+
| ---------------- | ---------------------------------------------------------- | ---------------------------------------- |
|
|
161
|
+
| `countryChange` | `{ iso2: CountryCode }` | Fires when the selected country changes. |
|
|
162
|
+
| `validityChange` | `boolean` | Emits when validity toggles. |
|
|
163
|
+
| `inputChange` | `{ raw: string; e164: string \| null; iso2: CountryCode }` | Emitted on each input. |
|
|
164
|
+
|
|
165
|
+
### Public Methods
|
|
160
166
|
|
|
161
167
|
* `focus(): void`
|
|
162
168
|
* `selectCountry(iso2: CountryCode): void`
|
|
163
169
|
|
|
164
170
|
---
|
|
165
171
|
|
|
166
|
-
##
|
|
172
|
+
## Supported Formats
|
|
173
|
+
|
|
174
|
+
* **E164** → `+41446681800`
|
|
175
|
+
* **International** (display) → `+41 44 668 18 00`
|
|
176
|
+
* **National** (display) → `044 668 18 00`
|
|
177
|
+
|
|
178
|
+
> The control **emits E.164** by default. If you need the currently typed format too, use `(inputChange)`.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Theming & Styling
|
|
167
183
|
|
|
168
|
-
|
|
184
|
+
This component exposes CSS variables for quick theming:
|
|
169
185
|
|
|
170
186
|
```html
|
|
171
187
|
<ngxsmk-tel-input style="
|
|
172
188
|
--tel-border:#cbd5e1;
|
|
173
|
-
--tel-ring:#
|
|
174
|
-
--tel-radius:
|
|
175
|
-
--tel-dd-item-hover: rgba(
|
|
189
|
+
--tel-ring:#2563eb;
|
|
190
|
+
--tel-radius:12px;
|
|
191
|
+
--tel-dd-item-hover: rgba(37,99,235,.08);
|
|
176
192
|
--tel-dd-z: 3000;
|
|
177
193
|
"></ngxsmk-tel-input>
|
|
178
194
|
```
|
|
179
195
|
|
|
180
|
-
Key
|
|
181
|
-
|
|
182
|
-
* Input: `--tel-bg`, `--tel-fg`, `--tel-border`, `--tel-border-hover`, `--tel-ring`, `--tel-placeholder`, `--tel-error`, `--tel-radius`, `--tel-focus-shadow`
|
|
183
|
-
* Dropdown: `--tel-dd-bg`, `--tel-dd-border`, `--tel-dd-shadow`, `--tel-dd-radius`, `--tel-dd-item-hover`, `--tel-dd-search-bg`, `--tel-dd-z`
|
|
196
|
+
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`.
|
|
184
197
|
|
|
185
|
-
Dark mode: wrap in a `.dark` container
|
|
198
|
+
Dark mode: wrap in a `.dark` container; tokens adapt.
|
|
186
199
|
|
|
187
200
|
---
|
|
188
201
|
|
|
189
202
|
## SSR
|
|
190
203
|
|
|
191
|
-
|
|
192
|
-
* No direct `window`/`document` usage on the server path.
|
|
204
|
+
The library guards `intl-tel-input` import behind `isPlatformBrowser`, so Angular Universal builds won’t try to access `window` during SSR.
|
|
193
205
|
|
|
194
206
|
---
|
|
195
207
|
|
|
196
|
-
##
|
|
197
|
-
|
|
198
|
-
* **Unstyled UI / bullets** → Add CSS + assets in `angular.json`, restart dev server.
|
|
199
|
-
* **Flags missing** → Verify the images were copied to `/assets/intl-tel-input/img` or add the CSS override.
|
|
200
|
-
* **`TS2307: Cannot find module 'ngxsmk-tel-input'`** → Build the lib first; or add a `paths` alias to your app’s `tsconfig` if consuming locally.
|
|
201
|
-
* **Peer dependency conflict** → App Angular version must satisfy `>=17 <20`.
|
|
208
|
+
## Local Development
|
|
202
209
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
## Local dev & publish
|
|
210
|
+
This repository is an Angular workspace with a library.
|
|
206
211
|
|
|
207
212
|
```bash
|
|
208
213
|
# Build the library
|
|
209
214
|
ng build ngxsmk-tel-input
|
|
210
215
|
|
|
211
|
-
#
|
|
216
|
+
# Try it in a demo app inside the workspace
|
|
217
|
+
ng serve demo
|
|
218
|
+
|
|
219
|
+
# Or pack and install in another app locally
|
|
212
220
|
cd dist/ngxsmk-tel-input && npm pack
|
|
213
|
-
# in your app
|
|
214
|
-
npm i ../path-to/dist/ngxsmk-tel-input/ngxsmk-tel-input-<version>.tgz
|
|
215
221
|
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
# in your other app
|
|
223
|
+
npm i ../path/to/dist/ngxsmk-tel-input/ngxsmk-tel-input-<version>.tgz
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Tip: you can also map a workspace path alias in `tsconfig.base.json`:
|
|
227
|
+
|
|
228
|
+
```jsonc
|
|
229
|
+
{
|
|
230
|
+
"compilerOptions": {
|
|
231
|
+
"paths": {
|
|
232
|
+
"ngxsmk-tel-input": ["dist/ngxsmk-tel-input"]
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
221
236
|
```
|
|
222
237
|
|
|
223
238
|
---
|
|
224
239
|
|
|
225
|
-
##
|
|
240
|
+
## Troubleshooting
|
|
226
241
|
|
|
227
|
-
|
|
242
|
+
* **Unstyled dropdown / bullets / missing flags** → Ensure CSS & assets are added in your app’s `angular.json`, then restart the dev server.
|
|
243
|
+
* **`TS2307: Cannot find module 'ngxsmk-tel-input'`** → Build the library first so `dist/ngxsmk-tel-input` exists (or install from npm).
|
|
244
|
+
* **Peer dependency conflict** → Your app must be Angular 17–19 to satisfy peers.
|
|
245
|
+
* **Dropdown clipped by parent** → Keep `dropdownAttachToBody=true` or raise `dropdownZIndex`.
|
|
228
246
|
|
|
229
|
-
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Contributing
|
|
250
|
+
|
|
251
|
+
PRs welcome! Please:
|
|
252
|
+
|
|
253
|
+
1. `npm ci` and `ng build`
|
|
254
|
+
2. Cover behavior changes with tests if possible
|
|
255
|
+
3. Update README for any API changes
|
|
256
|
+
|
|
257
|
+
This project is open to using the [all-contributors](https://github.com/all-contributors/all-contributors) spec. Contributions of any kind welcome!
|
|
230
258
|
|
|
231
|
-
|
|
232
|
-
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
[MIT](./LICENSE)
|
package/dist/README.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# ngxsmk-tel-input — International Telephone Input for Angular
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/ngxsmk-tel-input)
|
|
4
|
+
[](https://www.npmjs.com/package/ngxsmk-tel-input)
|
|
5
|
+
[](https://github.com/toozuuu/ngxsmk-tel-input/blob/main/LICENSE)
|
|
6
|
+
[](https://bundlephobia.com/package/ngxsmk-tel-input)
|
|
7
|
+
[](https://github.com/toozuuu/ngxsmk-tel-input)
|
|
8
|
+
|
|
9
|
+
An Angular component for entering and validating **international telephone numbers**. It adds a country flag dropdown, formats input, and validates using real numbering rules.
|
|
10
|
+
|
|
11
|
+
* UI powered by [`intl-tel-input`](https://github.com/jackocnr/intl-tel-input)
|
|
12
|
+
* Parsing & validation via [`libphonenumber-js`](https://github.com/catamphetamine/libphonenumber-js)
|
|
13
|
+
* Implements Angular **ControlValueAccessor** (works with Reactive & Template‑driven Forms)
|
|
14
|
+
|
|
15
|
+
> Emits **E.164** by default (e.g. `+14155550123`). SSR‑safe via lazy, browser‑only import.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Compatibility
|
|
20
|
+
|
|
21
|
+
| ngxsmk-tel-input | Angular |
|
|
22
|
+
| ---------------- | --------------- |
|
|
23
|
+
| `0.0.x` | `17.x` – `19.x` |
|
|
24
|
+
|
|
25
|
+
> The library declares `peerDependencies` of `@angular/* >=17 <20`.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### 1) Install dependencies
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm i ngxsmk-tel-input intl-tel-input libphonenumber-js
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2) Add styles & flag assets (in your **app**, not the library)
|
|
38
|
+
|
|
39
|
+
Add intl‑tel‑input CSS and copy its flag images via `angular.json`:
|
|
40
|
+
|
|
41
|
+
```jsonc
|
|
42
|
+
{
|
|
43
|
+
"projects": {
|
|
44
|
+
"your-app": {
|
|
45
|
+
"architect": {
|
|
46
|
+
"build": {
|
|
47
|
+
"options": {
|
|
48
|
+
"styles": [
|
|
49
|
+
"node_modules/intl-tel-input/build/css/intlTelInput.css",
|
|
50
|
+
"src/styles.css"
|
|
51
|
+
],
|
|
52
|
+
"assets": [
|
|
53
|
+
{ "glob": "**/*", "input": "src/assets" },
|
|
54
|
+
{ "glob": "**/*", "input": "node_modules/intl-tel-input/build/img", "output": "assets/intl-tel-input/img" }
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Optional (helps some bundlers resolve flags): add to your global styles
|
|
65
|
+
|
|
66
|
+
```css
|
|
67
|
+
.iti__flag { background-image: url("/assets/intl-tel-input/img/flags.png"); }
|
|
68
|
+
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
|
69
|
+
.iti__flag { background-image: url("/assets/intl-tel-input/img/flags@2x.png"); }
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Restart your dev server after editing `angular.json`.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
### Reactive Forms
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// app.component.ts
|
|
83
|
+
import { Component } from '@angular/core';
|
|
84
|
+
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
85
|
+
import { NgxsmkTelInputComponent } from 'ngxsmk-tel-input';
|
|
86
|
+
|
|
87
|
+
@Component({
|
|
88
|
+
selector: 'app-root',
|
|
89
|
+
standalone: true,
|
|
90
|
+
imports: [ReactiveFormsModule, NgxsmkTelInputComponent],
|
|
91
|
+
template: `
|
|
92
|
+
<form [formGroup]="fg" style="max-width:420px;display:grid;gap:12px">
|
|
93
|
+
<ngxsmk-tel-input
|
|
94
|
+
formControlName="phone"
|
|
95
|
+
label="Phone"
|
|
96
|
+
hint="Include area code"
|
|
97
|
+
[initialCountry]="'US'"
|
|
98
|
+
[preferredCountries]="['US','GB','AU']"
|
|
99
|
+
(countryChange)="onCountry($event)">
|
|
100
|
+
</ngxsmk-tel-input>
|
|
101
|
+
|
|
102
|
+
<p *ngIf="fg.get('phone')?.hasError('phoneInvalid') && fg.get('phone')?.touched">
|
|
103
|
+
Please enter a valid phone number.
|
|
104
|
+
</p>
|
|
105
|
+
|
|
106
|
+
<pre>Value: {{ fg.value | json }}</pre>
|
|
107
|
+
</form>
|
|
108
|
+
`
|
|
109
|
+
})
|
|
110
|
+
export class AppComponent {
|
|
111
|
+
fg = this.fb.group({ phone: ['', Validators.required] });
|
|
112
|
+
constructor(private readonly fb: FormBuilder) {}
|
|
113
|
+
onCountry(e: { iso2: any }) { console.log('Country changed:', e.iso2); }
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Value semantics:** The control emits **E.164** when valid, or `null` when empty/invalid.
|
|
118
|
+
|
|
119
|
+
### Template‑driven
|
|
120
|
+
|
|
121
|
+
```html
|
|
122
|
+
<form #f="ngForm">
|
|
123
|
+
<ngxsmk-tel-input name="phone" [(ngModel)]="phone"></ngxsmk-tel-input>
|
|
124
|
+
</form>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Options (Inputs)
|
|
130
|
+
|
|
131
|
+
| Option | Type | Default | Description |
|
|
132
|
+
| ---------------------- | -------------------------------------- | ---------------------- | ---------------------------------------------------------------------- |
|
|
133
|
+
| `initialCountry` | `CountryCode \| 'auto'` | `'US'` | Starting country. `'auto'` uses a simple geo‑IP stub (defaults to US). |
|
|
134
|
+
| `preferredCountries` | `CountryCode[]` | `['US','GB']` | Countries pinned at the top. |
|
|
135
|
+
| `onlyCountries` | `CountryCode[]` | — | Restrict selectable countries. |
|
|
136
|
+
| `nationalMode` | `boolean` | `false` | Display national format in the box. Value still emits E.164. |
|
|
137
|
+
| `separateDialCode` | `boolean` | `false` | Show dial code separately to the left. |
|
|
138
|
+
| `allowDropdown` | `boolean` | `true` | Enable/disable country dropdown. |
|
|
139
|
+
| `placeholder` | `string` | `'Enter phone number'` | Input placeholder. |
|
|
140
|
+
| `autocomplete` | `string` | `'tel'` | Native autocomplete attribute. |
|
|
141
|
+
| `disabled` | `boolean` | `false` | Disable the control. |
|
|
142
|
+
| `label` | `string` | — | Optional label text. |
|
|
143
|
+
| `hint` | `string` | — | Helper text shown below. |
|
|
144
|
+
| `errorText` | `string` | — | Custom error message. |
|
|
145
|
+
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Preset sizing. |
|
|
146
|
+
| `variant` | `'outline' \| 'filled' \| 'underline'` | `'outline'` | Visual style. |
|
|
147
|
+
| `showClear` | `boolean` | `true` | Show a clear (×) button when not empty. |
|
|
148
|
+
| `autoFocus` | `boolean` | `false` | Focus on init. |
|
|
149
|
+
| `selectOnFocus` | `boolean` | `false` | Select text on focus. |
|
|
150
|
+
| `formatOnBlur` | `boolean` | `true` | Pretty‑print on blur (national if `nationalMode`). |
|
|
151
|
+
| `showErrorWhenTouched` | `boolean` | `true` | Only show error styles after the control is touched. |
|
|
152
|
+
| `dropdownAttachToBody` | `boolean` | `true` | Attach dropdown to `<body>` to avoid clipping. |
|
|
153
|
+
| `dropdownZIndex` | `number` | `2000` | Z‑index for dropdown panel. |
|
|
154
|
+
|
|
155
|
+
> `CountryCode` is the ISO‑2 code from `libphonenumber-js` (e.g. `US`, `GB`).
|
|
156
|
+
|
|
157
|
+
### Outputs (Events)
|
|
158
|
+
|
|
159
|
+
| Event | Payload | Description |
|
|
160
|
+
| ---------------- | ---------------------------------------------------------- | ---------------------------------------- |
|
|
161
|
+
| `countryChange` | `{ iso2: CountryCode }` | Fires when the selected country changes. |
|
|
162
|
+
| `validityChange` | `boolean` | Emits when validity toggles. |
|
|
163
|
+
| `inputChange` | `{ raw: string; e164: string \| null; iso2: CountryCode }` | Emitted on each input. |
|
|
164
|
+
|
|
165
|
+
### Public Methods
|
|
166
|
+
|
|
167
|
+
* `focus(): void`
|
|
168
|
+
* `selectCountry(iso2: CountryCode): void`
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Supported Formats
|
|
173
|
+
|
|
174
|
+
* **E164** → `+41446681800`
|
|
175
|
+
* **International** (display) → `+41 44 668 18 00`
|
|
176
|
+
* **National** (display) → `044 668 18 00`
|
|
177
|
+
|
|
178
|
+
> The control **emits E.164** by default. If you need the currently typed format too, use `(inputChange)`.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Theming & Styling
|
|
183
|
+
|
|
184
|
+
This component exposes CSS variables for quick theming:
|
|
185
|
+
|
|
186
|
+
```html
|
|
187
|
+
<ngxsmk-tel-input style="
|
|
188
|
+
--tel-border:#cbd5e1;
|
|
189
|
+
--tel-ring:#2563eb;
|
|
190
|
+
--tel-radius:12px;
|
|
191
|
+
--tel-dd-item-hover: rgba(37,99,235,.08);
|
|
192
|
+
--tel-dd-z: 3000;
|
|
193
|
+
"></ngxsmk-tel-input>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
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`.
|
|
197
|
+
|
|
198
|
+
Dark mode: wrap in a `.dark` container; tokens adapt.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## SSR
|
|
203
|
+
|
|
204
|
+
The library guards `intl-tel-input` import behind `isPlatformBrowser`, so Angular Universal builds won’t try to access `window` during SSR.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Local Development
|
|
209
|
+
|
|
210
|
+
This repository is an Angular workspace with a library.
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
# Build the library
|
|
214
|
+
ng build ngxsmk-tel-input
|
|
215
|
+
|
|
216
|
+
# Try it in a demo app inside the workspace
|
|
217
|
+
ng serve demo
|
|
218
|
+
|
|
219
|
+
# Or pack and install in another app locally
|
|
220
|
+
cd dist/ngxsmk-tel-input && npm pack
|
|
221
|
+
|
|
222
|
+
# in your other app
|
|
223
|
+
npm i ../path/to/dist/ngxsmk-tel-input/ngxsmk-tel-input-<version>.tgz
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Tip: you can also map a workspace path alias in `tsconfig.base.json`:
|
|
227
|
+
|
|
228
|
+
```jsonc
|
|
229
|
+
{
|
|
230
|
+
"compilerOptions": {
|
|
231
|
+
"paths": {
|
|
232
|
+
"ngxsmk-tel-input": ["dist/ngxsmk-tel-input"]
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Troubleshooting
|
|
241
|
+
|
|
242
|
+
* **Unstyled dropdown / bullets / missing flags** → Ensure CSS & assets are added in your app’s `angular.json`, then restart the dev server.
|
|
243
|
+
* **`TS2307: Cannot find module 'ngxsmk-tel-input'`** → Build the library first so `dist/ngxsmk-tel-input` exists (or install from npm).
|
|
244
|
+
* **Peer dependency conflict** → Your app must be Angular 17–19 to satisfy peers.
|
|
245
|
+
* **Dropdown clipped by parent** → Keep `dropdownAttachToBody=true` or raise `dropdownZIndex`.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Contributing
|
|
250
|
+
|
|
251
|
+
PRs welcome! Please:
|
|
252
|
+
|
|
253
|
+
1. `npm ci` and `ng build`
|
|
254
|
+
2. Cover behavior changes with tests if possible
|
|
255
|
+
3. Update README for any API changes
|
|
256
|
+
|
|
257
|
+
This project is open to using the [all-contributors](https://github.com/all-contributors/all-contributors) spec. Contributions of any kind welcome!
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
[MIT](./LICENSE)
|
package/ng-package.json
ADDED
package/package.json
CHANGED
|
@@ -1,33 +1,22 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ngxsmk-tel-input",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Angular telephone input with intl-tel-input + libphonenumber-js",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"private": false,
|
|
7
|
-
"sideEffects": false,
|
|
8
|
-
"publishConfig": {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"@angular/
|
|
13
|
-
"@angular/
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"intl-tel-input": "^25.3.2",
|
|
19
|
-
"libphonenumber-js": "^1.12.10",
|
|
20
|
-
"tslib": "^2.3.0"
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
"typings": "index.d.ts",
|
|
24
|
-
"exports": {
|
|
25
|
-
"./package.json": {
|
|
26
|
-
"default": "./package.json"
|
|
27
|
-
},
|
|
28
|
-
".": {
|
|
29
|
-
"types": "./index.d.ts",
|
|
30
|
-
"default": "./fesm2022/ngxsmk-tel-input.mjs"
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "ngxsmk-tel-input",
|
|
3
|
+
"version": "0.0.9",
|
|
4
|
+
"description": "Angular telephone input with intl-tel-input + libphonenumber-js",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"publishConfig": { "access": "public" },
|
|
9
|
+
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"@angular/core": ">=17",
|
|
12
|
+
"@angular/common": ">=17",
|
|
13
|
+
"@angular/forms": ">=17",
|
|
14
|
+
"rxjs": ">=7.8.0"
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"intl-tel-input": "^25.3.2",
|
|
19
|
+
"libphonenumber-js": "^1.12.10",
|
|
20
|
+
"tslib": "^2.3.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { ngxsmkTelInputComponent } from './ngxsmk-tel-input.component';
|
|
4
|
+
|
|
5
|
+
describe('ngxsmkTelInputComponent', () => {
|
|
6
|
+
let component: ngxsmkTelInputComponent;
|
|
7
|
+
let fixture: ComponentFixture<ngxsmkTelInputComponent>;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await TestBed.configureTestingModule({
|
|
11
|
+
imports: [ngxsmkTelInputComponent]
|
|
12
|
+
})
|
|
13
|
+
.compileComponents();
|
|
14
|
+
|
|
15
|
+
fixture = TestBed.createComponent(ngxsmkTelInputComponent);
|
|
16
|
+
component = fixture.componentInstance;
|
|
17
|
+
fixture.detectChanges();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should create', () => {
|
|
21
|
+
expect(component).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AfterViewInit,
|
|
3
|
+
Component,
|
|
4
|
+
ElementRef,
|
|
5
|
+
EventEmitter,
|
|
6
|
+
forwardRef,
|
|
7
|
+
Inject,
|
|
8
|
+
Input,
|
|
9
|
+
NgZone,
|
|
10
|
+
OnChanges,
|
|
11
|
+
OnDestroy,
|
|
12
|
+
Output,
|
|
13
|
+
PLATFORM_ID,
|
|
14
|
+
SimpleChanges,
|
|
15
|
+
ViewChild
|
|
16
|
+
} from '@angular/core';
|
|
17
|
+
import { isPlatformBrowser, NgIf } from '@angular/common';
|
|
18
|
+
import {
|
|
19
|
+
AbstractControl,
|
|
20
|
+
ControlValueAccessor,
|
|
21
|
+
NG_VALIDATORS,
|
|
22
|
+
NG_VALUE_ACCESSOR,
|
|
23
|
+
ValidationErrors
|
|
24
|
+
} from '@angular/forms';
|
|
25
|
+
import type { CountryCode } from 'libphonenumber-js';
|
|
26
|
+
import { NgxsmkTelInputService } from './ngxsmk-tel-input.service';
|
|
27
|
+
|
|
28
|
+
type IntlTelInstance = any;
|
|
29
|
+
|
|
30
|
+
@Component({
|
|
31
|
+
selector: 'ngxsmk-tel-input',
|
|
32
|
+
standalone: true,
|
|
33
|
+
imports: [NgIf],
|
|
34
|
+
template: `
|
|
35
|
+
<div class="ngx-tel" [class.disabled]="disabled" [attr.data-size]="size" [attr.data-variant]="variant">
|
|
36
|
+
<label *ngIf="label" class="ngx-tel__label" [for]="resolvedId">{{ label }}</label>
|
|
37
|
+
|
|
38
|
+
<div class="ngx-tel__wrap" [class.has-error]="showError">
|
|
39
|
+
<div class="ngxsmk-tel-input__wrapper">
|
|
40
|
+
<input
|
|
41
|
+
#telInput
|
|
42
|
+
type="tel"
|
|
43
|
+
class="ngxsmk-tel-input__control"
|
|
44
|
+
[id]="resolvedId"
|
|
45
|
+
[attr.name]="name || null"
|
|
46
|
+
[attr.placeholder]="placeholder || null"
|
|
47
|
+
[attr.autocomplete]="autocomplete"
|
|
48
|
+
[disabled]="disabled"
|
|
49
|
+
[attr.aria-invalid]="showError ? 'true' : 'false'"
|
|
50
|
+
(blur)="onBlur()"
|
|
51
|
+
(focus)="onFocus()"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<button *ngIf="showClear && currentRaw()"
|
|
56
|
+
type="button"
|
|
57
|
+
class="ngx-tel__clear"
|
|
58
|
+
(click)="clearInput()"
|
|
59
|
+
[attr.aria-label]="'Clear phone number'">
|
|
60
|
+
×
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="ngx-tel__hint" *ngIf="hint && !showError">{{ hint }}</div>
|
|
65
|
+
<div class="ngx-tel__error" *ngIf="showError">{{ errorText || 'Please enter a valid phone number.' }}</div>
|
|
66
|
+
</div>
|
|
67
|
+
`,
|
|
68
|
+
providers: [
|
|
69
|
+
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NgxsmkTelInputComponent), multi: true },
|
|
70
|
+
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => NgxsmkTelInputComponent), multi: true }
|
|
71
|
+
],
|
|
72
|
+
styles: [`
|
|
73
|
+
/* ---------- Theme tokens ---------- */
|
|
74
|
+
:host {
|
|
75
|
+
--tel-bg: #fff;
|
|
76
|
+
--tel-fg: #0f172a;
|
|
77
|
+
--tel-border: #c0c0c0;
|
|
78
|
+
--tel-border-hover: #9aa0a6;
|
|
79
|
+
--tel-ring: #2563eb;
|
|
80
|
+
--tel-placeholder: #9ca3af;
|
|
81
|
+
--tel-error: #ef4444;
|
|
82
|
+
--tel-radius: 12px;
|
|
83
|
+
--tel-focus-shadow: 0 0 0 3px rgba(37, 99, 235, .25);
|
|
84
|
+
|
|
85
|
+
/* dropdown tokens */
|
|
86
|
+
--tel-dd-bg: var(--tel-bg);
|
|
87
|
+
--tel-dd-border: var(--tel-border);
|
|
88
|
+
--tel-dd-shadow: 0 24px 60px rgba(0,0,0,.18);
|
|
89
|
+
--tel-dd-radius: 12px;
|
|
90
|
+
--tel-dd-item-hover: rgba(37,99,235,.08);
|
|
91
|
+
--tel-dd-z: 2000;
|
|
92
|
+
--tel-dd-search-bg: rgba(148,163,184,.08);
|
|
93
|
+
|
|
94
|
+
display: block;
|
|
95
|
+
}
|
|
96
|
+
:host-context(.dark) {
|
|
97
|
+
--tel-bg: #0b0f17;
|
|
98
|
+
--tel-fg: #e5e7eb;
|
|
99
|
+
--tel-border: #334155;
|
|
100
|
+
--tel-border-hover: #475569;
|
|
101
|
+
--tel-ring: #60a5fa;
|
|
102
|
+
--tel-placeholder: #94a3b8;
|
|
103
|
+
|
|
104
|
+
--tel-dd-bg: #0f1521;
|
|
105
|
+
--tel-dd-border: #324056;
|
|
106
|
+
--tel-dd-search-bg: rgba(148,163,184,.12);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ---------- Structure ---------- */
|
|
110
|
+
.ngx-tel { width: 100%; color: var(--tel-fg); }
|
|
111
|
+
.ngx-tel.disabled { opacity: .7; cursor: not-allowed; }
|
|
112
|
+
|
|
113
|
+
.ngx-tel__label { display: inline-block; margin-bottom: 6px; font-size: .875rem; font-weight: 500; }
|
|
114
|
+
|
|
115
|
+
.ngx-tel__wrap { position: relative; }
|
|
116
|
+
|
|
117
|
+
.ngxsmk-tel-input__wrapper,
|
|
118
|
+
:host ::ng-deep .iti { width: 100%; }
|
|
119
|
+
|
|
120
|
+
.ngxsmk-tel-input__control {
|
|
121
|
+
width: 100%;
|
|
122
|
+
height: 40px;
|
|
123
|
+
font: inherit;
|
|
124
|
+
color: var(--tel-fg);
|
|
125
|
+
background: var(--tel-bg);
|
|
126
|
+
border: 1px solid var(--tel-border);
|
|
127
|
+
border-radius: var(--tel-radius);
|
|
128
|
+
padding: 10px 40px 10px 12px; /* room for clear button */
|
|
129
|
+
outline: none;
|
|
130
|
+
transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
|
|
131
|
+
}
|
|
132
|
+
.ngxsmk-tel-input__control::placeholder { color: var(--tel-placeholder); }
|
|
133
|
+
.ngxsmk-tel-input__control:hover { border-color: var(--tel-border-hover); }
|
|
134
|
+
.ngxsmk-tel-input__control:focus { border-color: var(--tel-ring); box-shadow: var(--tel-focus-shadow); }
|
|
135
|
+
|
|
136
|
+
/* Size presets */
|
|
137
|
+
[data-size="sm"] .ngxsmk-tel-input__control { height: 34px; font-size: 13px; padding: 6px 36px 6px 10px; border-radius: 10px; }
|
|
138
|
+
[data-size="lg"] .ngxsmk-tel-input__control { height: 46px; font-size: 16px; padding: 12px 44px 12px 14px; border-radius: 14px; }
|
|
139
|
+
|
|
140
|
+
/* Variants */
|
|
141
|
+
[data-variant="filled"] .ngxsmk-tel-input__control { background: rgba(148, 163, 184, .08); }
|
|
142
|
+
[data-variant="underline"] .ngxsmk-tel-input__control { border: 0; border-bottom: 2px solid var(--tel-border); border-radius: 0; padding-left: 0; padding-right: 34px; }
|
|
143
|
+
[data-variant="underline"] .ngxsmk-tel-input__control:focus { border-bottom-color: var(--tel-ring); box-shadow: none; }
|
|
144
|
+
|
|
145
|
+
/* ---------- intl-tel-input dropdown (deep selectors) ---------- */
|
|
146
|
+
:host ::ng-deep .iti__flag-container {
|
|
147
|
+
border-top-left-radius: var(--tel-radius);
|
|
148
|
+
border-bottom-left-radius: var(--tel-radius);
|
|
149
|
+
border: 1px solid var(--tel-border);
|
|
150
|
+
border-right: none;
|
|
151
|
+
background: var(--tel-bg);
|
|
152
|
+
}
|
|
153
|
+
:host ::ng-deep .iti__selected-flag { height: 100%; padding: 0 10px; display: inline-flex; align-items: center; }
|
|
154
|
+
|
|
155
|
+
/* Core dropdown panel */
|
|
156
|
+
:host ::ng-deep .iti__country-list {
|
|
157
|
+
background: var(--tel-dd-bg);
|
|
158
|
+
border: 1px solid var(--tel-dd-border);
|
|
159
|
+
border-radius: var(--tel-dd-radius);
|
|
160
|
+
box-shadow: var(--tel-dd-shadow);
|
|
161
|
+
max-height: min(50vh, 360px);
|
|
162
|
+
overflow: auto;
|
|
163
|
+
padding: 6px 0;
|
|
164
|
+
width: max(280px, 100%);
|
|
165
|
+
z-index: var(--tel-dd-z);
|
|
166
|
+
}
|
|
167
|
+
/* When attached to <body>, it's wrapped in .iti--container */
|
|
168
|
+
:host ::ng-deep .iti--container .iti__country-list {
|
|
169
|
+
z-index: var(--tel-dd-z);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Search input (sticky header) */
|
|
173
|
+
:host ::ng-deep .iti__search-input {
|
|
174
|
+
position: sticky; top: 0;
|
|
175
|
+
margin: 0; padding: 10px 12px;
|
|
176
|
+
width: 100%;
|
|
177
|
+
border: 0; border-bottom: 1px solid var(--tel-dd-border);
|
|
178
|
+
outline: none;
|
|
179
|
+
background: var(--tel-dd-search-bg);
|
|
180
|
+
color: var(--tel-fg);
|
|
181
|
+
}
|
|
182
|
+
:host ::ng-deep .iti__search-input::placeholder { color: var(--tel-placeholder); }
|
|
183
|
+
|
|
184
|
+
/* Rows: flag | country name | dial code (right) */
|
|
185
|
+
:host ::ng-deep .iti__country {
|
|
186
|
+
display: grid;
|
|
187
|
+
grid-template-columns: 28px 1fr auto;
|
|
188
|
+
align-items: center;
|
|
189
|
+
column-gap: .5rem;
|
|
190
|
+
padding: 10px 12px;
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
}
|
|
193
|
+
:host ::ng-deep .iti__flag-box { width: 28px; display: inline-flex; justify-content: center; }
|
|
194
|
+
:host ::ng-deep .iti__country-name { color: var(--tel-fg); }
|
|
195
|
+
:host ::ng-deep .iti__dial-code { color: var(--tel-placeholder); font-weight: 600; margin-left: 10px; }
|
|
196
|
+
:host ::ng-deep .iti__country:hover,
|
|
197
|
+
:host ::ng-deep .iti__country.iti__highlight { background: var(--tel-dd-item-hover); }
|
|
198
|
+
:host ::ng-deep .iti__country:focus { outline: 2px solid var(--tel-ring); outline-offset: -2px; }
|
|
199
|
+
|
|
200
|
+
:host ::ng-deep .iti__divider { margin: 6px 0; border-top: 1px dashed var(--tel-dd-border); }
|
|
201
|
+
|
|
202
|
+
/* Separate dial code pushes input text */
|
|
203
|
+
:host ::ng-deep .iti--separate-dial-code .ngxsmk-tel-input__control { padding-left: 56px; }
|
|
204
|
+
|
|
205
|
+
/* Custom scrollbar (WebKit/Chromium) */
|
|
206
|
+
:host ::ng-deep .iti__country-list::-webkit-scrollbar { width: 10px; }
|
|
207
|
+
:host ::ng-deep .iti__country-list::-webkit-scrollbar-thumb { background: rgba(148,163,184,.4); border-radius: 8px; }
|
|
208
|
+
:host ::ng-deep .iti__country-list::-webkit-scrollbar-track { background: transparent; }
|
|
209
|
+
|
|
210
|
+
/* Mobile tweak: make it breathe */
|
|
211
|
+
@media (max-width: 480px) {
|
|
212
|
+
:host ::ng-deep .iti__country-list { width: 100vw; max-width: 100vw; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* Clear button */
|
|
216
|
+
.ngx-tel__clear {
|
|
217
|
+
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
|
218
|
+
border: 0; background: transparent; font-size: 18px; line-height: 1;
|
|
219
|
+
width: 28px; height: 28px; border-radius: 50%; cursor: pointer; color: var(--tel-placeholder);
|
|
220
|
+
}
|
|
221
|
+
.ngx-tel__clear:hover { background: rgba(148, 163, 184, .15); }
|
|
222
|
+
|
|
223
|
+
/* Hint & Error */
|
|
224
|
+
.ngx-tel__hint { margin-top: 6px; font-size: 12px; color: var(--tel-placeholder); }
|
|
225
|
+
.ngx-tel__error { margin-top: 6px; font-size: 12px; color: var(--tel-error); }
|
|
226
|
+
.ngx-tel__wrap.has-error .ngxsmk-tel-input__control { border-color: var(--tel-error); box-shadow: 0 0 0 3px rgba(239, 68, 68, .15); }
|
|
227
|
+
`]
|
|
228
|
+
})
|
|
229
|
+
export class NgxsmkTelInputComponent implements AfterViewInit, OnChanges, OnDestroy, ControlValueAccessor {
|
|
230
|
+
@ViewChild('telInput', { static: true }) inputRef!: ElementRef<HTMLInputElement>;
|
|
231
|
+
|
|
232
|
+
/* Existing inputs */
|
|
233
|
+
@Input() initialCountry: CountryCode | 'auto' = 'US';
|
|
234
|
+
@Input() preferredCountries: CountryCode[] = ['US', 'GB'];
|
|
235
|
+
@Input() onlyCountries?: CountryCode[];
|
|
236
|
+
@Input() nationalMode = false;
|
|
237
|
+
@Input() separateDialCode = false;
|
|
238
|
+
@Input() allowDropdown = true;
|
|
239
|
+
|
|
240
|
+
@Input() placeholder = 'Enter phone number';
|
|
241
|
+
@Input() autocomplete = 'tel';
|
|
242
|
+
@Input() name?: string;
|
|
243
|
+
@Input() inputId?: string;
|
|
244
|
+
@Input() disabled = false;
|
|
245
|
+
|
|
246
|
+
/* New UI/UX inputs */
|
|
247
|
+
@Input() label?: string;
|
|
248
|
+
@Input() hint?: string;
|
|
249
|
+
@Input() errorText?: string;
|
|
250
|
+
@Input() size: 'sm' | 'md' | 'lg' = 'md';
|
|
251
|
+
@Input() variant: 'outline' | 'filled' | 'underline' = 'outline';
|
|
252
|
+
@Input() showClear = true;
|
|
253
|
+
@Input() autoFocus = false;
|
|
254
|
+
@Input() selectOnFocus = false;
|
|
255
|
+
@Input() formatOnBlur = true;
|
|
256
|
+
@Input() showErrorWhenTouched = true;
|
|
257
|
+
|
|
258
|
+
/** Dropdown plumbing */
|
|
259
|
+
@Input() dropdownAttachToBody = true; // append dropdown to <body> (escapes overflow/clip)
|
|
260
|
+
@Input() dropdownZIndex = 2000; // used by CSS var --tel-dd-z
|
|
261
|
+
|
|
262
|
+
/* Outputs */
|
|
263
|
+
@Output() countryChange = new EventEmitter<{ iso2: CountryCode }>();
|
|
264
|
+
@Output() validityChange = new EventEmitter<boolean>();
|
|
265
|
+
@Output() inputChange = new EventEmitter<{ raw: string; e164: string | null; iso2: CountryCode }>();
|
|
266
|
+
|
|
267
|
+
/* Internal */
|
|
268
|
+
private iti: IntlTelInstance | null = null;
|
|
269
|
+
private onChange: (val: string | null) => void = () => {};
|
|
270
|
+
private onTouchedCb: () => void = () => {};
|
|
271
|
+
private lastEmittedValid = false;
|
|
272
|
+
private pendingWrite: string | null = null;
|
|
273
|
+
private touched = false;
|
|
274
|
+
|
|
275
|
+
readonly resolvedId = (() => 'tel-' + Math.random().toString(36).slice(2))();
|
|
276
|
+
|
|
277
|
+
constructor(
|
|
278
|
+
private readonly zone: NgZone,
|
|
279
|
+
private readonly tel: NgxsmkTelInputService,
|
|
280
|
+
@Inject(PLATFORM_ID) private readonly platformId: Object
|
|
281
|
+
) {}
|
|
282
|
+
|
|
283
|
+
async ngAfterViewInit() {
|
|
284
|
+
if (!isPlatformBrowser(this.platformId)) return;
|
|
285
|
+
|
|
286
|
+
// set z-index via CSS var
|
|
287
|
+
(this as any).constructor; // no-op to keep TS calm
|
|
288
|
+
(this.inputRef.nativeElement.closest(':host') as any);
|
|
289
|
+
|
|
290
|
+
await this.initIntlTelInput();
|
|
291
|
+
this.bindDomListeners();
|
|
292
|
+
|
|
293
|
+
if (this.pendingWrite !== null) {
|
|
294
|
+
this.setInputValue(this.pendingWrite);
|
|
295
|
+
this.handleInput();
|
|
296
|
+
this.pendingWrite = null;
|
|
297
|
+
}
|
|
298
|
+
if (this.autoFocus) setTimeout(() => this.focus(), 0);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
302
|
+
if (!isPlatformBrowser(this.platformId)) return;
|
|
303
|
+
const configChanged = ['initialCountry','preferredCountries','onlyCountries','separateDialCode','allowDropdown','nationalMode']
|
|
304
|
+
.some(k => k in changes && !changes[k].firstChange);
|
|
305
|
+
if (configChanged && this.iti) this.reinitPlugin();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
ngOnDestroy(): void { this.destroyPlugin(); }
|
|
309
|
+
|
|
310
|
+
// ----- CVA -----
|
|
311
|
+
writeValue(val: string | null): void {
|
|
312
|
+
if (!this.inputRef) return;
|
|
313
|
+
if (!this.iti) { this.pendingWrite = val ?? ''; return; }
|
|
314
|
+
this.setInputValue(val ?? '');
|
|
315
|
+
}
|
|
316
|
+
registerOnChange(fn: any): void { this.onChange = fn; }
|
|
317
|
+
registerOnTouched(fn: any): void { this.onTouchedCb = fn; }
|
|
318
|
+
setDisabledState(isDisabled: boolean): void {
|
|
319
|
+
this.disabled = isDisabled;
|
|
320
|
+
if (this.inputRef) this.inputRef.nativeElement.disabled = isDisabled;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ----- Validator -----
|
|
324
|
+
validate(_: AbstractControl): ValidationErrors | null {
|
|
325
|
+
const raw = this.currentRaw();
|
|
326
|
+
if (!raw) return null;
|
|
327
|
+
const valid = this.tel.isValid(raw, this.currentIso2());
|
|
328
|
+
if (valid !== this.lastEmittedValid) {
|
|
329
|
+
this.lastEmittedValid = valid;
|
|
330
|
+
this.validityChange.emit(valid);
|
|
331
|
+
}
|
|
332
|
+
return valid ? null : { phoneInvalid: true };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ----- Public helpers -----
|
|
336
|
+
focus(): void {
|
|
337
|
+
this.inputRef?.nativeElement.focus();
|
|
338
|
+
if (this.selectOnFocus) {
|
|
339
|
+
const el = this.inputRef.nativeElement;
|
|
340
|
+
queueMicrotask(() => el.setSelectionRange(0, el.value.length));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
selectCountry(iso2: CountryCode): void {
|
|
344
|
+
if (this.iti) { this.iti.setCountry(iso2.toLowerCase()); this.handleInput(); }
|
|
345
|
+
}
|
|
346
|
+
clearInput() {
|
|
347
|
+
this.setInputValue(''); this.handleInput(); this.inputRef.nativeElement.focus();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ----- Plugin wiring -----
|
|
351
|
+
private async initIntlTelInput() {
|
|
352
|
+
const [{ default: intlTelInput }] = await Promise.all([ import('intl-tel-input') ]);
|
|
353
|
+
const config: any = {
|
|
354
|
+
initialCountry: this.initialCountry === 'auto' ? 'auto' : (this.initialCountry?.toLowerCase() || 'us'),
|
|
355
|
+
preferredCountries: (this.preferredCountries ?? []).map(c => c.toLowerCase()),
|
|
356
|
+
onlyCountries: (this.onlyCountries ?? []).map(c => c.toLowerCase()),
|
|
357
|
+
nationalMode: this.nationalMode,
|
|
358
|
+
allowDropdown: this.allowDropdown,
|
|
359
|
+
separateDialCode: this.separateDialCode,
|
|
360
|
+
geoIpLookup: (cb: (iso2: string) => void) => cb('us'),
|
|
361
|
+
utilsScript: undefined,
|
|
362
|
+
dropdownContainer: this.dropdownAttachToBody && typeof document !== 'undefined' ? document.body : undefined
|
|
363
|
+
};
|
|
364
|
+
this.zone.runOutsideAngular(() => { this.iti = intlTelInput(this.inputRef.nativeElement, config); });
|
|
365
|
+
|
|
366
|
+
// expose z-index var to host (so CSS picks it up)
|
|
367
|
+
(this.inputRef.nativeElement as HTMLElement).style.setProperty('--tel-dd-z', String(this.dropdownZIndex));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private reinitPlugin() {
|
|
371
|
+
const current = this.currentRaw();
|
|
372
|
+
this.destroyPlugin();
|
|
373
|
+
this.initIntlTelInput().then(() => {
|
|
374
|
+
if (current) { this.setInputValue(current); this.handleInput(); }
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private destroyPlugin() {
|
|
379
|
+
if (this.iti) { this.iti.destroy(); this.iti = null; }
|
|
380
|
+
if (this.inputRef?.nativeElement) {
|
|
381
|
+
const el = this.inputRef.nativeElement;
|
|
382
|
+
const clone = el.cloneNode(true) as HTMLInputElement;
|
|
383
|
+
el.parentNode?.replaceChild(clone, el);
|
|
384
|
+
(this.inputRef as any).nativeElement = clone;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private bindDomListeners() {
|
|
389
|
+
const el = this.inputRef.nativeElement;
|
|
390
|
+
this.zone.runOutsideAngular(() => {
|
|
391
|
+
el.addEventListener('input', () => this.handleInput());
|
|
392
|
+
el.addEventListener('countrychange', () => {
|
|
393
|
+
const iso2 = this.currentIso2();
|
|
394
|
+
this.zone.run(() => this.countryChange.emit({ iso2 }));
|
|
395
|
+
this.handleInput();
|
|
396
|
+
});
|
|
397
|
+
el.addEventListener('paste', () => queueMicrotask(() => this.handleInput()));
|
|
398
|
+
el.addEventListener('blur', () => this.onBlur());
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
onBlur() {
|
|
403
|
+
this.touched = true;
|
|
404
|
+
this.zone.run(() => this.onTouchedCb());
|
|
405
|
+
if (!this.formatOnBlur) return;
|
|
406
|
+
const raw = this.currentRaw();
|
|
407
|
+
if (!raw) return;
|
|
408
|
+
const parsed = this.tel.parse(raw, this.currentIso2());
|
|
409
|
+
if (this.nationalMode && parsed.national) {
|
|
410
|
+
this.setInputValue(parsed.national.replace(/\s{2,}/g, ' '));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
onFocus() {
|
|
415
|
+
if (this.selectOnFocus) {
|
|
416
|
+
const el = this.inputRef.nativeElement;
|
|
417
|
+
queueMicrotask(() => el.setSelectionRange(0, el.value.length));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private handleInput() {
|
|
422
|
+
const raw = this.currentRaw();
|
|
423
|
+
const iso2 = this.currentIso2();
|
|
424
|
+
const parsed = this.tel.parse(raw, iso2);
|
|
425
|
+
this.zone.run(() => this.onChange(parsed.e164)); // E.164 or null
|
|
426
|
+
this.zone.run(() => this.inputChange.emit({ raw, e164: parsed.e164, iso2 }));
|
|
427
|
+
if (raw && this.nationalMode && parsed.national) {
|
|
428
|
+
const normalized = parsed.national.replace(/\s{2,}/g, ' ');
|
|
429
|
+
if (normalized !== raw) this.setInputValue(normalized);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
currentRaw(): string { return (this.inputRef?.nativeElement.value ?? '').trim(); }
|
|
434
|
+
private currentIso2(): CountryCode {
|
|
435
|
+
const iso2 = (this.iti?.getSelectedCountryData?.().iso2 ?? this.initialCountry ?? 'US').toString().toUpperCase();
|
|
436
|
+
return iso2 as CountryCode;
|
|
437
|
+
}
|
|
438
|
+
private setInputValue(v: string) { this.inputRef.nativeElement.value = v ?? ''; }
|
|
439
|
+
|
|
440
|
+
get showError(): boolean {
|
|
441
|
+
const invalid = !!this.validate({} as AbstractControl);
|
|
442
|
+
return this.showErrorWhenTouched ? (this.touched && invalid) : invalid;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { ngxsmkTelInputService } from './ngxsmk-tel-input.service';
|
|
4
|
+
|
|
5
|
+
describe('ngxsmkTelInputService', () => {
|
|
6
|
+
let service: ngxsmkTelInputService;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
TestBed.configureTestingModule({});
|
|
10
|
+
service = TestBed.inject(ngxsmkTelInputService);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should be created', () => {
|
|
14
|
+
expect(service).toBeTruthy();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
|
|
3
|
+
|
|
4
|
+
@Injectable({ providedIn: 'root' })
|
|
5
|
+
export class NgxsmkTelInputService {
|
|
6
|
+
parse(input: string, iso2: CountryCode): { e164: string | null; national: string | null; isValid: boolean } {
|
|
7
|
+
const phone = parsePhoneNumberFromString(input, iso2);
|
|
8
|
+
if (!phone) return { e164: null, national: null, isValid: false };
|
|
9
|
+
const isValid = phone.isValid();
|
|
10
|
+
return { e164: isValid ? phone.number : null, national: phone.formatNational(), isValid };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
isValid(input: string, iso2: CountryCode): boolean {
|
|
14
|
+
const phone = parsePhoneNumberFromString(input, iso2);
|
|
15
|
+
return !!phone && phone.isValid();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|