203 lines
7.0 KiB
JavaScript
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;
|
|
})();
|