import { UiElement } from "./UiElement.js";
import "./Dropdown.css";
export { Dropdown };
/**
* Dropdown menu
*
* @author rhess <robin.hess@awi.de>
* @memberof vef.ui
*/
class Dropdown extends UiElement {
classes = 'vef-dropdown';
html = `
<div class='vef-dropdown-header' tabindex="0">
<div class='vef-dropdown-header-inner'>
<span class="preset-title"></span>
<i class='fas fa-chevron-down'></i>
</div>
</div>
<div class='vef-dropdown-menu'>
<div class='vef-dropdown-search'></div>
<div class='vef-dropdown-content'></div>
</div>
`;
/**
* options = {
* items: object (object with entries for dropdown),
* showDeselectItem: boolean
* deselectLabel: string
* }
*
* @param {HTMLElement | string} target
* @param {object} options
*/
constructor(target, options) {
super(target, {
"select": []
});
this.content_ = null;
this.selected_ = null;
this.searchbar_ = null;
this.items_ = [];
this.allItems_ = [];
this.placeholder_ = (options && options.placeholder) ? options.placeholder : "select an item ...";
this.deselectLabel_ = (options && (options.deselectLabel)) ? options.deselectLabel : "... deselect";
this.showDeselectItem = (options && (typeof options.showDeselectItem == "boolean")) ? options.showDeselectItem : true;
this.initElement_();
if (options && options.items) this.setItems(options.items);
if (options.search) this.initSearch_(options.searchPlaceholder);
}
/**
* initialize the Ui element.
* Called once in the constructor
*
* @private
*/
initElement_() {
this.setHtml(this.html);
this.setClass(this.classes);
this.query(".preset-title").innerText = this.placeholder_;
let open = false;
const event = () => {
if (!open) {
this.setClass("open");
if (this.searchbar_) this.searchbar_.focus();
document.body.addEventListener("click", event);
document.body.addEventListener("keydown", (e) => {
if (e.key === "Enter") event();
});
} else {
this.removeClass("open");
document.body.removeEventListener("click", event);
document.body.addEventListener("keydown", (e) => {
if (e.key === "Enter") event();
});
}
open = !open;
}
this.getElement().addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.stopPropagation();
if (e.target.nodeName == "INPUT") {
return;
} else {
event();
}
}
});
this.getElement().addEventListener("click", (e) => {
e.stopPropagation();
if (e.target.nodeName == "INPUT") {
return;
} else {
event();
}
});
this.content_ = this.query(".vef-dropdown-content");
}
/**
* helper method to add an element to deselect
* the current value from the dropdown
*
* @private
*/
addDeselectItem_() {
if (this.showDeselectItem) {
const deselectNodes = [...this.content_.childNodes].filter(el => el.textContent === this.deselectLabel_);
if (deselectNodes.length === 0) {
const element = this.createItemElement_(this.deselectLabel_, null, null);
element.onclick = () => {
this.deselect();
this.fire("select", null);
};
this.content_.prepend(element);
}
}
}
/**
* Removes the helper method set by addDeselectItem_()
*
* @private
*/
removeDeselectItem_() {
if (this.showDeselectItem) {
const deselectNodes = [...this.content_.childNodes].filter(el => el.textContent === this.deselectLabel_);
deselectNodes.forEach((node) => {
this.content_.removeChild(node)
})
}
}
/**
* Helper method for creating the
* HTMLElement of an item
*
* @param {string} text
* @param {string} title
* @param {object} css
* @returns {HTMLElement} element
*/
createItemElement_(label, title, css) {
const element = document.createElement("div");
element.setAttribute("tabindex", "0")
element.classList.add("vef-dropdown-item");
if (css) {
for (let i in css) {
element.style[i] = css[i];
}
}
const text = document.createElement("div");
text.classList.add("vef-dropdown-item-text");
text.title = title;
text.innerText = label;
element.appendChild(text);
return element;
}
/**
* called when an item gets selected.
* Sets the name in the Ui
*
* @param {object} item
* @private
*/
select_(item) {
if (!item && !this.selected_) return false;
const title = this.query(".vef-dropdown-header .preset-title");
if (item) {
title.innerText = item.key;
this.selected_ = item;
this.addDeselectItem_();
} else {
title.innerText = this.placeholder_;
this.selected_ = null;
}
return true;
}
/**
* Set the available items in the dropdown
*
* @param {object} items
*/
setItems(items) {
this.allItems_ = items;
this.setItems_(items);
}
/**
* Internal method to set the visible items
*
* @param {object} items
* @private
*/
setItems_(items) {
this.clear();
if (this.selected_)
this.addDeselectItem_();
for (let i in items) {
this.addItem(i, items[i]);
}
}
/**
* Add an item to the dropdown
*
* @param {string} key
* @param {*} item
* @param {object} css (optional) styling for dropdown item
*/
addItem(key, item, css) {
const element = this.createItemElement_(key, item.title, css)
const itemWrapper = {
key: key,
item: item
};
element.onclick = () => {
this.select_(itemWrapper);
this.fire("select", itemWrapper);
};
element.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
this.select_(itemWrapper);
this.fire("select", itemWrapper);
}
})
this.content_.appendChild(element);
this.items_.push(itemWrapper);
}
/**
* Remove all items
*/
clear() {
this.content_.innerHTML = "";
this.items_ = [];
}
/**
* Get the currently selected color item
*/
getSelectedItem() {
return this.selected_;
}
/**
* Set the dropdown menu to deselected.
*/
deselect() {
this.removeDeselectItem_()
this.select_(null);
}
/**
* Selet an item by the given key
*
* @param {string} key
*/
select(key) {
for (let i = 0; i < this.items_.length; ++i) {
const item = this.items_[i];
if (item.key == key) {
this.select_(item);
return;
}
}
}
/**
* Add search in titles if needed
*
* @private
*/
initSearch_(placeholder) {
let search = this.query(".vef-dropdown-search")
search.innerHTML = `<input spellcheck="false" placeholder="${(placeholder) ? placeholder : 'search items ...'}" class="query" type="text"/>`;
const input = search.querySelector(".query");
input.addEventListener("focus", (e) => {
e.stopPropagation();
});
// init search events
let searchTimeout = null;
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.search(input.value.toLowerCase().trim());
searchTimeout = null;
}, 750);
});
input.addEventListener("keydown", (e) => {
if (e.key == "Enter") {
clearTimeout(searchTimeout);
this.search(input.value.toLowerCase().trim());
}
});
this.searchbar_ = input;
}
/**
* Search in titles and add to dropdown
* @param {string} query
*/
search(query) {
/**
* Computes the size of a text based on the font.
* Source: Domi (2014, January 9). Calculate text width with JavaScript. Stackoverflow. https://stackoverflow.com/a/21015393.
* @param {String} text - Text.
* @param {String} font - CSS font, i.e., 'bold 14px "PT Sans", sans-serif'.
* @returns {Number} Text width.
*/
function _getTextWidth(text, font) {
// re-usage of canvas object for better performance
const canvas = _getTextWidth.canvas || (_getTextWidth.canvas = document.createElement("canvas"));
const context = canvas.getContext("2d");
context.font = font;
const metrics = context.measureText(text);
return metrics.width;
}
const font = 'bold ' + getComputedStyle(this.getElement()).font;
const availableTextSpace = this.getElement().querySelector("input").clientWidth;
const matchedObjectEntries = Object.entries(this.allItems_).filter(
([key, val]) => key.toLowerCase().includes(query)
)
const matchedObjectEntriesShortened = matchedObjectEntries.map(([key, value]) => {
const queryPosition = key.toLowerCase().indexOf(query);
let keyShortened;
let offset = 0;
const requiredTextSpace = Math.ceil(_getTextWidth(key, font));
while (Math.ceil(_getTextWidth(keyShortened, font)) < availableTextSpace) {
if (queryPosition > offset && requiredTextSpace > availableTextSpace) {
keyShortened = "..." + key.toLowerCase().substring(queryPosition - offset);
offset += 1
}
else break
}
return [keyShortened || key, value]
})
const filtered = Object.fromEntries(matchedObjectEntriesShortened);
this.setItems_(filtered);
}
}