// Combobox with a listbox popup (WAI-ARIA 1.2). Focus stays in the input; arrow keys move aria-activedescendant,
// Enter picks, Escape closes, and a polite status line announces how many stations match.
(() => {
const box = document.querySelector('[data-ac]');
if (!box) return;
const input = box.querySelector('[role="combobox"]');
const list = box.querySelector('[role="listbox"]');
const status = box.querySelector('[role="status"]');
const tpl = box.querySelector('template');
const stations = [['Amsterdam Centraal','NL'],['Antwerpen-Centraal','BE'],['Barcelona Sants','ES'],['Basel SBB','CH'],['Bergamo','IT'],
['Bergen','NO'],['Berlin Hauptbahnhof','DE'],['Berlin Ostbahnhof','DE'],['Berlin Südkreuz','DE'],['Bern','CH'],['Bologna Centrale','IT'],
['Bordeaux Saint-Jean','FR'],['Brussels-Midi','BE'],['Budapest Keleti','HU'],['Copenhagen Central','DK'],['Hamburg Hauptbahnhof','DE'],
['Lyon Part-Dieu','FR'],['Milano Centrale','IT'],['Paris Gare de Lyon','FR'],['Paris Nord','FR'],['Wien Hauptbahnhof','AT'],['Zürich HB','CH']];
let active = -1;
function setActive(i) {
const opts = list.querySelectorAll('[role="option"]');
active = i;
opts.forEach((o, n) => o.setAttribute('aria-selected', String(n === i)));
if (i > -1) { input.setAttribute('aria-activedescendant', opts[i].id); opts[i].scrollIntoView({ block: 'nearest' }); }
else input.removeAttribute('aria-activedescendant');
}
function setOpen(on) {
list.hidden = !on;
input.setAttribute('aria-expanded', String(on));
if (!on) setActive(-1);
}
function render(announce = true) {
const q = input.value.trim().toLowerCase();
const hits = q ? stations.filter(([name]) => name.toLowerCase().includes(q)).slice(0, 6) : [];
list.replaceChildren(...hits.map(([name, code], n) => {
const li = tpl.content.firstElementChild.cloneNode(true);
const at = name.toLowerCase().indexOf(q);
const mark = document.createElement('mark');
mark.textContent = name.slice(at, at + q.length);
li.id = 'ac-opt-' + n;
li.dataset.value = name;
li.querySelector('[data-name]').append(name.slice(0, at), mark, name.slice(at + q.length));
li.querySelector('[data-code]').textContent = code;
return li;
}));
setActive(-1);
setOpen(hits.length > 0);
if (announce) status.textContent = !q ? '' : hits.length ? hits.length + ' stations found' : 'No stations found';
}
function pick(li) {
input.value = li.dataset.value;
setOpen(false);
status.textContent = '';
}
input.addEventListener('input', () => render());
input.addEventListener('keydown', e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (list.hidden) render();
const n = list.children.length;
if (!n) return;
const step = e.key === 'ArrowDown' ? 1 : -1;
setActive(active < 0 ? (step > 0 ? 0 : n - 1) : (active + step + n) % n);
} else if (e.key === 'Enter' && !list.hidden && active > -1) {
e.preventDefault();
pick(list.children[active]);
} else if (e.key === 'Escape' && !list.hidden) {
e.preventDefault();
setOpen(false);
}
});
list.addEventListener('mousedown', e => e.preventDefault()); // keep focus in the input
list.addEventListener('click', e => { const li = e.target.closest('[role="option"]'); if (li) pick(li); });
input.addEventListener('blur', () => setOpen(false));
document.addEventListener('click', e => { if (!box.contains(e.target)) setOpen(false); });
render(false); // demo: show suggestions for the prefilled "Ber". Remove this line (and value="Ber") for an empty field.
})();
About this set
Two ways to suggest results while someone types. The native datalist version connects a search input to a datalist of fifteen European cities through the list attribute, so the browser draws and handles the suggestions itself with no JavaScript at all; use it when plain text suggestions are enough. The styled listbox version is a combobox following the WAI-ARIA 1.2 pattern for a train station search: the input has role=”combobox”, aria-expanded and aria-controls pointing at a listbox, and each suggestion is an option with the typed letters highlighted in a mark element and a country code on the right. About seventy lines of vanilla JavaScript filter the list, move aria-activedescendant with the arrow keys, pick with Enter or a click, close on Escape and announce the number of matches through a polite status message. Focus never leaves the input. Options are cloned from a template element, so each version keeps its own classes while the script stays the same. The demo loads with Ber typed in to show the open list.
What’s included
Native datalist version with no JavaScript
Styled combobox following the WAI-ARIA 1.2 listbox pattern
Arrow keys, Enter and Escape, with focus kept in the input
Match count announced through a role="status" message
Options cloned from a template, so one script serves all three versions
Accessibility
Both inputs have visible labels and sit in forms with role="search". The combobox exposes aria-expanded, aria-controls, aria-autocomplete="list" and aria-activedescendant, and the active option is marked with aria-selected and a visible highlight plus a left accent bar rather than colour alone. A visually hidden status line announces how many stations match. The field shows an accent focus ring and its transition is removed under prefers-reduced-motion.
Customise it
Set --ac-accent, --ac-mark and --ac-active on .ac for the ring, highlighted letters and active row, and replace the stations array in the script with your own data or a fetch call. Bootstrap styles the listbox as a .list-group; in Tailwind edit the --color-ac-* theme colours and the classes inside the template element.