# Tilgængelig adresse-combobox efter ARIA 1.2

> Lær combobox-mønstret til et adressefelt (roller, aria-activedescendant, live region og tastatur), se hvordan vores script gør, og byg en minimal version selv.
>
> https://danadresse.dk/guides/tilgaengelig-combobox · verified 2026-11-02
Et adressefelt med forslag er en af de mest brugte kontroller i en checkout, og en af dem, der oftest ikke virker med skærmlæser eller tastatur. Brugeren hører ikke, at der er kommet forslag, kan ikke nå dem med piletasterne, eller mister fokus, når listen lukker.

ARIA 1.2 beskriver et fast mønster for netop den type felt: en *combobox* med en *listbox*. Denne guide gennemgår mønstret, viser hvordan `danadresse-autocomplete-1.0.0.js` implementerer det, og giver dig en minimal version i ren JavaScript, du kan bygge videre på.

## Mønstret

Fokus bliver hele tiden i tekstfeltet. Listen er et separat element, som feltet peger på. Når brugeren bevæger sig i listen, flytter du ikke fokus, men fortæller skærmlæseren, hvilket forslag der er markeret.

| Element | Rolle og attributter | Formål |
|---|---|---|
| `input` | `role="combobox"` | Fortæller, at feltet har en tilknyttet liste |
| `input` | `aria-autocomplete="list"` | Forslagene vises i en liste. Feltet udfylder ikke selv teksten |
| `input` | `aria-expanded` = `true` eller `false` | Om listen er åben lige nu |
| `input` | `aria-controls="<listens id>"` | Hvilken liste feltet styrer |
| `input` | `aria-activedescendant="<id på markeret forslag>"` | Hvilket forslag der er markeret. Fjernes, når intet er markeret |
| `ul` | `role="listbox"` og `aria-label` | Selve listen. Må kun indeholde forslag |
| `li` | `role="option"`, unikt `id`, `aria-selected` | Ét forslag. `aria-selected="true"` på det markerede |
| `div` | `role="status"` (live region) | Læser antal forslag, "ingen fundet" og fejl op uden at flytte fokus |

Feltet skal også have en synlig `label`. En pladsholder er ikke nok, for den forsvinder, når brugeren begynder at skrive.

## Tastatur

| Tast | Når listen er lukket | Når listen er åben |
|---|---|---|
| Pil ned | Åbner listen, hvis der er nok tekst | Markerer næste forslag. Fra det sidste går den til det første |
| Pil op | Ingenting | Markerer forrige forslag. Fra det første går den til det sidste |
| Enter | Sender formularen som normalt | Vælger det markerede forslag. Uden markering sendes formularen |
| Escape | Ingenting | Lukker listen, teksten bliver stående |
| Tab | Flytter fokus som normalt | Lukker listen og flytter fokus videre |
| Home / End | Flytter markøren i teksten | Første / sidste forslag, hvis et forslag allerede er markeret |

Enter må kun blokeres, når der faktisk er et markeret forslag. Ellers kan brugeren ikke sende formularen fra feltet.

## Sådan gør danadresse-autocomplete-1.0.0.js

Scriptet fra [Autocomplete-widget i ren JavaScript](/guides/vanilla-autocomplete-widget) følger mønstret ovenfor. Det er de valg, der er værd at kende, hvis du selv skal bygge eller teste:

