/* Accessible, dependency-free combobox used by racket-wiki. */ (() => { "use strict"; class RacketWikiComboBox { constructor(root) { this.root = root; this.input = root.querySelector("input"); this.button = root.querySelector("button"); this.list = root.querySelector('[role="listbox"]'); this.options = []; this.filteredOptions = []; this.selectedValue = ""; this.activeIndex = -1; this.selectionHandlers = []; this.input.setAttribute("aria-controls", this.list.id); this.input.setAttribute("aria-expanded", "false"); this.input.setAttribute("aria-autocomplete", "list"); this.input.setAttribute("autocomplete", "off"); this.input.addEventListener("input", () => { this.selectedValue = ""; this.input.setCustomValidity(""); this.open(); this.renderOptions(false); }); this.input.addEventListener("focus", () => { this.open(); this.renderOptions(true); }); this.input.addEventListener("keydown", (event) => this.handleKeydown(event)); this.input.addEventListener("blur", () => { window.setTimeout(() => this.close(), 100); }); this.button.addEventListener("pointerdown", (event) => event.preventDefault()); this.button.addEventListener("click", () => { if (this.isOpen()) { this.close(); } else { this.open(); this.renderOptions(true); this.input.focus({ preventScroll: true }); } }); } setOptions(options, selectedValue = "") { this.options = options.map((option) => ({ value: String(option.value), label: String(option.label), description: String(option.description || "") })); const selected = this.options.find((option) => option.value === selectedValue) || null; this.selectedValue = selected ? selected.value : ""; this.input.value = selected ? selected.label : ""; this.activeIndex = -1; this.renderOptions(); } value() { const text = this.input.value.trim().toLocaleLowerCase(); if (!text) { this.selectedValue = ""; return ""; } if (this.selectedValue) { const selected = this.options.find((option) => option.value === this.selectedValue); if (selected && selected.label.toLocaleLowerCase() === text) return selected.value; } const selected = this.options.find((option) => option.label.toLocaleLowerCase() === text || option.value.toLocaleLowerCase() === text) || null; this.selectedValue = selected ? selected.value : ""; return selected ? selected.value : null; } clear() { this.selectedValue = ""; this.input.value = ""; this.input.setCustomValidity(""); this.activeIndex = -1; this.renderOptions(); } onSelect(handler) { this.selectionHandlers.push(handler); return this; } isOpen() { return !this.list.classList.contains("hidden"); } open() { this.list.classList.remove("hidden"); this.input.setAttribute("aria-expanded", "true"); this.button.setAttribute("aria-expanded", "true"); } close() { this.list.classList.add("hidden"); this.input.setAttribute("aria-expanded", "false"); this.button.setAttribute("aria-expanded", "false"); this.activeIndex = -1; this.input.removeAttribute("aria-activedescendant"); } renderOptions(showAll = false) { const query = showAll ? "" : this.input.value.trim().toLocaleLowerCase(); this.filteredOptions = this.options.filter((option) => !query || option.label.toLocaleLowerCase().includes(query) || option.description.toLocaleLowerCase().includes(query) || option.value.toLocaleLowerCase().includes(query)); this.list.replaceChildren(); for (const [index, option] of this.filteredOptions.entries()) { const item = document.createElement("div"); item.id = `${this.list.id}-option-${index}`; item.className = "wiki-combobox-option"; item.setAttribute("role", "option"); item.setAttribute("aria-selected", String(option.value === this.selectedValue)); item.dataset.index = String(index); const label = document.createElement("span"); label.className = "wiki-combobox-option-label"; label.textContent = option.label; item.append(label); if (option.description && option.description !== option.label) { const description = document.createElement("span"); description.className = "wiki-combobox-option-description"; description.textContent = option.description; item.append(description); } item.addEventListener("pointerdown", (event) => { event.preventDefault(); this.selectOption(option); }); this.list.append(item); } this.updateActiveOption(); } selectOption(option) { this.selectedValue = option.value; this.input.value = option.label; this.input.setCustomValidity(""); this.close(); for (const handler of this.selectionHandlers) handler(option.value, option); this.input.dispatchEvent(new Event("change", { bubbles: true })); } handleKeydown(event) { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); if (!this.isOpen()) { this.open(); this.renderOptions(); } const direction = event.key === "ArrowDown" ? 1 : -1; const maximum = this.filteredOptions.length - 1; if (maximum < 0) return; this.activeIndex = Math.max(0, Math.min(maximum, this.activeIndex + direction)); this.updateActiveOption(); return; } if (event.key === "Enter" && this.isOpen()) { const exactText = this.input.value.trim().toLocaleLowerCase(); const exact = this.filteredOptions.find((option) => option.label.toLocaleLowerCase() === exactText || option.value.toLocaleLowerCase() === exactText) || null; const selected = this.activeIndex >= 0 ? this.filteredOptions[this.activeIndex] : (exact || (this.filteredOptions.length === 1 ? this.filteredOptions[0] : null)); if (selected) { event.preventDefault(); this.selectOption(selected); return; } } if (event.key === "Escape") { event.preventDefault(); this.close(); } } updateActiveOption() { const elements = Array.from(this.list.querySelectorAll('[role="option"]')); for (const [index, element] of elements.entries()) { element.classList.toggle("active", index === this.activeIndex); } const active = elements[this.activeIndex]; if (active) { this.input.setAttribute("aria-activedescendant", active.id); active.scrollIntoView({ block: "nearest" }); } else { this.input.removeAttribute("aria-activedescendant"); } } } window.RacketWikiComboBox = RacketWikiComboBox; })();