@africanies/angular-web-sdk 0.1.10 → 0.1.12
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/.nx/cache/{14995846848874973576 → 14892813664845064572}/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +1634 -1016
- package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
- package/.nx/cache/{14995846848874973576 → 14892813664845064572}/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +261 -137
- package/.nx/cache/{14876618240301288353 → 7821397806208783174}/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +2641 -1011
- package/.nx/cache/7821397806208783174/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
- package/.nx/cache/{14876618240301288353 → 7821397806208783174}/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +460 -147
- package/.nx/cache/run.json +16 -16
- package/.nx/cache/terminalOutputs/{14876618240301288353 → 14892813664845064572} +2 -2
- package/.nx/cache/terminalOutputs/16208231681037145050 +85 -0
- package/.nx/cache/terminalOutputs/{1446890025183346611 → 17909810599991457481} +15 -7
- package/.nx/cache/terminalOutputs/{7978448102466861029 → 2927637324428060203} +41 -31
- package/.nx/cache/terminalOutputs/{17394769338046050914 → 4787481949164239892} +2 -2
- package/.nx/cache/terminalOutputs/{14995846848874973576 → 7821397806208783174} +2 -2
- package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db +0 -0
- package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-shm +0 -0
- package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-wal +0 -0
- package/.nx/workspace-data/d/daemon.log +3694 -0
- package/.nx/workspace-data/d/server-process.json +2 -2
- package/.nx/workspace-data/file-map.json +1508 -1478
- package/.nx/workspace-data/nx_files.nxt +0 -0
- package/.nx/workspace-data/project-graph.json +2 -2
- package/README.md +0 -17
- package/apps/playground/src/app/pages/forms-page.ts +70 -14
- package/apps/playground/src/app/snippets/forms.snippets.ts +41 -6
- package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +1149 -142
- package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -1
- package/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +205 -18
- package/libs/africanies-ui/src/index.ts +18 -0
- package/libs/africanies-ui/src/lib/forms/index.ts +20 -0
- package/libs/africanies-ui/src/lib/forms/phone-input/index.ts +21 -0
- package/libs/africanies-ui/src/lib/forms/phone-input/phone-codes.ts +264 -0
- package/libs/africanies-ui/src/lib/forms/phone-input/phone-input.component.ts +477 -0
- package/libs/africanies-ui/src/lib/forms/phone-input/phone-input.types.ts +35 -0
- package/libs/africanies-ui/src/lib/forms/phone-input/phone-input.utils.spec.ts +81 -0
- package/libs/africanies-ui/src/lib/forms/phone-input/phone-input.utils.ts +275 -0
- package/libs/africanies-ui/src/lib/modules/africanies-forms.module.ts +2 -0
- package/libs/africanies-ui/src/lib/navigation/segment/segment.component.ts +2 -1
- package/libs/africanies-ui/src/lib/navigation/shipping-mode-switch/shipping-mode-switch.component.spec.ts +15 -0
- package/libs/africanies-ui/src/lib/navigation/shipping-mode-switch/shipping-mode-switch.component.ts +6 -3
- package/libs/africanies-ui/src/lib/table/table.component.ts +70 -65
- package/package.json +1 -1
- package/.nx/cache/13466509747676776487/dist/libs/africanies-core/README.md +0 -85
- package/.nx/cache/13466509747676776487/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs +0 -4634
- package/.nx/cache/13466509747676776487/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs.map +0 -1
- package/.nx/cache/13466509747676776487/dist/libs/africanies-core/package.json +0 -31
- package/.nx/cache/13466509747676776487/dist/libs/africanies-core/types/africanies-africanies-core.d.ts +0 -2743
- package/.nx/cache/14416694322692463866/dist/libs/africanies-models/README.md +0 -60
- package/.nx/cache/14416694322692463866/dist/libs/africanies-models/fesm2022/africanies-africanies-models.mjs +0 -599
- package/.nx/cache/14416694322692463866/dist/libs/africanies-models/fesm2022/africanies-africanies-models.mjs.map +0 -1
- package/.nx/cache/14416694322692463866/dist/libs/africanies-models/package.json +0 -23
- package/.nx/cache/14416694322692463866/dist/libs/africanies-models/types/africanies-africanies-models.d.ts +0 -1452
- package/.nx/cache/14876618240301288353/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +0 -1
- package/.nx/cache/14995846848874973576/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +0 -1
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/README.md +0 -46
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +0 -221
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +0 -1
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/package.json +0 -35
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/tailwind-preset.cjs +0 -253
- package/.nx/cache/17626862048742598274/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +0 -166
- package/.nx/cache/6664915487114632770/dist/libs/africanies-icons/README.md +0 -69
- package/.nx/cache/6664915487114632770/dist/libs/africanies-icons/assets/icons.sprite.svg +0 -1844
- package/.nx/cache/6664915487114632770/dist/libs/africanies-icons/fesm2022/africanies-africanies-icons.mjs +0 -832
- package/.nx/cache/6664915487114632770/dist/libs/africanies-icons/fesm2022/africanies-africanies-icons.mjs.map +0 -1
- package/.nx/cache/6664915487114632770/dist/libs/africanies-icons/package.json +0 -30
- package/.nx/cache/6664915487114632770/dist/libs/africanies-icons/types/africanies-africanies-icons.d.ts +0 -108
- package/.nx/cache/6673686859704879504/dist/libs/africanies-theme/README.md +0 -46
- package/.nx/cache/6673686859704879504/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +0 -231
- package/.nx/cache/6673686859704879504/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +0 -1
- package/.nx/cache/6673686859704879504/dist/libs/africanies-theme/package.json +0 -35
- package/.nx/cache/6673686859704879504/dist/libs/africanies-theme/tailwind-preset.cjs +0 -262
- package/.nx/cache/6673686859704879504/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +0 -174
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/README.md +0 -85
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs +0 -4511
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs.map +0 -1
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/package.json +0 -31
- package/.nx/cache/9040451482998546630/dist/libs/africanies-core/types/africanies-africanies-core.d.ts +0 -2741
- package/.nx/cache/terminalOutputs/13466509747676776487 +0 -21
- package/.nx/cache/terminalOutputs/13555699813138069787 +0 -6
- package/.nx/cache/terminalOutputs/14061940909886036014 +0 -173
- package/.nx/cache/terminalOutputs/14416694322692463866 +0 -108
- package/.nx/cache/terminalOutputs/17626862048742598274 +0 -21
- package/.nx/cache/terminalOutputs/6337357866566791975 +0 -6
- package/.nx/cache/terminalOutputs/6664915487114632770 +0 -44
- package/.nx/cache/terminalOutputs/6673686859704879504 +0 -21
- package/.nx/cache/terminalOutputs/783228502946611950 +0 -19
- package/.nx/cache/terminalOutputs/9040451482998546630 +0 -21
- /package/.nx/cache/{14876618240301288353 → 14892813664845064572}/dist/libs/africanies-ui/README.md +0 -0
- /package/.nx/cache/{14876618240301288353 → 14892813664845064572}/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
- /package/.nx/cache/{14876618240301288353 → 14892813664845064572}/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
- /package/.nx/cache/{14876618240301288353 → 14892813664845064572}/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +0 -0
- /package/.nx/cache/{14876618240301288353 → 14892813664845064572}/dist/libs/africanies-ui/assets/carriers/dhl.svg +0 -0
- /package/.nx/cache/{14876618240301288353 → 14892813664845064572}/dist/libs/africanies-ui/package.json +0 -0
- /package/.nx/cache/{14995846848874973576 → 7821397806208783174}/dist/libs/africanies-ui/README.md +0 -0
- /package/.nx/cache/{14995846848874973576 → 7821397806208783174}/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
- /package/.nx/cache/{14995846848874973576 → 7821397806208783174}/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
- /package/.nx/cache/{14995846848874973576 → 7821397806208783174}/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +0 -0
- /package/.nx/cache/{14995846848874973576 → 7821397806208783174}/dist/libs/africanies-ui/assets/carriers/dhl.svg +0 -0
- /package/.nx/cache/{14995846848874973576 → 7821397806208783174}/dist/libs/africanies-ui/package.json +0 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterNextRender,
|
|
3
|
+
booleanAttribute,
|
|
4
|
+
ChangeDetectionStrategy,
|
|
5
|
+
Component,
|
|
6
|
+
computed,
|
|
7
|
+
effect,
|
|
8
|
+
ElementRef,
|
|
9
|
+
forwardRef,
|
|
10
|
+
inject,
|
|
11
|
+
Injector,
|
|
12
|
+
input,
|
|
13
|
+
model,
|
|
14
|
+
signal,
|
|
15
|
+
untracked,
|
|
16
|
+
viewChild,
|
|
17
|
+
} from '@angular/core';
|
|
18
|
+
import {
|
|
19
|
+
CdkConnectedOverlay,
|
|
20
|
+
CdkOverlayOrigin,
|
|
21
|
+
type ConnectedPosition,
|
|
22
|
+
} from '@angular/cdk/overlay';
|
|
23
|
+
import {
|
|
24
|
+
type ControlValueAccessor,
|
|
25
|
+
NG_VALUE_ACCESSOR,
|
|
26
|
+
} from '@angular/forms';
|
|
27
|
+
|
|
28
|
+
import { AfricaniesIconComponent } from '@africanies/africanies-icons';
|
|
29
|
+
import { countryFlagUrl } from '@africanies/africanies-core';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
FORM_DISABLED_CLASS,
|
|
33
|
+
FORM_ERROR_CLASS,
|
|
34
|
+
FORM_FIELD_CLASS,
|
|
35
|
+
FORM_FIELD_ERROR_CLASS,
|
|
36
|
+
FORM_HINT_CLASS,
|
|
37
|
+
FORM_LABEL_CLASS,
|
|
38
|
+
} from '../form-field.classes';
|
|
39
|
+
import { PHONE_MAX_NATIONAL_DIGITS } from './phone-codes';
|
|
40
|
+
import type { PhoneCountryOption, PhoneNumberValue } from './phone-input.types';
|
|
41
|
+
import {
|
|
42
|
+
buildPhoneNumberValue,
|
|
43
|
+
dialCodeForIso2,
|
|
44
|
+
filterPhoneCountries,
|
|
45
|
+
normalizePhoneIso2,
|
|
46
|
+
parsePhoneControlValue,
|
|
47
|
+
phoneCountryOptions,
|
|
48
|
+
phoneDigitsOnly,
|
|
49
|
+
phoneToE164,
|
|
50
|
+
removeDialCode,
|
|
51
|
+
} from './phone-input.utils';
|
|
52
|
+
|
|
53
|
+
let nextPhoneInputId = 0;
|
|
54
|
+
|
|
55
|
+
const PANEL_POSITIONS: ConnectedPosition[] = [
|
|
56
|
+
{
|
|
57
|
+
originX: 'start',
|
|
58
|
+
originY: 'bottom',
|
|
59
|
+
overlayX: 'start',
|
|
60
|
+
overlayY: 'top',
|
|
61
|
+
offsetY: 4,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
originX: 'start',
|
|
65
|
+
originY: 'top',
|
|
66
|
+
overlayX: 'start',
|
|
67
|
+
overlayY: 'bottom',
|
|
68
|
+
offsetY: -4,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* International phone field — flag + dial code + national number.
|
|
74
|
+
*
|
|
75
|
+
* Visual / behavioral cousin of classic `africanies-phone-field`
|
|
76
|
+
* (`ngx-intl-tel-input`), rebuilt for Angular 22 + AFRICANIES form chrome
|
|
77
|
+
* (no ngx peer-dep).
|
|
78
|
+
*
|
|
79
|
+
* **Value contract**
|
|
80
|
+
* - `[(value)]` / CVA → **E.164 string** (e.g. `+2348012345678`) so admin
|
|
81
|
+
* mappers stay `phone: string`.
|
|
82
|
+
* - `phoneValue` model mirrors the structured classic object when you need
|
|
83
|
+
* `countryCode` / `dialCode` in the parent.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```html
|
|
87
|
+
* <africanies-phone-input
|
|
88
|
+
* label="Phone"
|
|
89
|
+
* [countryIso2]="countryIso"
|
|
90
|
+
* [lockCountry]="true"
|
|
91
|
+
* [error]="phoneError()"
|
|
92
|
+
* [(value)]="phone"
|
|
93
|
+
* />
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
@Component({
|
|
97
|
+
selector: 'africanies-phone-input',
|
|
98
|
+
standalone: true,
|
|
99
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
100
|
+
imports: [AfricaniesIconComponent, CdkOverlayOrigin, CdkConnectedOverlay],
|
|
101
|
+
providers: [
|
|
102
|
+
{
|
|
103
|
+
provide: NG_VALUE_ACCESSOR,
|
|
104
|
+
useExisting: forwardRef(() => PhoneInputComponent),
|
|
105
|
+
multi: true,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
host: {
|
|
109
|
+
class: 'relative block',
|
|
110
|
+
},
|
|
111
|
+
template: `
|
|
112
|
+
<label [attr.for]="controlId" [class]="labelClass">{{ label() }}</label>
|
|
113
|
+
|
|
114
|
+
<div [class]="shellClass()">
|
|
115
|
+
<button
|
|
116
|
+
cdkOverlayOrigin
|
|
117
|
+
#dialOrigin="cdkOverlayOrigin"
|
|
118
|
+
type="button"
|
|
119
|
+
class="inline-flex h-full shrink-0 items-center gap-1.5 border-0 border-r border-control bg-transparent px-2.5 text-body-sm text-ink dark:border-white/25 dark:text-white disabled:cursor-not-allowed"
|
|
120
|
+
[class.pointer-events-none]="lockCountry()"
|
|
121
|
+
[attr.aria-expanded]="countryOpen()"
|
|
122
|
+
[attr.aria-haspopup]="'listbox'"
|
|
123
|
+
[attr.aria-label]="'Country dial code'"
|
|
124
|
+
[disabled]="disabled() || lockCountry()"
|
|
125
|
+
(click)="toggleCountryPicker($event)"
|
|
126
|
+
>
|
|
127
|
+
@if (flagUrl(); as src) {
|
|
128
|
+
<img
|
|
129
|
+
[src]="src"
|
|
130
|
+
alt=""
|
|
131
|
+
class="h-4 w-5 rounded-sm object-cover"
|
|
132
|
+
loading="lazy"
|
|
133
|
+
aria-hidden="true"
|
|
134
|
+
/>
|
|
135
|
+
}
|
|
136
|
+
<span class="tabular-nums">{{ dialCode() }}</span>
|
|
137
|
+
@if (!lockCountry()) {
|
|
138
|
+
<africanies-icon name="chevron-down" [size]="14" class="shrink-0 opacity-70" />
|
|
139
|
+
}
|
|
140
|
+
</button>
|
|
141
|
+
|
|
142
|
+
<input
|
|
143
|
+
[id]="controlId"
|
|
144
|
+
type="tel"
|
|
145
|
+
inputmode="tel"
|
|
146
|
+
autocomplete="tel-national"
|
|
147
|
+
class="min-h-0 h-full min-w-0 flex-1 border-0 bg-transparent px-3 py-0 text-body text-ink outline-none placeholder:text-neutral-500 disabled:cursor-not-allowed dark:text-white"
|
|
148
|
+
[value]="nationalDisplay()"
|
|
149
|
+
[disabled]="disabled()"
|
|
150
|
+
[attr.placeholder]="placeholder() || null"
|
|
151
|
+
[attr.maxlength]="maxLengthAttr()"
|
|
152
|
+
[attr.aria-invalid]="error() ? true : null"
|
|
153
|
+
[attr.aria-describedby]="describedBy()"
|
|
154
|
+
(input)="onNationalInput($event)"
|
|
155
|
+
(blur)="onBlur()"
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
@if (error(); as err) {
|
|
160
|
+
<p [id]="errorId" [class]="errorClass" role="alert">{{ err }}</p>
|
|
161
|
+
} @else if (hint(); as h) {
|
|
162
|
+
<p [id]="hintId" [class]="hintClass">{{ h }}</p>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
<ng-template
|
|
166
|
+
cdkConnectedOverlay
|
|
167
|
+
[cdkConnectedOverlayOrigin]="dialOrigin"
|
|
168
|
+
[cdkConnectedOverlayOpen]="countryOpen()"
|
|
169
|
+
[cdkConnectedOverlayPositions]="panelPositions"
|
|
170
|
+
[cdkConnectedOverlayWidth]="320"
|
|
171
|
+
[cdkConnectedOverlayPush]="true"
|
|
172
|
+
[cdkConnectedOverlayHasBackdrop]="true"
|
|
173
|
+
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
|
174
|
+
(backdropClick)="closeCountryPicker()"
|
|
175
|
+
(overlayOutsideClick)="closeCountryPicker()"
|
|
176
|
+
>
|
|
177
|
+
<div
|
|
178
|
+
class="flex w-full flex-col overflow-hidden rounded-md border border-border bg-white shadow-lg dark:border-white/15 dark:bg-ink-950"
|
|
179
|
+
role="listbox"
|
|
180
|
+
[attr.aria-label]="'Select country'"
|
|
181
|
+
>
|
|
182
|
+
<div class="border-b border-border p-2 dark:border-white/15">
|
|
183
|
+
<input
|
|
184
|
+
#countrySearch
|
|
185
|
+
type="search"
|
|
186
|
+
class="h-9 w-full rounded-control border border-control bg-transparent px-3 text-body-sm text-ink outline-none placeholder:text-neutral-500 focus:ring-2 focus:ring-inset focus:ring-focus dark:border-white/25 dark:text-white"
|
|
187
|
+
placeholder="Search country or code"
|
|
188
|
+
[value]="countryQuery()"
|
|
189
|
+
(input)="onCountryQuery($event)"
|
|
190
|
+
(keydown)="$event.stopPropagation()"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
<ul class="africanies-overlay-scroll m-0 max-h-56 list-none overflow-auto p-1">
|
|
194
|
+
@for (row of filteredCountries(); track row.iso2) {
|
|
195
|
+
<li>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left text-body-sm text-ink hover:bg-neutral-100 dark:text-white dark:hover:bg-white/10"
|
|
199
|
+
[class.bg-neutral-100]="row.iso2 === countryIso2()"
|
|
200
|
+
[class.dark:bg-white/10]="row.iso2 === countryIso2()"
|
|
201
|
+
role="option"
|
|
202
|
+
[attr.aria-selected]="row.iso2 === countryIso2()"
|
|
203
|
+
(click)="selectCountry(row)"
|
|
204
|
+
>
|
|
205
|
+
<img
|
|
206
|
+
[src]="row.flagUrl"
|
|
207
|
+
alt=""
|
|
208
|
+
class="h-4 w-5 shrink-0 rounded-sm object-cover"
|
|
209
|
+
loading="lazy"
|
|
210
|
+
aria-hidden="true"
|
|
211
|
+
/>
|
|
212
|
+
<span class="min-w-0 flex-1 truncate">{{ row.name }}</span>
|
|
213
|
+
<span class="shrink-0 tabular-nums text-neutral-600 dark:text-neutral-400">
|
|
214
|
+
{{ row.dialCode }}
|
|
215
|
+
</span>
|
|
216
|
+
</button>
|
|
217
|
+
</li>
|
|
218
|
+
} @empty {
|
|
219
|
+
<li class="px-3 py-4 text-center text-caption text-neutral-600 dark:text-neutral-400">
|
|
220
|
+
No countries match.
|
|
221
|
+
</li>
|
|
222
|
+
}
|
|
223
|
+
</ul>
|
|
224
|
+
</div>
|
|
225
|
+
</ng-template>
|
|
226
|
+
`,
|
|
227
|
+
})
|
|
228
|
+
export class PhoneInputComponent implements ControlValueAccessor {
|
|
229
|
+
protected readonly controlId = `africanies-phone-input-${++nextPhoneInputId}`;
|
|
230
|
+
protected readonly hintId = `${this.controlId}-hint`;
|
|
231
|
+
protected readonly errorId = `${this.controlId}-error`;
|
|
232
|
+
protected readonly labelClass = FORM_LABEL_CLASS;
|
|
233
|
+
protected readonly hintClass = FORM_HINT_CLASS;
|
|
234
|
+
protected readonly errorClass = FORM_ERROR_CLASS;
|
|
235
|
+
protected readonly panelPositions = PANEL_POSITIONS;
|
|
236
|
+
|
|
237
|
+
private readonly allCountries = phoneCountryOptions();
|
|
238
|
+
private readonly injector = inject(Injector);
|
|
239
|
+
private readonly countrySearch =
|
|
240
|
+
viewChild<ElementRef<HTMLInputElement>>('countrySearch');
|
|
241
|
+
|
|
242
|
+
/** Visible field label. */
|
|
243
|
+
readonly label = input('');
|
|
244
|
+
|
|
245
|
+
/** Helper under the field (hidden while {@link error} is set). */
|
|
246
|
+
readonly hint = input<string | undefined>(undefined);
|
|
247
|
+
|
|
248
|
+
/** Field-level validation message. */
|
|
249
|
+
readonly error = input<string | null>(null);
|
|
250
|
+
|
|
251
|
+
/** National-number placeholder. */
|
|
252
|
+
readonly placeholder = input('Phone number');
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Selected country ISO2. Parents set this from address/user country.
|
|
256
|
+
* Two-way so picking a country updates the parent.
|
|
257
|
+
*/
|
|
258
|
+
readonly countryIso2 = model('US');
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* When true, the flag/dial segment is not clickable (classic `resetISO`).
|
|
262
|
+
* Use for locked sender-country phones; set `false` for free country pick.
|
|
263
|
+
*/
|
|
264
|
+
readonly lockCountry = input(false, { transform: booleanAttribute });
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* E.164 string for `[(value)]` / reactive forms.
|
|
268
|
+
* Empty string when cleared.
|
|
269
|
+
*/
|
|
270
|
+
readonly value = model<string>('');
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Structured phone object (classic `PhoneNumberFormat` shape).
|
|
274
|
+
* `null` when the national field is empty.
|
|
275
|
+
*/
|
|
276
|
+
readonly phoneValue = model<PhoneNumberValue | null>(null);
|
|
277
|
+
|
|
278
|
+
readonly disabledInput = input(false, {
|
|
279
|
+
alias: 'disabled',
|
|
280
|
+
transform: booleanAttribute,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
protected readonly cvaDisabled = signal(false);
|
|
284
|
+
protected readonly disabled = computed(
|
|
285
|
+
() => this.disabledInput() || this.cvaDisabled(),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
protected readonly countryOpen = signal(false);
|
|
289
|
+
protected readonly countryQuery = signal('');
|
|
290
|
+
/** Digits currently typed in the national field. */
|
|
291
|
+
protected readonly nationalDigits = signal('');
|
|
292
|
+
|
|
293
|
+
protected readonly dialCode = computed(() =>
|
|
294
|
+
dialCodeForIso2(this.countryIso2()) || '+1',
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
protected readonly flagUrl = computed(() => {
|
|
298
|
+
const iso = normalizePhoneIso2(this.countryIso2());
|
|
299
|
+
return iso ? countryFlagUrl(iso, { width: 40 }) : '';
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
protected readonly nationalDisplay = computed(() => this.nationalDigits());
|
|
303
|
+
|
|
304
|
+
protected readonly filteredCountries = computed((): PhoneCountryOption[] =>
|
|
305
|
+
filterPhoneCountries(this.allCountries, this.countryQuery()),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
protected readonly shellClass = computed(() => {
|
|
309
|
+
let classes = FORM_FIELD_CLASS;
|
|
310
|
+
if (this.error()) {
|
|
311
|
+
classes += ` ${FORM_FIELD_ERROR_CLASS}`;
|
|
312
|
+
}
|
|
313
|
+
if (this.disabled()) {
|
|
314
|
+
classes += ` ${FORM_DISABLED_CLASS}`;
|
|
315
|
+
}
|
|
316
|
+
return classes;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
protected readonly describedBy = computed(() => {
|
|
320
|
+
if (this.error()) {
|
|
321
|
+
return this.errorId;
|
|
322
|
+
}
|
|
323
|
+
if (this.hint()) {
|
|
324
|
+
return this.hintId;
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
private onChange: (value: string) => void = () => undefined;
|
|
330
|
+
private onTouched: () => void = () => undefined;
|
|
331
|
+
/** True while applying an external `value` so emit does not loop. */
|
|
332
|
+
private syncingFromExternal = false;
|
|
333
|
+
|
|
334
|
+
constructor() {
|
|
335
|
+
// Template `[(value)]` / `[value]` updates the model but does not call CVA
|
|
336
|
+
// `writeValue`. Sync national digits whenever the external value or the
|
|
337
|
+
// parent-bound country changes (edit prepopulate + locked country forms).
|
|
338
|
+
effect(() => {
|
|
339
|
+
const external = this.value();
|
|
340
|
+
const iso = this.countryIso2();
|
|
341
|
+
const locked = this.lockCountry();
|
|
342
|
+
untracked(() => this.applyExternalValue(external, iso, locked));
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
protected maxLengthAttr(): number {
|
|
347
|
+
return PHONE_MAX_NATIONAL_DIGITS;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
writeValue(value: string | PhoneNumberValue | null): void {
|
|
351
|
+
this.applyExternalValue(value, this.countryIso2(), this.lockCountry());
|
|
352
|
+
const e164 =
|
|
353
|
+
this.phoneValue()?.e164Number ??
|
|
354
|
+
(typeof value === 'string' ? value.trim() : '');
|
|
355
|
+
this.syncingFromExternal = true;
|
|
356
|
+
this.value.set(e164);
|
|
357
|
+
this.syncingFromExternal = false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
registerOnChange(fn: (value: string) => void): void {
|
|
361
|
+
this.onChange = fn;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
registerOnTouched(fn: () => void): void {
|
|
365
|
+
this.onTouched = fn;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
setDisabledState(isDisabled: boolean): void {
|
|
369
|
+
this.cvaDisabled.set(isDisabled);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
protected toggleCountryPicker(event: Event): void {
|
|
373
|
+
event.preventDefault();
|
|
374
|
+
event.stopPropagation();
|
|
375
|
+
if (this.disabled() || this.lockCountry()) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.countryOpen.update((open) => !open);
|
|
379
|
+
if (this.countryOpen()) {
|
|
380
|
+
this.countryQuery.set('');
|
|
381
|
+
// Overlay attaches on the next render; focus search so typing filters.
|
|
382
|
+
afterNextRender(
|
|
383
|
+
() => this.countrySearch()?.nativeElement.focus(),
|
|
384
|
+
{ injector: this.injector },
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
protected closeCountryPicker(): void {
|
|
390
|
+
this.countryOpen.set(false);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
protected onCountryQuery(event: Event): void {
|
|
394
|
+
this.countryQuery.set((event.target as HTMLInputElement).value);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
protected selectCountry(row: PhoneCountryOption): void {
|
|
398
|
+
this.countryIso2.set(row.iso2);
|
|
399
|
+
this.closeCountryPicker();
|
|
400
|
+
this.emitFromState();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
protected onNationalInput(event: Event): void {
|
|
404
|
+
const raw = (event.target as HTMLInputElement).value;
|
|
405
|
+
const digits = phoneDigitsOnly(raw).slice(0, PHONE_MAX_NATIONAL_DIGITS);
|
|
406
|
+
this.nationalDigits.set(digits);
|
|
407
|
+
this.emitFromState();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
protected onBlur(): void {
|
|
411
|
+
this.onTouched();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private applyExternalValue(
|
|
415
|
+
value: string | PhoneNumberValue | null | undefined,
|
|
416
|
+
preferredIso2: string,
|
|
417
|
+
lockCountry: boolean,
|
|
418
|
+
): void {
|
|
419
|
+
const preferred = normalizePhoneIso2(preferredIso2) || 'US';
|
|
420
|
+
const parsed = parsePhoneControlValue(value, preferred);
|
|
421
|
+
// Locked forms keep the parent country; still strip that dial code from
|
|
422
|
+
// stored E.164 / international strings so the national field prefills.
|
|
423
|
+
const iso2 = lockCountry ? preferred : parsed.iso2;
|
|
424
|
+
const nationalDigits = lockCountry
|
|
425
|
+
? nationalDigitsForLockedCountry(value, preferred)
|
|
426
|
+
: parsed.nationalDigits;
|
|
427
|
+
|
|
428
|
+
if (
|
|
429
|
+
iso2 === normalizePhoneIso2(this.countryIso2()) &&
|
|
430
|
+
nationalDigits === this.nationalDigits()
|
|
431
|
+
) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
this.syncingFromExternal = true;
|
|
436
|
+
if (!lockCountry) {
|
|
437
|
+
this.countryIso2.set(iso2);
|
|
438
|
+
}
|
|
439
|
+
this.nationalDigits.set(nationalDigits);
|
|
440
|
+
this.phoneValue.set(buildPhoneNumberValue(iso2, nationalDigits));
|
|
441
|
+
this.syncingFromExternal = false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private emitFromState(): void {
|
|
445
|
+
if (this.syncingFromExternal) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const structured = buildPhoneNumberValue(
|
|
449
|
+
this.countryIso2(),
|
|
450
|
+
this.nationalDigits(),
|
|
451
|
+
);
|
|
452
|
+
const e164 = structured ? phoneToE164(structured) : '';
|
|
453
|
+
this.phoneValue.set(structured);
|
|
454
|
+
this.value.set(e164);
|
|
455
|
+
this.onChange(e164);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** National digits when the dial-code country is fixed by the parent. */
|
|
460
|
+
function nationalDigitsForLockedCountry(
|
|
461
|
+
value: string | PhoneNumberValue | null | undefined,
|
|
462
|
+
iso2: string,
|
|
463
|
+
): string {
|
|
464
|
+
if (value == null || value === '') {
|
|
465
|
+
return '';
|
|
466
|
+
}
|
|
467
|
+
if (typeof value === 'object') {
|
|
468
|
+
return (
|
|
469
|
+
phoneDigitsOnly(value.number) ||
|
|
470
|
+
phoneDigitsOnly(value.nationalNumber) ||
|
|
471
|
+
removeDialCode(value.e164Number, iso2)
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const stripped = removeDialCode(value, iso2);
|
|
475
|
+
// removeDialCode returns full digits when no dial matched — still fine as national.
|
|
476
|
+
return stripped;
|
|
477
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured phone value — same shape as classic `ngx-intl-tel-input` output
|
|
3
|
+
* (`PhoneNumberFormat` in legacy import-export apps).
|
|
4
|
+
*
|
|
5
|
+
* Prefer {@link PhoneNumberValue.e164Number} for API payloads.
|
|
6
|
+
*/
|
|
7
|
+
export interface PhoneNumberValue {
|
|
8
|
+
/** National digits as typed (may include spaces). */
|
|
9
|
+
number: string;
|
|
10
|
+
|
|
11
|
+
/** International display, e.g. `+234 801 234 5678`. */
|
|
12
|
+
internationalNumber: string;
|
|
13
|
+
|
|
14
|
+
/** National display, e.g. `0801 234 5678`. */
|
|
15
|
+
nationalNumber: string;
|
|
16
|
+
|
|
17
|
+
/** Canonical wire form, e.g. `+2348012345678`. */
|
|
18
|
+
e164Number: string;
|
|
19
|
+
|
|
20
|
+
/** ISO 3166-1 alpha-2, e.g. `NG`. */
|
|
21
|
+
countryCode: string;
|
|
22
|
+
|
|
23
|
+
/** Dial prefix including `+`, e.g. `+234`. */
|
|
24
|
+
dialCode: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Country row for the phone dial-code picker.
|
|
29
|
+
*/
|
|
30
|
+
export interface PhoneCountryOption {
|
|
31
|
+
iso2: string;
|
|
32
|
+
dialCode: string;
|
|
33
|
+
name: string;
|
|
34
|
+
flagUrl: string;
|
|
35
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildPhoneNumberValue,
|
|
3
|
+
filterPhoneCountries,
|
|
4
|
+
matchDialCode,
|
|
5
|
+
parsePhoneControlValue,
|
|
6
|
+
phoneCountryOptions,
|
|
7
|
+
phoneDigitsOnly,
|
|
8
|
+
phoneToE164,
|
|
9
|
+
removeDialCode,
|
|
10
|
+
} from './phone-input.utils';
|
|
11
|
+
|
|
12
|
+
describe('phone-input.utils', () => {
|
|
13
|
+
it('builds an E.164 structured value for Nigeria', () => {
|
|
14
|
+
const phone = buildPhoneNumberValue('NG', '8012345678');
|
|
15
|
+
expect(phone).toEqual({
|
|
16
|
+
number: '801 234 5678',
|
|
17
|
+
internationalNumber: '+234 801 234 5678',
|
|
18
|
+
nationalNumber: '0801 234 5678',
|
|
19
|
+
e164Number: '+2348012345678',
|
|
20
|
+
countryCode: 'NG',
|
|
21
|
+
dialCode: '+234',
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('matches dial codes preferring the hint for shared +1', () => {
|
|
26
|
+
const matched = matchDialCode('+18015551234', 'US');
|
|
27
|
+
expect(matched?.iso2).toBe('US');
|
|
28
|
+
expect(matched?.nationalDigits).toBe('8015551234');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('strips dial code for prefills', () => {
|
|
32
|
+
expect(removeDialCode('+2348012345678', 'NG')).toBe('8012345678');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('parses E.164 control values for edit prepopulate', () => {
|
|
36
|
+
expect(parsePhoneControlValue('+2348012345678', 'US')).toEqual({
|
|
37
|
+
iso2: 'NG',
|
|
38
|
+
nationalDigits: '8012345678',
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps national digits when the value has no dial code', () => {
|
|
43
|
+
expect(parsePhoneControlValue('08012345678', 'NG')).toEqual({
|
|
44
|
+
iso2: 'NG',
|
|
45
|
+
nationalDigits: '08012345678',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('normalizes structured or string phones to E.164', () => {
|
|
50
|
+
expect(phoneToE164('+234 801 234 5678')).toBe('+2348012345678');
|
|
51
|
+
expect(
|
|
52
|
+
phoneToE164({
|
|
53
|
+
number: '801 234 5678',
|
|
54
|
+
internationalNumber: '+234 801 234 5678',
|
|
55
|
+
nationalNumber: '0801 234 5678',
|
|
56
|
+
e164Number: '+2348012345678',
|
|
57
|
+
countryCode: 'NG',
|
|
58
|
+
dialCode: '+234',
|
|
59
|
+
}),
|
|
60
|
+
).toBe('+2348012345678');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('filters countries by name without matching every dial code', () => {
|
|
64
|
+
const countries = phoneCountryOptions();
|
|
65
|
+
const nigeria = filterPhoneCountries(countries, 'nigeria');
|
|
66
|
+
expect(nigeria.some((row) => row.iso2 === 'NG')).toBe(true);
|
|
67
|
+
expect(nigeria.length).toBeLessThan(countries.length);
|
|
68
|
+
expect(
|
|
69
|
+
nigeria.every((row) => row.name.toLowerCase().includes('nigeria')),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('filters countries by dial digits', () => {
|
|
74
|
+
const countries = phoneCountryOptions();
|
|
75
|
+
const matched = filterPhoneCountries(countries, '234');
|
|
76
|
+
expect(matched.some((row) => row.iso2 === 'NG')).toBe(true);
|
|
77
|
+
expect(
|
|
78
|
+
matched.every((row) => phoneDigitsOnly(row.dialCode).includes('234')),
|
|
79
|
+
).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|