Adressefelt med autocomplete i Svelte 5

Byg en Svelte 5-komponent med runes, der henter adresseforslag med debounce og AbortController, og flyt nøglen til en SvelteKit-endpoint, hvis du vil.

3 min Niveau: Øvet ✓ Kode testet

Denne guide bruger Svelte 5 og runes ($state, $derived, $effect, $props). Komponenten virker i en almindelig Vite-app og i SvelteKit. Er du på Svelte 4, er logikken den samme, men du skal skrive let og $: i stedet for runes og on:input i stedet for oninput.

Nøglen bliver sendt fra browseren. Brug en domænelåst browsernøgle, se Browsernøgler og tilladte domæner. Vil du helt undgå en nøgle i browseren, så se afsnittet om SvelteKit-endpointet nederst.

Komponenten

$effect kører igen, hver gang en værdi, den læser, ændrer sig. Funktionen, som effekten returnerer, kører før næste kørsel. Den bruger vi til at stoppe debounce-timeren og afbryde et kald, der stadig er i gang. Bemærk, at q og aaben skal læses direkte i effekten, ikke inde i setTimeout: Svelte sporer kun de værdier, der læses synkront.

Svelte#adresse-felt-svelte
<script lang="ts">
  type Forslag = {
    type: "vejnavn" | "adgangsadresse" | "adresse";
    tekst: string;
    forslagstekst: string;
    caretpos: number;
    data: Record<string, any>;
  };

  let { apiKey, onselect }: { apiKey: string; onselect: (f: Forslag) => void } = $props();

  const uid = `adr-${Math.random().toString(36).slice(2, 8)}`;
  let q = $state("");
  let aaben = $state(false);
  let forslag = $state<Forslag[]>([]);
  let aktiv = $state(-1);
  let fejl = $state(false);
  let input: HTMLInputElement;
  let vis = $derived(aaben && forslag.length > 0);

  $effect(() => {
    const tekst = q;            // læst synkront → effekten følger q
    if (!aaben || tekst.trim().length < 2) {
      forslag = [];
      return;
    }
    const ctrl = new AbortController();
    const timer = setTimeout(async () => {
      const url = new URL("https://api.danadresse.dk/autocomplete");
      url.searchParams.set("q", tekst);
      url.searchParams.set("type", "adresse");
      url.searchParams.set("per_side", "8");
      try {
        const res = await fetch(url, { headers: { "X-Api-Key": apiKey }, signal: ctrl.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        forslag = await res.json();
        fejl = false;
        aktiv = -1;
      } catch (e) {
        if ((e as Error).name === "AbortError") return;
        forslag = [];
        fejl = true;
      }
    }, 200);
    return () => {
      clearTimeout(timer);
      ctrl.abort();
    };
  });

  function vaelg(f: Forslag) {
    if (f.type !== "adresse") {
      q = f.tekst; // vejnavn/opgang: søg videre
      requestAnimationFrame(() => input.setSelectionRange(f.caretpos, f.caretpos));
      return;
    }
    aaben = false;
    q = f.forslagstekst;
    onselect(f);
  }

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

<div class="adr">
  <label for="{uid}-input">Adresse</label>
  <input
    id="{uid}-input"
    bind:this={input}
    bind:value={q}
    oninput={() => (aaben = true)}
    onkeydown={tast}
    onblur={() => (aaben = false)}
    role="combobox"
    aria-autocomplete="list"
    aria-expanded={vis}
    aria-controls="{uid}-list"
    aria-activedescendant={vis && aktiv >= 0 ? `${uid}-${aktiv}` : undefined}
    autocomplete="off"
  />
  {#if vis}
    <ul id="{uid}-list" role="listbox" aria-label="Adresseforslag">
      {#each forslag as f, i (f.data.id ?? f.tekst)}
        <li
          id="{uid}-{i}"
          role="option"
          aria-selected={i === aktiv}
          onmousedown={(e) => { e.preventDefault(); vaelg(f); }}
        >
          {f.forslagstekst}
        </li>
      {/each}
    </ul>
  {/if}
  <div role="status" aria-live="polite" class="sr-only">
    {fejl ? "Adresseopslag er utilgængeligt. Skriv adressen manuelt." : vis ? `${forslag.length} forslag` : ""}
  </div>
</div>

Tre ting at lægge mærke til:

  • Drill-down. /autocomplete svarer først med vejnavne, så med husnumre og til sidst med etage og dør. Et vejnavn er ikke en adresse, så vaelg sætter teksten ind og søger igen. onselect kaldes kun, når type er adresse.
  • Ingen ekstra kald. Efter et endeligt valg er aaben falsk, så effekten henter ikke forslag til den tekst, du lige har sat ind.
  • Skærmlæsere. role="combobox", aria-activedescendant og live-regionen følger ARIA 1.2-mønstret. Se Tilgængelig adresse-combobox.

Brug komponenten

I SvelteKit lægger du browsernøglen i en PUBLIC_-variabel. Den havner i bundtet, så den skal være låst til dine domæner:

Svelte#brug-svelte
<script lang="ts">
  import { PUBLIC_DANADRESSE_BROWSER_KEY } from "$env/static/public";
  import AdresseFelt from "$lib/AdresseFelt.svelte";

  let adresseId = $state("");
  let postnr = $state("");
  let by = $state("");
</script>

<AdresseFelt
  apiKey={PUBLIC_DANADRESSE_BROWSER_KEY}
  onselect={(f) => {
    adresseId = f.data.id;
    postnr = f.data.postnr;
    by = f.data.postnrnavn;
  }}
/>
<input type="hidden" name="adresse_id" value={adresseId} />

Gem data.id. Det er adressens faste UUID, og med den kan du slå adressen op igen med /adresser/{id}.

Uden nøgle i browseren: et SvelteKit-endpoint

Vil du ikke have en nøgle i browseren, så lad en server route kalde API'et. Nøglen ligger i $env/static/private og kommer aldrig ud til klienten. Komponenten kalder så /api/adresse?q=… uden header.

TypeScript#sveltekit-endpoint
// src/routes/api/adresse/+server.ts
import { DANADRESSE_API_KEY } from "$env/static/private";
import type { RequestHandler } from "./$types";

export const GET: RequestHandler = async ({ url, fetch }) => {
  const q = (url.searchParams.get("q") ?? "").slice(0, 120);
  const upstream = new URL("https://api.danadresse.dk/autocomplete");
  upstream.searchParams.set("q", q);
  upstream.searchParams.set("type", "adresse");
  upstream.searchParams.set("per_side", "8");

  const res = await fetch(upstream, { headers: { "X-Api-Key": DANADRESSE_API_KEY } });
  return new Response(await res.text(), {
    status: res.status,
    headers: {
      "Content-Type": "application/json",
      "Cache-Control": res.ok ? "public, max-age=60, s-maxage=300" : "no-store",
    },
  });
};

Her deler alle besøgende din servers nøgle og dens minutgrænse. Den per-besøgende grænse, som browsernøgler har, gælder ikke. Læg selv en grænse pr. IP i din app, hvis siden er offentlig. Detaljerne, også om at sende rate-limit-headers videre, står i Next.js route handler som proxy.

På Svelte 4

Logikken kan flyttes direkte. Det er kun syntaksen, der er anderledes:

  • $state(…) bliver til en almindelig let, og $derived(…) til en reaktiv erklæring med $:.
  • $effect bliver til en reaktiv blok, der afhænger af q og aaben. Svelte 4 har ingen oprydningsfunktion i en $:-blok, så gem timeren og AbortController i variabler uden for blokken, og ryd dem op i starten af næste kørsel.
  • $props() bliver til export let apiKey, og callback-prop'en onselect kan blive et event med createEventDispatcher.
  • oninput, onkeydown og onmousedown skrives on:input, on:keydown og on:mousedown|preventDefault.

Test kaldet

Samme forespørgsel som komponenten, fra terminalen:

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

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 →