# 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.
>
> https://danadresse.dk/guides/angular-autocomplete · verified 2026-11-02
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](/guides/browser-noegler-domaener). Vil du hellere kalde API'et fra din egen server, så se [Next.js route handler som proxy](/guides/nextjs-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`:

```ts id=app-config run=no
// 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:

```ts id=environment run=no
// 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.

```ts id=adresse-felt-component run=no
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 id=brug-angular run=no
<app-adresse-felt (valgt)="adresseValgt($event)" />
```

```ts id=brug-angular-ts run=no
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:

```ts id=key-interceptor run=no
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.

```bash id=angular-request run=live expect="forslagstekst"
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

- [Browsernøgler og tilladte domæner](/guides/browser-noegler-domaener)
- [Tilgængelig adresse-combobox](/guides/tilgaengelig-combobox)
- [Next.js route handler som proxy](/guides/nextjs-proxy)
