2025-08-09 21:16:24 +02:00
|
|
|
(() => {
|
|
|
|
class Dropdown {
|
|
|
|
#_select;
|
|
|
|
#_dropdown;
|
|
|
|
#_selected;
|
2025-08-09 22:14:35 +02:00
|
|
|
#_search;
|
2025-08-09 21:16:24 +02:00
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
#search = false;
|
2025-08-09 21:16:24 +02:00
|
|
|
#selected = false;
|
|
|
|
#options = [];
|
|
|
|
|
|
|
|
constructor(el) {
|
|
|
|
this.#_select = el;
|
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
this.#search = "searchable" in el.dataset;
|
|
|
|
|
2025-08-09 21:16:24 +02:00
|
|
|
this.#_select.querySelectorAll("option").forEach((option) => {
|
|
|
|
this.#options.push({
|
|
|
|
value: option.value,
|
2025-08-09 22:14:35 +02:00
|
|
|
label: option.textContent,
|
|
|
|
search: searchable(option.textContent),
|
2025-08-09 21:16:24 +02:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
this.#build();
|
|
|
|
|
|
|
|
if (this.#options.length) {
|
|
|
|
this.#set(this.#options[0].value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#build() {
|
|
|
|
// prepare and hide original select
|
|
|
|
this.#_select.style.display = "none";
|
|
|
|
|
|
|
|
const descriptor = Object.getOwnPropertyDescriptor(
|
|
|
|
HTMLSelectElement.prototype,
|
|
|
|
"value",
|
|
|
|
);
|
|
|
|
|
|
|
|
Object.defineProperty(this.#_select, "value", {
|
|
|
|
get: () => {
|
|
|
|
return descriptor.get.call(this.#_select);
|
|
|
|
},
|
|
|
|
set: (value) => {
|
|
|
|
descriptor.set.call(this.#_select, value);
|
|
|
|
|
|
|
|
this.#set(value);
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
// dropdown
|
|
|
|
this.#_dropdown = make("div", "dropdown");
|
|
|
|
|
|
|
|
// selected item
|
|
|
|
this.#_selected = make("div", "selected");
|
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
this.#_selected.addEventListener("click", () => {
|
|
|
|
this.#_dropdown.classList.toggle("open");
|
|
|
|
});
|
|
|
|
|
2025-08-09 21:16:24 +02:00
|
|
|
this.#_dropdown.appendChild(this.#_selected);
|
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
// content
|
|
|
|
const _content = make("div", "cont");
|
|
|
|
|
|
|
|
this.#_dropdown.appendChild(_content);
|
|
|
|
|
2025-08-09 21:16:24 +02:00
|
|
|
// option wrapper
|
2025-08-09 22:14:35 +02:00
|
|
|
const _options = make("div", "opts");
|
2025-08-09 21:16:24 +02:00
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
_content.appendChild(_options);
|
2025-08-09 21:16:24 +02:00
|
|
|
|
|
|
|
// options
|
|
|
|
for (const option of this.#options) {
|
2025-08-09 22:14:35 +02:00
|
|
|
const _opt = make("div", "opt");
|
2025-08-09 21:16:24 +02:00
|
|
|
|
|
|
|
_opt.textContent = option.label;
|
|
|
|
|
|
|
|
_opt.addEventListener("click", () => {
|
|
|
|
this.#set(option.value);
|
2025-08-09 22:14:35 +02:00
|
|
|
|
|
|
|
this.#_dropdown.classList.remove("open");
|
2025-08-09 21:16:24 +02:00
|
|
|
});
|
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
_options.appendChild(_opt);
|
|
|
|
|
2025-08-09 21:16:24 +02:00
|
|
|
option.el = _opt;
|
|
|
|
}
|
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
// live search (if enabled)
|
|
|
|
if (this.#search) {
|
|
|
|
this.#_search = make("input", "search");
|
|
|
|
|
|
|
|
this.#_search.type = "text";
|
|
|
|
this.#_search.placeholder = "Search...";
|
|
|
|
|
|
|
|
this.#_search.addEventListener("input", () => {
|
|
|
|
this.#filter();
|
|
|
|
});
|
|
|
|
|
|
|
|
this.#_search.addEventListener("keydown", (event) => {
|
|
|
|
if (event.key !== "Escape") {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.#_search.value) {
|
|
|
|
this.#_search.value = "";
|
|
|
|
|
|
|
|
this.#_search.dispatchEvent(new Event("input"));
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.#_dropdown.classList.remove("open");
|
|
|
|
});
|
|
|
|
|
|
|
|
_content.appendChild(this.#_search);
|
|
|
|
}
|
|
|
|
|
2025-08-09 21:16:24 +02:00
|
|
|
// add to dom
|
|
|
|
this.#_select.after(this.#_dropdown);
|
|
|
|
|
|
|
|
this.#render();
|
|
|
|
}
|
|
|
|
|
|
|
|
#render() {
|
|
|
|
if (this.#selected === false) {
|
|
|
|
this.#_selected.textContent = "";
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const selection = this.#options[this.#selected];
|
|
|
|
|
|
|
|
this.#_selected.textContent = selection.label;
|
|
|
|
}
|
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
#filter() {
|
|
|
|
if (!this.#_search) {
|
|
|
|
return;
|
|
|
|
}
|
2025-08-09 21:16:24 +02:00
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
const query = searchable(this.#_search.value);
|
|
|
|
|
|
|
|
for (const option of this.#options) {
|
|
|
|
if (query && !option.search.includes(query)) {
|
|
|
|
option.el.classList.add("filtered");
|
|
|
|
} else {
|
|
|
|
option.el.classList.remove("filtered");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#set(value) {
|
2025-08-09 21:16:24 +02:00
|
|
|
const index = this.#options.findIndex((option) => option.value === value);
|
|
|
|
|
|
|
|
if (this.#selected === index) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.#selected = index !== -1 ? index : false;
|
|
|
|
|
|
|
|
this.#render();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-08-09 22:14:35 +02:00
|
|
|
function searchable(text) {
|
|
|
|
// lowercase
|
|
|
|
text = text.toLowerCase();
|
|
|
|
|
|
|
|
// only alpha-num
|
|
|
|
text = text.replace(/[^\w]/g, "");
|
|
|
|
|
|
|
|
return text.trim();
|
|
|
|
}
|
|
|
|
|
2025-08-09 21:16:24 +02:00
|
|
|
document.body.addEventListener("click", (event) => {
|
|
|
|
const clicked = event.target.closest(".dropdown");
|
|
|
|
|
|
|
|
document.querySelectorAll(".dropdown").forEach((element) => {
|
|
|
|
if (element === clicked) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
element.classList.remove("open");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
window.dropdown = (el) => new Dropdown(el);
|
|
|
|
})();
|