Files

203 lines
7.0 KiB
JavaScript

/* 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;
})();