- **Listen indeholder kun forslag.** Beskeder som "Ingen adresser fundet", fejlteksten og kreditlinjen ligger i en separat `div` under listen. En `listbox` må kun have `option`-børn, og ellers læser nogle skærmlæsere et forkert antal op.
- **Live region.** En skjult `div` med `role="status"` og `aria-live="polite"` får teksten "8 forslag", "Ingen adresser fundet", fejlbeskeden eller den valgte adresse. `polite` betyder, at skærmlæseren venter, til brugeren holder pause.
- **Markering uden fokusflyt.** Piletasterne sætter `aria-selected` på et forslag og `aria-activedescendant` på feltet. Forslaget rulles ind i synsfeltet med `scrollIntoView({ block: "nearest" })`.
- **Mus og touch.** Forslag vælges på `mousedown` med `preventDefault()`. Så mister feltet ikke fokus, før valget er registreret.
- **Drill-down.** Vælger brugeren et vejnavn, sætter scriptet teksten ind, placerer markøren ved `caretpos` og søger igen. Fokus bliver i feltet, så skærmlæseren fortsætter i samme kontekst.
- **Synligt fokus og størrelse.** Det markerede forslag har en 2 px outline ud over baggrundsfarven, så markeringen ikke kun vises med farve. Hver række er mindst 44 px høj.
- **Kreditlinket** har `tabIndex = -1`, så Tab ikke lander i listen.
- **Sprog.** Teksterne findes på dansk, engelsk og ukrainsk og vælges ud fra `lang`-optionen eller `<html lang>`.

Det ældre `danadresse-widget.js` følger samme mønster: `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-activedescendant`, en live region (`aria-live="polite"`) med antal resultater, og kreditlinjen uden for listboxen. Det adskiller sig på fire punkter: kun danske tekster, ingen Home/End, piletasterne stopper ved første og sidste forslag, og markeringen vises kun med baggrundsfarve. Brug 1.0.0, hvis du har brug for dem. Scriptet `danadresse-auto.min.js` med ét tag til webshops bruger også combobox-mønstret med en live region.

## En minimal version selv

Vil du ikke bruge vores script, er her et udgangspunkt på omkring 80 linjer. Først markup og den nødvendige CSS:

```html id=combobox-html run=no
<label for="adr">Adresse</label>
<div class="adr">
  <input id="adr" type="text" role="combobox" aria-autocomplete="list"
         aria-expanded="false" aria-controls="adr-liste" aria-describedby="adr-hjaelp"
         autocomplete="off" spellcheck="false">
  <ul id="adr-liste" role="listbox" aria-label="Adresseforslag" hidden></ul>
</div>
<p id="adr-hjaelp">Skriv vej og husnummer, og vælg med piletasterne.</p>
<div id="adr-status" role="status" aria-live="polite" class="sr-only"></div>

<style>
  .adr { position: relative; }
  #adr-liste { position: absolute; left: 0; right: 0; margin: 0; padding: 4px; list-style: none; background: #fff; border: 1px solid #888; }
  #adr-liste li { min-height: 44px; display: flex; align-items: center; padding: 0 12px; cursor: pointer; }
  #adr-liste li[aria-selected="true"] { background: #e6f0ff; outline: 2px solid #0050b3; outline-offset: -2px; }
  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
</style>
```

Så scriptet. Det bruger en domænelåst browsernøgle, se [Browsernøgler og tilladte domæner](/guides/browser-noegler-domaener):

