# 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.
>
> https://danadresse.dk/guides/svelte-autocomplete · verified 2026-11-02
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](/guides/browser-noegler-domaener). 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 id=adresse-felt-svelte run=no
<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](/guides/tilgaengelig-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 id=brug-svelte run=no
<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.

```ts id=sveltekit-endpoint run=no
// 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](/guides/nextjs-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:

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

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