From 8f79c5aaf389fbf5719f2a5edd69ea2abdadd3ef Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 9 Feb 2026 19:54:33 +0000 Subject: [PATCH] feat(a11y): add combobox ARIA and keyboard nav to searchable-select Add role=combobox, aria-expanded, aria-controls, and role=listbox/option attributes. Support arrow key navigation, Enter to select, and Escape to dismiss. Add aria-label to clear button. --- static/js/components/searchable-select.js | 56 ++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/static/js/components/searchable-select.js b/static/js/components/searchable-select.js index c5d9b16..2d16720 100644 --- a/static/js/components/searchable-select.js +++ b/static/js/components/searchable-select.js @@ -22,11 +22,22 @@ customElements.define( search.type = "text"; search.className = "input-field w-full"; search.placeholder = placeholder; + search.setAttribute("role", "combobox"); + search.setAttribute("aria-expanded", "false"); + search.setAttribute("aria-autocomplete", "list"); + + const listId = `ss-list-${name}`; + search.setAttribute("aria-controls", listId); const options = document.createElement("div"); options.className = "hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface"; - buttons.forEach((btn) => options.appendChild(btn)); + options.id = listId; + options.setAttribute("role", "listbox"); + buttons.forEach((btn) => { + btn.setAttribute("role", "option"); + options.appendChild(btn); + }); const searchWrap = document.createElement("div"); searchWrap.appendChild(search); @@ -39,6 +50,7 @@ customElements.define( clear.type = "button"; clear.className = "absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition"; + clear.setAttribute("aria-label", "Clear selection"); clear.innerHTML = ''; @@ -74,14 +86,54 @@ customElements.define( } } + const updateExpanded = () => { + search.setAttribute( + "aria-expanded", + String(!options.classList.contains("hidden")), + ); + }; + search.addEventListener("input", () => { const q = search.value.toLowerCase(); options.classList.toggle("hidden", !q); + options.querySelector(".ss-active")?.classList.remove("ss-active"); buttons.forEach((btn) => { btn.style.display = btn.textContent.toLowerCase().includes(q) ? "" : "none"; }); + updateExpanded(); + }); + + search.addEventListener("keydown", (e) => { + const visible = buttons.filter( + (b) => + b.style.display !== "none" && !options.classList.contains("hidden"), + ); + if (!visible.length) return; + + const active = options.querySelector(".ss-active"); + let idx = active ? visible.indexOf(active) : -1; + + if (e.key === "ArrowDown") { + e.preventDefault(); + if (active) active.classList.remove("ss-active"); + idx = (idx + 1) % visible.length; + visible[idx].classList.add("ss-active"); + visible[idx].scrollIntoView({ block: "nearest" }); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (active) active.classList.remove("ss-active"); + idx = idx <= 0 ? visible.length - 1 : idx - 1; + visible[idx].classList.add("ss-active"); + visible[idx].scrollIntoView({ block: "nearest" }); + } else if (e.key === "Enter" && active) { + e.preventDefault(); + active.click(); + } else if (e.key === "Escape") { + options.classList.add("hidden"); + updateExpanded(); + } }); options.addEventListener("click", (e) => { @@ -92,6 +144,7 @@ customElements.define( display.textContent = btn.dataset.display; searchWrap.classList.add("hidden"); selectedWrap.classList.remove("hidden"); + updateExpanded(); this.dispatchEvent( new CustomEvent("change", { @@ -112,6 +165,7 @@ customElements.define( searchWrap.classList.remove("hidden"); selectedWrap.classList.add("hidden"); options.classList.add("hidden"); + updateExpanded(); setTimeout(() => search.focus(), 0); this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));