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)); } }