Adressefelt med autocomplete i Angular

Byg en standalone Angular-komponent med HttpClient, debounceTime og switchMap, der foreslår danske adresser og afbryder forældede kald.

3 min Niveau: Øvet ✓ Kode testet

Denne guide bygger et adressefelt som en standalone-komponent til Angular 17 eller nyere. Den bruger HttpClient, reaktive formularer, signals og RxJS-operatorerne debounceTime og switchMap. Skabelonen bruger den indbyggede kontrolsyntaks (@if, @for).

Nøglen ligger i dit Angular-bundle, så den skal være en domænelåst browsernøgle. Se Browsernøgler og tilladte domæner. Vil du hellere kalde API'et fra din egen server, så se Next.js route handler som proxy: mønstret er det samme med en Express- eller .NET-backend.

1. Slå HttpClient til

Komponenten henter data med HttpClient. I en standalone-app registrerer du den i app.config.ts:

TypeScript#app-config
// app.config.ts
import { ApplicationConfig } from "@angular/core";
import { provideHttpClient, withFetch } from "@angular/common/http";

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withFetch())],
};

Læg browsernøglen i environment.ts. Alt i den fil ender i bundtet:

TypeScript#environment
// environments/environment.ts
export const environment = {
  danadresseBrowserKey: "dawa_live_din_browsernøgle", // låst til dine domæner
};

2. Komponenten

Hele søgelogikken er én RxJS-kæde:

  • debounceTime(200) venter, til brugeren holder en kort pause. Det sparer kald og holder dig under grænsen pr. besøgende.
  • distinctUntilChanged() springer over, hvis teksten er den samme som sidst.
  • switchMap afmelder det forrige HTTP-kald, når en ny værdi kommer. Med HttpClient betyder det, at forespørgslen bliver afbrudt, så et gammelt svar aldrig overskriver et nyt.
  • catchError inde i switchMap sørger for, at én fejl ikke afslutter hele strømmen. Feltet virker videre som almindeligt tekstfelt.
TypeScript#adresse-felt-component
import { Component, ElementRef, EventEmitter, Output, ViewChild, computed, inject, signal } from "@angular/core";
import { HttpClient, HttpParams } from "@angular/common/http";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { catchError, debounceTime, distinctUntilChanged, of, switchMap, tap } from "rxjs";
import { environment } from "../environments/environment";

export interface Forslag {
  type: "vejnavn" | "adgangsadresse" | "adresse";
  tekst: string;
  forslagstekst: string;
  caretpos: number;
  data: { id?: string; postnr?: string; postnrnavn?: string; [felt: string]: unknown };
}

let naesteId = 0;