```js id=combobox-js run=syntax
const API_KEY = "dawa_live_din_browsernøgle";
const input = document.getElementById("adr");
const liste = document.getElementById("adr-liste");
const status = document.getElementById("adr-status");
let forslag = [];
let aktiv = -1;
let timer;
let ctrl;

function aabn(aaben) {
  liste.hidden = !aaben;
  input.setAttribute("aria-expanded", String(aaben));
  if (!aaben) marker(-1);
}

function marker(i) {
  aktiv = i;
  liste.querySelectorAll("li").forEach((li, n) => li.setAttribute("aria-selected", String(n === i)));
  if (i < 0) {
    input.removeAttribute("aria-activedescendant");
    return;
  }
  const li = document.getElementById(`adr-opt-${i}`);
  input.setAttribute("aria-activedescendant", li.id);
  li.scrollIntoView({ block: "nearest" });
}

function vis() {
  liste.replaceChildren(...forslag.map((f, i) => {
    const li = document.createElement("li");
    li.id = `adr-opt-${i}`;
    li.setAttribute("role", "option");
    li.setAttribute("aria-selected", "false");
    li.textContent = f.forslagstekst;
    li.addEventListener("mousedown", (e) => { e.preventDefault(); vaelg(i); });
    return li;
  }));
  aabn(forslag.length > 0);
  status.textContent = forslag.length ? `${forslag.length} forslag` : "Ingen adresser fundet";
}

async function soeg(q) {
  ctrl?.abort();
  ctrl = new AbortController();
  const url = new URL("https://api.danadresse.dk/autocomplete");
  url.searchParams.set("q", q);
  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 = await res.json();
    vis();
  } catch (e) {
    if (e.name === "AbortError") return;
    forslag = [];
    aabn(false);
    status.textContent = "Adresseopslag er utilgængeligt. Skriv adressen manuelt.";
  }
}

function vaelg(i) {
  const f = forslag[i];
  if (f.type !== "adresse") {
    input.value = f.tekst; // vejnavn eller opgang: søg videre
    input.setSelectionRange(f.caretpos, f.caretpos);
    soeg(input.value);
    return;
  }
  input.value = f.forslagstekst;
  aabn(false);
  status.textContent = f.forslagstekst;
  input.dispatchEvent(new CustomEvent("adresse-valgt", { detail: f, bubbles: true }));
}

input.addEventListener("input", () => {
  clearTimeout(timer);
  if (input.value.trim().length < 2) {
    ctrl?.abort();
    aabn(false);
    return;
  }
  timer = setTimeout(() => soeg(input.value), 200);
});

input.addEventListener("keydown", (e) => {
  const aaben = !liste.hidden && forslag.length > 0;
  if (e.key === "ArrowDown" && aaben) { e.preventDefault(); marker((aktiv + 1) % forslag.length); }
  else if (e.key === "ArrowUp" && aaben) { e.preventDefault(); marker(aktiv <= 0 ? forslag.length - 1 : aktiv - 1); }
  else if (e.key === "Enter" && aaben && aktiv >= 0) { e.preventDefault(); vaelg(aktiv); }
  else if (e.key === "Escape" && aaben) { e.preventDefault(); aabn(false); }
  else if (e.key === "Tab") { aabn(false); }
});

input.addEventListener("blur", () => aabn(false));
```

Versionen mangler bevidst et par ting, som vores script har: tekster på flere sprog, Home/End, at Pil ned genåbner en lukket liste, og en `destroy()`-funktion. Tilføj dem, hvis du har brug for dem.

## Det svar, live-regionen tæller

Live-regionen læser længden af svaret op. Så mange forslag giver API'et for en typisk indtastning:

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

Hold `per_side` lavt, fx 5 til 8. En lang liste er svær at overskue med skærmlæser, og brugeren kan altid skrive mere for at indsnævre.

## Sådan tester du

1. **Kun tastatur.** Tab ind i feltet, skriv, brug piletasterne, vælg med Enter, luk med Escape. Fokus må aldrig forlade feltet, mens du bevæger dig i listen.
2. **Skærmlæser.** Test med VoiceOver på macOS/iOS og NVDA på Windows. Du skal høre antallet af forslag, det markerede forslag, når du trykker pil ned, og den valgte adresse.
3. **Automatisk.** Kør axe eller Lighthouse på siden med listen åben. De fanger fx en `listbox` med forkerte børn eller en manglende `label`.
4. **Fejl.** Sæt en forkert nøgle og tjek, at fejlbeskeden bliver læst op, og at feltet stadig kan bruges som almindeligt tekstfelt.

## Næste skridt

- [Autocomplete-widget i ren JavaScript](/guides/vanilla-autocomplete-widget)
- [Adressefelt i React](/guides/react-autocomplete) — samme mønster som komponent.
- [Browsernøgler og tilladte domæner](/guides/browser-noegler-domaener)
