# Adressefelt med autocomplete i Vue 3

> Skriv en Vue 3-komponent med Composition API, der henter adresseforslag med debounce og afbrudte kald og sender den valgte adresse videre.
>
> https://danadresse.dk/guides/vue-autocomplete · verified 2026-11-02
Denne guide bygger en single-file component (SFC) til Vue 3 med `<script setup>` og TypeScript. Komponenten henter forslag fra `/autocomplete`, håndterer tastaturet og udsender et `select`-event med den valgte adresse. Der bruges ingen andre biblioteker end Vue.

Nøglen kører i browseren. Den skal derfor være en domænelåst browsernøgle, eller også skal kaldene gå gennem din egen server. Begge dele er beskrevet i [Browsernøgler og tilladte domæner](/guides/browser-noegler-domaener) og [Next.js route handler som proxy](/guides/nextjs-proxy). Mønstret med en proxy virker på samme måde i en Nuxt server route.

## Nøglen i Vite

En variabel med præfikset `VITE_` bliver skrevet ind i bundtet, så alle kan læse den. Det er i orden for en browsernøgle, der er låst til dine domæner. Brug aldrig en ulåst nøgle her.

```bash id=vite-env run=no
# .env.local
VITE_DANADRESSE_BROWSER_KEY=dawa_live_din_browsernøgle
```

## Komponenten

`watch` med `onCleanup` er det, der gør komponenten robust. Hver gang `q` ændrer sig, kører Vue oprydningen fra den forrige kørsel først. Den stopper timeren (debounce) og afbryder et kald, der stadig er undervejs. Så kan et gammelt, langsomt svar aldrig overskrive et nyere.

```vue id=adresse-felt-vue run=no
<script setup lang="ts">
import { computed, ref, watch } from "vue";

type Forslag = {
  type: "vejnavn" | "adgangsadresse" | "adresse";
  tekst: string;
  forslagstekst: string;
  caretpos: number;
  data: Record<string, any>;
};

const emit = defineEmits<{ select: [forslag: Forslag] }>();
const API_KEY = import.meta.env.VITE_DANADRESSE_BROWSER_KEY as string;
const uid = `adr-${Math.random().toString(36).slice(2, 8)}`;

const q = ref("");
const forslag = ref<Forslag[]>([]);
const aktiv = ref(-1);
const aaben = ref(false);
const fejl = ref(false);
const input = ref<HTMLInputElement | null>(null);
const vis = computed(() => aaben.value && forslag.value.length > 0);

watch(q, (vaerdi, _gammel, onCleanup) => {
  if (!aaben.value || vaerdi.trim().length < 2) {
    forslag.value = [];
    return;
  }
  const ctrl = new AbortController();
  const timer = setTimeout(async () => {
    const url = new URL("https://api.danadresse.dk/autocomplete");
    url.searchParams.set("q", vaerdi);
    url.searchParams.set("type", "adresse");
    url.searchParams.set("per_side", "8");
    try {
      const res = await fetch(url, { headers: { "X-Api-Key": API_KEY }, signal: ctrl.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      forslag.value = await res.json();
      fejl.value = false;
      aktiv.value = -1;
    } catch (e) {
      if ((e as Error).name === "AbortError") return;
      forslag.value = [];
      fejl.value = true; // feltet virker videre som almindeligt tekstfelt
    }
  }, 200);
  onCleanup(() => {
    clearTimeout(timer);
    ctrl.abort();
  });
});

function vaelg(f: Forslag) {
  if (f.type !== "adresse") {
    // Vejnavn eller opgang: sæt teksten ind og søg videre fra caretpos.
    q.value = f.tekst;
    requestAnimationFrame(() => input.value?.setSelectionRange(f.caretpos, f.caretpos));
    return;
  }
  aaben.value = false;
  q.value = f.forslagstekst;
  emit("select", f);
}

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

<template>
  <div class="adr">
    <label :for="`${uid}-input`">Adresse</label>
    <input
      :id="`${uid}-input`"
      ref="input"
      v-model="q"
      role="combobox"
      aria-autocomplete="list"
      :aria-expanded="vis"
      :aria-controls="`${uid}-list`"
      :aria-activedescendant="vis && aktiv >= 0 ? `${uid}-${aktiv}` : undefined"
      autocomplete="off"
      @input="aaben = true"
      @keydown="tast"
      @blur="aaben = false"
    />
    <ul v-if="vis" :id="`${uid}-list`" role="listbox" aria-label="Adresseforslag">
      <li
        v-for="(f, i) in forslag"
        :id="`${uid}-${i}`"
        :key="f.data.id ?? f.tekst"
        role="option"
        :aria-selected="i === aktiv"
        @mousedown.prevent="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>
  </div>
</template>
```

