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.

3 min Niveau: Øvet ✓ Kode testet

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 og Next.js route handler som 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.

Shell#vite-env
# .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#adresse-felt-vue
<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.

Brug komponenten

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

Vue#brug-vue
<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:

Shell#vue-request
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

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 →