import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition, } from '@angular/cdk/overlay'; import { afterNextRender, booleanAttribute, ChangeDetectionStrategy, Component, computed, effect, ElementRef, forwardRef, inject, Injector, input, model, signal, untracked, viewChild, } from '@angular/core'; import { type ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms'; import { countryFlagUrl } from '@africanies/africanies-core'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; import { FORM_DISABLED_CLASS, FORM_ERROR_CLASS, FORM_FIELD_CLASS, FORM_FIELD_ERROR_CLASS, FORM_HINT_CLASS, FORM_LABEL_CLASS, FORM_OVERLAY_PANEL_CLASS, FORM_OVERLAY_SEARCH_CLASS, } from '../form-field.classes'; import { PHONE_MAX_NATIONAL_DIGITS } from './phone-codes'; import type { PhoneCountryOption, PhoneNumberValue } from './phone-input.types'; import { buildPhoneNumberValue, dialCodeForIso2, filterPhoneCountries, normalizePhoneIso2, parsePhoneControlValue, phoneCountryOptions, phoneDigitsOnly, phoneToE164, removeDialCode, } from './phone-input.utils'; let nextPhoneInputId = 0; const PANEL_POSITIONS: ConnectedPosition[] = [ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4, }, { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4, }, ]; /** * International phone field — flag + dial code + national number. * * Visual / behavioral cousin of classic `africanies-phone-field` * (`ngx-intl-tel-input`), rebuilt for Angular 22 + AFRICANIES form chrome * (no ngx peer-dep). * * **Value contract** * - `[(value)]` / CVA → **E.164 string** (e.g. `+2348012345678`) so admin * mappers stay `phone: string`. * - `phoneValue` model mirrors the structured classic object when you need * `countryCode` / `dialCode` in the parent. * * @example * ```html * * ``` */ @Component({ selector: 'africanies-phone-input', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [AfricaniesIconComponent, CdkOverlayOrigin, CdkConnectedOverlay], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => PhoneInputComponent), multi: true, }, ], host: { class: 'relative block', }, template: `
@if (error(); as err) {

{{ err }}

} @else if (hint(); as h) {

{{ h }}

}
    @for (row of filteredCountries(); track row.iso2) {
  • } @empty {
  • No countries match.
  • }
`, }) /** * International phone field — flag + dial code + national number. */ export class PhoneInputComponent implements ControlValueAccessor { protected readonly controlId = `africanies-phone-input-${++nextPhoneInputId}`; protected readonly hintId = `${this.controlId}-hint`; protected readonly errorId = `${this.controlId}-error`; protected readonly labelClass = FORM_LABEL_CLASS; protected readonly overlayPanelClass = FORM_OVERLAY_PANEL_CLASS; protected readonly overlaySearchClass = FORM_OVERLAY_SEARCH_CLASS; protected readonly hintClass = FORM_HINT_CLASS; protected readonly errorClass = FORM_ERROR_CLASS; protected readonly panelPositions = PANEL_POSITIONS; private readonly allCountries = phoneCountryOptions(); private readonly injector = inject(Injector); private readonly countrySearch = viewChild>('countrySearch'); /** Visible field label. */ readonly label = input(''); /** Helper under the field (hidden while {@link error} is set). */ readonly hint = input(undefined); /** Field-level validation message. */ readonly error = input(null); /** National-number placeholder. */ readonly placeholder = input('Phone number'); /** * Selected country ISO2. Parents set this from address/user country. * Two-way so picking a country updates the parent. */ readonly countryIso2 = model('US'); /** * When true, the flag/dial segment is not clickable (classic `resetISO`). * Use for locked sender-country phones; set `false` for free country pick. */ readonly lockCountry = input(false, { transform: booleanAttribute }); /** * E.164 string for `[(value)]` / reactive forms. * Empty string when cleared. */ readonly value = model(''); /** * Structured phone object (classic `PhoneNumberFormat` shape). * `null` when the national field is empty. */ readonly phoneValue = model(null); readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute, }); protected readonly cvaDisabled = signal(false); protected readonly disabled = computed( () => this.disabledInput() || this.cvaDisabled(), ); protected readonly countryOpen = signal(false); protected readonly countryQuery = signal(''); /** Digits currently typed in the national field. */ protected readonly nationalDigits = signal(''); protected readonly dialCode = computed(() => dialCodeForIso2(this.countryIso2()) || '+1', ); protected readonly flagUrl = computed(() => { const iso = normalizePhoneIso2(this.countryIso2()); return iso ? countryFlagUrl(iso, { width: 40 }) : ''; }); protected readonly nationalDisplay = computed(() => this.nationalDigits()); protected readonly filteredCountries = computed((): PhoneCountryOption[] => filterPhoneCountries(this.allCountries, this.countryQuery()), ); protected readonly shellClass = computed(() => { let classes = FORM_FIELD_CLASS; if (this.error()) { classes += ` ${FORM_FIELD_ERROR_CLASS}`; } if (this.disabled()) { classes += ` ${FORM_DISABLED_CLASS}`; } return classes; }); protected readonly describedBy = computed(() => { if (this.error()) { return this.errorId; } if (this.hint()) { return this.hintId; } return null; }); private onChange: (value: string) => void = () => undefined; private onTouched: () => void = () => undefined; /** True while applying an external `value` so emit does not loop. */ private syncingFromExternal = false; constructor() { // Template `[(value)]` / `[value]` updates the model but does not call CVA // `writeValue`. Sync national digits whenever the external value or the // parent-bound country changes (edit prepopulate + locked country forms). effect(() => { const external = this.value(); const iso = this.countryIso2(); const locked = this.lockCountry(); untracked(() => this.applyExternalValue(external, iso, locked)); }); } protected maxLengthAttr(): number { return PHONE_MAX_NATIONAL_DIGITS; } writeValue(value: string | PhoneNumberValue | null): void { this.applyExternalValue(value, this.countryIso2(), this.lockCountry()); const e164 = this.phoneValue()?.e164Number ?? (typeof value === 'string' ? value.trim() : ''); this.syncingFromExternal = true; this.value.set(e164); this.syncingFromExternal = false; } registerOnChange(fn: (value: string) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(isDisabled: boolean): void { this.cvaDisabled.set(isDisabled); } protected toggleCountryPicker(event: Event): void { event.preventDefault(); event.stopPropagation(); if (this.disabled() || this.lockCountry()) { return; } this.countryOpen.update((open) => !open); if (this.countryOpen()) { this.countryQuery.set(''); // Overlay attaches on the next render; focus search so typing filters. afterNextRender( () => this.countrySearch()?.nativeElement.focus(), { injector: this.injector }, ); } } protected closeCountryPicker(): void { this.countryOpen.set(false); } protected onCountryQuery(event: Event): void { this.countryQuery.set((event.target as HTMLInputElement).value); } protected selectCountry(row: PhoneCountryOption): void { this.countryIso2.set(row.iso2); this.closeCountryPicker(); this.emitFromState(); } protected onNationalInput(event: Event): void { const raw = (event.target as HTMLInputElement).value; const digits = phoneDigitsOnly(raw).slice(0, PHONE_MAX_NATIONAL_DIGITS); this.nationalDigits.set(digits); this.emitFromState(); } protected onBlur(): void { this.onTouched(); } private applyExternalValue( value: string | PhoneNumberValue | null | undefined, preferredIso2: string, lockCountry: boolean, ): void { const preferred = normalizePhoneIso2(preferredIso2) || 'US'; const parsed = parsePhoneControlValue(value, preferred); // Locked forms keep the parent country; still strip that dial code from // stored E.164 / international strings so the national field prefills. const iso2 = lockCountry ? preferred : parsed.iso2; const nationalDigits = lockCountry ? nationalDigitsForLockedCountry(value, preferred) : parsed.nationalDigits; if ( iso2 === normalizePhoneIso2(this.countryIso2()) && nationalDigits === this.nationalDigits() ) { return; } this.syncingFromExternal = true; if (!lockCountry) { this.countryIso2.set(iso2); } this.nationalDigits.set(nationalDigits); this.phoneValue.set(buildPhoneNumberValue(iso2, nationalDigits)); this.syncingFromExternal = false; } private emitFromState(): void { if (this.syncingFromExternal) { return; } const structured = buildPhoneNumberValue( this.countryIso2(), this.nationalDigits(), ); const e164 = structured ? phoneToE164(structured) : ''; this.phoneValue.set(structured); this.value.set(e164); this.onChange(e164); } } /** National digits when the dial-code country is fixed by the parent. */ function nationalDigitsForLockedCountry( value: string | PhoneNumberValue | null | undefined, iso2: string, ): string { if (value == null || value === '') { return ''; } if (typeof value === 'object') { return ( phoneDigitsOnly(value.number) || phoneDigitsOnly(value.nationalNumber) || removeDialCode(value.e164Number, iso2) ); } const stripped = removeDialCode(value, iso2); // removeDialCode returns full digits when no dial matched — still fine as national. return stripped; }