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.
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.
<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.
/autocompletesvarer først med vejnavne, så med husnumre og til sidst med etage og dør. Et vejnavn er ikke en adresse, såvaelgsætter teksten ind og søger igen.onselectkaldes kun, nårtypeeradresse. - Ingen ekstra kald. Efter et endeligt valg er
aabenfalsk, så effekten henter ikke forslag til den tekst, du lige har sat ind. - Skærmlæsere.
role="combobox",aria-activedescendantog 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:
<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.
// 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 almindeliglet, og$derived(…)til en reaktiv erklæring med$:.$effectbliver til en reaktiv blok, der afhænger afqogaaben. Svelte 4 har ingen oprydningsfunktion i en$:-blok, så gem timeren ogAbortControlleri variabler uden for blokken, og ryd dem op i starten af næste kørsel.$props()bliver tilexport let apiKey, og callback-prop'enonselectkan blive et event medcreateEventDispatcher.oninput,onkeydownogonmousedownskriveson:input,on:keydownogon:mousedown|preventDefault.
Test kaldet
Samme forespørgsel som komponenten, fra terminalen:
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)