Bettern cmap handling. Lots of changes.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
/* 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;
|
||||
})();
|
||||
+2005
-103
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user