import {
CdkConnectedOverlay,
CdkOverlayOrigin,
type ConnectedPosition,
} from '@angular/cdk/overlay';
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
forwardRef,
inject,
input,
model,
numberAttribute,
output,
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
type ControlValueAccessor,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
import { Subject, timer } from 'rxjs';
import { debounce, switchMap } from 'rxjs/operators';
import {
FORM_AFFIX_CLASS,
FORM_CONTROL_INNER_CLASS,
FORM_DISABLED_CLASS,
FORM_ERROR_CLASS,
FORM_FIELD_CLASS,
FORM_FIELD_ERROR_CLASS,
FORM_HINT_CLASS,
FORM_LABEL_CLASS,
FORM_OVERLAY_LIST_CLASS,
FORM_OVERLAY_PANEL_CLASS,
} from '../form-field.classes';
import type {
AddressPlace,
AddressPrediction,
} from './address-input.types';
import { GooglePlacesService } from './google-places.service';
let nextAddressInputId = 0;
/** Preferred panel placement — flips upward when there is no room below. */
const ADDRESS_PANEL_POSITIONS: ConnectedPosition[] = [
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 4,
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -4,
},
];
/**
* Google Places address field with suggestion dropdown.
*
* Requires {@link provideGooglePlaces} at bootstrap. Predictions come from
* Places API (New) REST (`places.googleapis.com`). Form value is a structured
* {@link AddressPlace} (or `null`). Selecting a suggestion also emits
* {@link placeSelected}. Typing emits {@link queryChange} with the visible text.
*
* @example
* ```html
*
*
*
* ```
*/
@Component({
selector: 'africanies-address-input',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CdkOverlayOrigin, CdkConnectedOverlay],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AddressInputComponent),
multi: true,
},
],
host: {
class: 'relative block',
},
template: `
@if (loading()) {
Searching…
} @else if (apiError(); as err) {
{{ err }}
} @else if (predictions().length === 0) {
No matches
} @else {
@for (prediction of predictions(); track prediction.placeId; let i = $index) {
}
}
@if (error(); as err) {
{{ err }}
} @else if (hint(); as h) {
{{ h }}
}
`,
})
/**
* Google Places address field with suggestion dropdown.
*/
export class AddressInputComponent implements ControlValueAccessor {
private readonly places = inject(GooglePlacesService);
private readonly destroyRef = inject(DestroyRef);
protected readonly controlId = `africanies-address-input-${++nextAddressInputId}`;
protected readonly hintId = `${this.controlId}-hint`;
protected readonly errorId = `${this.controlId}-error`;
protected readonly listboxId = `${this.controlId}-listbox`;
protected readonly labelClass = FORM_LABEL_CLASS;
protected readonly affixClass = FORM_AFFIX_CLASS;
protected readonly innerClass = FORM_CONTROL_INNER_CLASS;
protected readonly hintClass = FORM_HINT_CLASS;
protected readonly errorClass = FORM_ERROR_CLASS;
protected readonly overlayPanelClass = FORM_OVERLAY_PANEL_CLASS;
protected readonly overlayListClass = FORM_OVERLAY_LIST_CLASS;
protected readonly panelPositions = ADDRESS_PANEL_POSITIONS;
protected readonly minPanelWidth = 224;
private readonly triggerOrigin = viewChild(CdkOverlayOrigin);
private readonly querySubject = new Subject();
/**
* Visible field label.
*/
readonly label = input('');
/**
* Helper copy under the field. Hidden while {@link error} is set.
*/
readonly hint = input(undefined);
/**
* Field-level validation message.
*/
readonly error = input(null);
/**
* Native placeholder when the query is empty.
*/
readonly placeholder = input('');
/**
* ISO-3166-1 alpha-2 country codes to restrict predictions (e.g. `['ng']`).
*/
readonly countries = input(undefined);
/**
* Debounce for prediction requests (ms).
*/
readonly debounceMs = input(300, { transform: numberAttribute });
/**
* Selected place. Supports `[(value)]` and Reactive Forms via CVA.
*/
readonly value = model(null);
/**
* Emits when the user picks a suggestion (after place details resolve).
*/
readonly placeSelected = output();
/**
* Emits the visible query as the user types (including after a selection).
*/
readonly queryChange = output();
/**
* Optional host disable (in addition to CVA `setDisabledState`).
*/
readonly disabledInput = input(false, {
alias: 'disabled',
transform: booleanAttribute,
});
protected readonly cvaDisabled = signal(false);
protected readonly disabled = computed(
() => this.disabledInput() || this.cvaDisabled(),
);
protected readonly query = signal('');
protected readonly open = signal(false);
protected readonly loading = signal(false);
protected readonly predictions = signal([]);
protected readonly apiError = signal(null);
protected readonly activeIndex = signal(-1);
protected readonly panelWidth = signal(this.minPanelWidth);
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;
});
protected readonly activeOptionId = computed(() => {
const index = this.activeIndex();
if (index < 0 || !this.open()) {
return null;
}
return this.optionId(index);
});
private onChange: (value: AddressPlace | null) => void = () => undefined;
private onTouched: () => void = () => undefined;
constructor() {
effect(() => {
const formatted = this.value()?.formattedAddress?.trim() ?? '';
if (formatted) {
this.query.set(formatted);
}
});
this.querySubject
.pipe(
debounce(() => timer(Math.max(0, Number(this.debounceMs()) || 0))),
switchMap(async (text) => {
const trimmed = text.trim();
if (!trimmed) {
this.predictions.set([]);
this.apiError.set(null);
this.loading.set(false);
this.open.set(false);
return [] as AddressPrediction[];
}
this.loading.set(true);
this.open.set(true);
try {
const results = await this.places.getPredictions(
trimmed,
this.countries(),
);
this.apiError.set(null);
return results;
} catch (err) {
this.apiError.set(
err instanceof Error
? err.message
: 'Could not load address suggestions.',
);
return [] as AddressPrediction[];
} finally {
this.loading.set(false);
}
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((results) => {
this.predictions.set(results);
this.activeIndex.set(results.length ? 0 : -1);
if (this.query().trim().length > 0) {
this.open.set(true);
}
});
}
/**
* @param value - Form model value.
*/
writeValue(value: AddressPlace | null): void {
this.value.set(value);
this.query.set(value?.formattedAddress ?? '');
}
/**
* @param fn - Called when the selected place changes.
*/
registerOnChange(fn: (value: AddressPlace | null) => void): void {
this.onChange = fn;
}
/**
* @param fn - Called on blur.
*/
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/**
* @param isDisabled - When true, blocks editing.
*/
setDisabledState(isDisabled: boolean): void {
this.cvaDisabled.set(isDisabled);
}
protected optionId(index: number): string {
return `${this.controlId}-option-${index}`;
}
protected onInput(event: Event): void {
const next = (event.target as HTMLInputElement).value;
this.query.set(next);
this.queryChange.emit(next);
if (this.value() !== null) {
this.value.set(null);
this.onChange(null);
}
this.syncPanelWidth();
this.querySubject.next(next);
}
protected onFocus(): void {
this.syncPanelWidth();
if (this.query().trim() && this.predictions().length) {
this.open.set(true);
}
}
protected onBlur(): void {
this.onTouched();
}
protected onKeydown(event: KeyboardEvent): void {
if (!this.open() && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
if (this.predictions().length) {
this.open.set(true);
event.preventDefault();
}
return;
}
if (!this.open()) {
if (event.key === 'Escape') {
(event.target as HTMLInputElement).blur();
}
return;
}
const count = this.predictions().length;
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
if (!count) {
return;
}
this.activeIndex.set((this.activeIndex() + 1) % count);
break;
}
case 'ArrowUp': {
event.preventDefault();
if (!count) {
return;
}
this.activeIndex.set((this.activeIndex() - 1 + count) % count);
break;
}
case 'Enter': {
event.preventDefault();
const index = this.activeIndex();
const prediction = this.predictions()[index];
if (prediction) {
void this.selectPrediction(prediction);
}
break;
}
case 'Escape': {
event.preventDefault();
this.closePanel();
break;
}
default:
break;
}
}
protected async selectPrediction(prediction: AddressPrediction): Promise {
this.loading.set(true);
try {
const place = await this.places.getPlaceDetails(prediction.placeId);
if (!place) {
return;
}
this.query.set(place.formattedAddress);
this.queryChange.emit(place.formattedAddress);
this.value.set(place);
this.onChange(place);
this.placeSelected.emit(place);
this.closePanel();
this.predictions.set([]);
} finally {
this.loading.set(false);
}
}
protected closePanel(): void {
this.open.set(false);
this.activeIndex.set(-1);
}
protected onOutsideClick(event: MouseEvent): void {
const origin = this.triggerOrigin()?.elementRef.nativeElement as
| HTMLElement
| undefined;
if (origin?.contains(event.target as Node)) {
return;
}
this.closePanel();
}
protected onOverlayDetach(): void {
this.open.set(false);
}
private syncPanelWidth(): void {
const origin = this.triggerOrigin()?.elementRef.nativeElement as
| HTMLElement
| undefined;
const width = origin?.getBoundingClientRect().width ?? this.minPanelWidth;
this.panelWidth.set(Math.max(width, this.minPanelWidth));
}
}