@Component({
  selector: "app-adresse-felt",
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <label [for]="uid + '-input'">Adresse</label>
    <input #felt [id]="uid + '-input'" [formControl]="q"
           role="combobox" aria-autocomplete="list" autocomplete="off"
           [attr.aria-expanded]="vis()" [attr.aria-controls]="uid + '-list'"
           [attr.aria-activedescendant]="vis() && aktiv() >= 0 ? uid + '-' + aktiv() : null"
           (input)="aaben.set(true)" (keydown)="tast($event)" (blur)="aaben.set(false)" />
    @if (vis()) {
      <ul [id]="uid + '-list'" role="listbox" aria-label="Adresseforslag">
        @for (f of forslag(); track f.data.id ?? f.tekst; let i = $index) {
          <li [id]="uid + '-' + i" role="option" [attr.aria-selected]="i === aktiv()"
              (mousedown)="$event.preventDefault(); vaelg(f)">{{ f.forslagstekst }}</li>
        }
      </ul>
    }
    <div role="status" aria-live="polite" class="sr-only">
      {{ fejl() ? 'Adresseopslag er utilgængeligt. Skriv adressen manuelt.' : vis() ? forslag().length + ' forslag' : '' }}
    </div>
  `,
})
export class AdresseFeltComponent {
  @Output() valgt = new EventEmitter<Forslag>();
  @ViewChild("felt") felt!: ElementRef<HTMLInputElement>;

  private http = inject(HttpClient);
  readonly uid = `adr-${++naesteId}`;
  readonly q = new FormControl("", { nonNullable: true });
  readonly forslag = signal<Forslag[]>([]);
  readonly aktiv = signal(-1);
  readonly aaben = signal(false);
  readonly fejl = signal(false);
  readonly vis = computed(() => this.aaben() && this.forslag().length > 0);

  constructor() {
    this.q.valueChanges
      .pipe(
        debounceTime(200),
        distinctUntilChanged(),
        switchMap((tekst) => {
          if (!this.aaben() || tekst.trim().length < 2) return of<Forslag[]>([]);
          const params = new HttpParams()
            .set("q", tekst)
            .set("type", "adresse")
            .set("per_side", 8);
          return this.http
            .get<Forslag[]>("https://api.danadresse.dk/autocomplete", {
              params,
              headers: { "X-Api-Key": environment.danadresseBrowserKey },
            })
            .pipe(
              tap(() => this.fejl.set(false)),
              catchError(() => {
                this.fejl.set(true);
                return of<Forslag[]>([]);
              }),
            );
        }),
        takeUntilDestroyed(),
      )
      .subscribe((liste) => {
        this.forslag.set(liste);
        this.aktiv.set(-1);
      });
  }

  vaelg(f: Forslag): void {
    if (f.type !== "adresse") {
      // Vejnavn eller opgang: sæt teksten ind og søg videre fra caretpos.
      this.q.setValue(f.tekst);
      requestAnimationFrame(() => this.felt.nativeElement.setSelectionRange(f.caretpos, f.caretpos));
      return;
    }
    this.aaben.set(false);
    this.q.setValue(f.forslagstekst, { emitEvent: false }); // intet nyt kald
    this.valgt.emit(f);
  }

  tast(e: KeyboardEvent): void {
    const n = this.forslag().length;
    if (!this.vis()) return;
    if (e.key === "ArrowDown") { e.preventDefault(); this.aktiv.update((i) => (i + 1) % n); }
    else if (e.key === "ArrowUp") { e.preventDefault(); this.aktiv.update((i) => (i <= 0 ? n - 1 : i - 1)); }
    else if (e.key === "Enter" && this.aktiv() >= 0) { e.preventDefault(); this.vaelg(this.forslag()[this.aktiv()]); }
    else if (e.key === "Escape") { this.aaben.set(false); }
  }
}

/autocomplete svarer i tre faser: vejnavn, husnummer og til sidst etage og dør. Derfor udsender komponenten kun valgt, når forslagets type er adresse. Et vejnavn sætter den ind i feltet og søger videre.

setValue(…, { emitEvent: false }) ved det endelige valg forhindrer, at den indsatte tekst udløser endnu et kald.

Hvorfor switchMap

mergeMap ville lade alle kald køre færdige, og svarene kunne komme i forkert rækkefølge. concatMap ville sætte kaldene i kø, så brugeren venter på svar til tekst, der allerede er skrevet videre. switchMap er den eneste af de tre, der kun bruger svaret på den seneste tekst.

Komponenten kalder kun API'et, når brugeren skriver. Den virker derfor også med Angular SSR: der laves ingen kald under server-renderingen.

3. Brug komponenten

HTML#brug-angular
<app-adresse-felt (valgt)="adresseValgt($event)" />
TypeScript#brug-angular-ts
adresseValgt(f: Forslag): void {
  this.form.patchValue({
    adresse: f.forslagstekst,
    adresseId: f.data.id,
    postnr: f.data.postnr,
    by: f.data.postnrnavn,
  });
}

Gem data.id. Det er adressens UUID, og med /adresser/{id} kan du hente kommune, koordinater og andre felter senere.

4. Alternativ: nøglen i en interceptor

Bruger flere komponenter API'et, kan du sætte headeren ét sted. Interceptoren tilføjer kun nøglen til kald mod api.danadresse.dk, så den aldrig sendes til andre servere:

TypeScript#key-interceptor
import { HttpInterceptorFn } from "@angular/common/http";
import { environment } from "../environments/environment";

export const danadresseNoegle: HttpInterceptorFn = (req, next) =>
  req.url.startsWith("https://api.danadresse.dk/")
    ? next(req.clone({ setHeaders: { "X-Api-Key": environment.danadresseBrowserKey } }))
    : next(req);

// app.config.ts: provideHttpClient(withFetch(), withInterceptors([danadresseNoegle]))

5. Test kaldet

Samme forespørgsel som komponenten, fra terminalen. Virker den her, men ikke i browseren, så tjek nøglens tilladte domæner. Til ng serve skal localhost stå på listen.

Shell#angular-request
curl -s "https://api.danadresse.dk/autocomplete?q=Vesterbrogade%201&type=adresse&per_side=8" \
  -H "X-Api-Key: $DANADRESSE_API_KEY"

Ved en 403 med koden origin_not_allowed er domænet ikke på listen. Ved en 429 med rate_limit_per_visitor har én besøgende lavet mere end 120 kald i minuttet. Fejlsvar har CORS-headere, så HttpErrorResponse har den rigtige status og JSON-svaret i error (byg logikken på error.code). Retry-After er også læsbar: hent den med err.headers.get("Retry-After"), og vent så mange sekunder før næste kald.

Næste skridt

Relaterede guides

Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)

Prøv det med en gratis nøgle

2 000 kald/måned på gratisplanen. Intet kreditkort.

Opret konto →