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.
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 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
divunder listen. Enlistboxmå kun haveoption-børn, og ellers læser nogle skærmlæsere et forkert antal op. - Live region. En skjult
divmedrole="status"ogaria-live="polite"får teksten "8 forslag", "Ingen adresser fundet", fejlbeskeden eller den valgte adresse.politebetyder, at skærmlæseren venter, til brugeren holder pause. - Markering uden fokusflyt. Piletasterne sætter
aria-selectedpå et forslag ogaria-activedescendantpå feltet. Forslaget rulles ind i synsfeltet medscrollIntoView({ block: "nearest" }). - Mus og touch. Forslag vælges på
mousedownmedpreventDefault(). 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
caretposog 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:
<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:
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:
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
- 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.
- 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.
- Automatisk. Kør axe eller Lighthouse på siden med listen åben. De fanger fx en
listboxmed forkerte børn eller en manglendelabel. - 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
- Adressefelt i React — samme mønster som komponent.
- Browsernøgler og tilladte domæner
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)