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 }}
}
`,
})
/**
* 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;
}