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.

5 min Niveau: Avanceret ✓ Kode testet

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.

ElementRolle og attributterFormål
inputrole="combobox"Fortæller, at feltet har en tilknyttet liste
inputaria-autocomplete="list"Forslagene vises i en liste. Feltet udfylder ikke selv teksten
inputaria-expanded = true eller falseOm listen er åben lige nu
inputaria-controls="<listens id>"Hvilken liste feltet styrer
inputaria-activedescendant="<id på markeret forslag>"Hvilket forslag der er markeret. Fjernes, når intet er markeret
ulrole="listbox" og aria-labelSelve listen. Må kun indeholde forslag
lirole="option", unikt id, aria-selectedÉt forslag. aria-selected="true" på det markerede
divrole="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

TastNår listen er lukketNår listen er åben
Pil nedÅbner listen, hvis der er nok tekstMarkerer næste forslag. Fra det sidste går den til det første
Pil opIngentingMarkerer forrige forslag. Fra det første går den til det sidste
EnterSender formularen som normaltVælger det markerede forslag. Uden markering sendes formularen
EscapeIngentingLukker listen, teksten bliver stående
TabFlytter fokus som normaltLukker listen og flytter fokus videre
Home / EndFlytter markøren i tekstenFø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 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#combobox-html
<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:

JavaScript#combobox-js
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:

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

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 →