Et par detaljer:

- `@input="aaben = true"` sætter kun listen til åben ved brugerens indtastning. Når `vaelg` selv ændrer `q` efter et endeligt valg, er `aaben` falsk, og der laves intet ekstra kald.
- `@mousedown.prevent` vælger, før feltet mister fokus. Et `@click` ville komme efter `@blur`, og så er listen væk.
- `aria-activedescendant` peger på den markerede `li`, så skærmlæsere læser forslaget op, mens fokus bliver i feltet. Se [Tilgængelig adresse-combobox](/guides/tilgaengelig-combobox).

## Brug komponenten

Lyt efter `select`, og gem både teksten og adressens id:

```vue id=brug-vue run=no
<script setup lang="ts">
import { reactive } from "vue";
import AdresseFelt from "./AdresseFelt.vue";

const ordre = reactive({ adresse: "", adresseId: "", postnr: "", by: "" });

function naarValgt(f: any) {
  ordre.adresse = f.forslagstekst;
  ordre.adresseId = f.data.id;
  ordre.postnr = f.data.postnr;
  ordre.by = f.data.postnrnavn;
}
</script>

<template>
  <AdresseFelt @select="naarValgt" />
</template>
```

`data.id` er UUID'en for adressen. Den ændrer sig ikke, selv om vejen skifter navn, så den er det rigtige at gemme i din database.

## Det kald, komponenten laver

Kør samme forespørgsel fra terminalen, hvis du skal fejlsøge. Svarer den her, men ikke i browseren, er det næsten altid nøglens domæneliste:

```bash id=vue-request run=live expect="forslagstekst"
curl -s "https://api.danadresse.dk/autocomplete?q=R%C3%A5dhuspladsen%201&type=adresse&per_side=8" \
  -H "X-Api-Key: $DANADRESSE_API_KEY"
```

## Grænser i browseren

En browsernøgle bruger kontoens kvote og minutgrænse. Derudover har hver besøgende sin egen grænse på 120 kald i minuttet, og overskrides den, svarer API'et 429 med koden `rate_limit_per_visitor`. Med 200 ms debounce når en almindelig bruger ikke derop.

Fejlsvar har CORS-headere, så komponenten ser den rigtige status og JSON-svaret, også ved 403 og 500. `Retry-After` og headerne `X-RateLimit-*`, `X-RateLimit-Minute-*` og `X-Quota-*` kan også læses fra JavaScript. Ved en 429 `rate_limit_per_visitor` venter du `res.headers.get("Retry-After")` sekunder før næste kald, viser en kort besked og lader feltet virke som tekstfelt, ligesom komponenten gør ved andre fejl.

## Næste skridt

- [Browsernøgler og tilladte domæner](/guides/browser-noegler-domaener)
- [Adressefelt i React](/guides/react-autocomplete) — samme logik som hook.
- [Tilgængelig adresse-combobox](/guides/tilgaengelig-combobox)
- Oversigt: [/vue-adresse-autocomplete](/vue-adresse-autocomplete).
