2023-12-10 18:36:25 +01:00

336 lines
12 KiB
TypeScript

export class Dropdown {
#container: HTMLElement;
#options: HTMLElement;
#value: HTMLElement;
#callback: CallableFunction;
#defaultValue: string;
#optionsList: string[] = [];
#labelsList: string[] | undefined = [];
#index: number = 0;
#hidden: boolean = false;
#text!: HTMLElement;
constructor(ID: string | null, callback: CallableFunction, options: string[] | null = null, defaultText?: string) {
if (ID === null)
this.#container = this.#createElement(defaultText);
else
this.#container = document.getElementById(ID) as HTMLElement;
this.#options = this.#container.querySelector(".ol-select-options") as HTMLElement;
const text = this.#container.querySelector(".ol-select-value-text");
this.#value = (text instanceof HTMLElement) ? text : this.#container.querySelector(".ol-select-value") as HTMLElement;
this.#defaultValue = this.#value.innerText;
this.#callback = callback;
if (options != null) this.setOptions(options);
(this.#container.querySelector(".ol-select-value") as HTMLElement)?.addEventListener("click", (ev) => { this.#toggle(); });
document.addEventListener("click", (ev) => {
if (!(this.#value.contains(ev.target as Node) || this.#options.contains(ev.target as Node) || this.#container.contains(ev.target as Node))) {
this.close();
}
});
this.#options.classList.add("ol-scrollable");
}
getContainer() {
return this.#container;
}
/** Set the dropdown options strings
*
* @param optionsList List of options. These are the keys that will always be returned on selection
* @param sort Sort method. null means no sorting. "string" performs js default sort. "number" sorts purely by numeric value.
* "string+number" sorts by string, unless two elements are lexicographically identical up to a numeric value (e.g. "SA-2" and "SA-3"), in which case it sorts by number.
* @param labelsList (Optional) List of labels to be shown instead of the keys directly. If provided, the options will be sorted by label.
*/
setOptions(optionsList: string[], sort: null | "string" | "number" | "string+number" = "string", labelsList: string[] | undefined = undefined) {
if (sort != null) {
/* If labels are provided, sort by labels, else by options */
if (labelsList && labelsList.length == optionsList.length)
this.#sortByLabels(optionsList, sort, labelsList);
else
this.#sortByOptions(optionsList, sort);
} else {
this.#optionsList = optionsList;
}
/* If no options are provided, return */
if (this.#optionsList.length == 0) {
optionsList = ["No options available"]
this.#value.innerText = "No options available";
return;
}
/* Create the buttons containing the options or the labels */
this.#options.replaceChildren(...this.#optionsList.map((option: string, idx: number) => {
var div = document.createElement("div");
var button = document.createElement("button");
/* If the labels are provided use them for the options */
if (this.#labelsList && this.#labelsList.length === optionsList.length)
button.textContent = this.#labelsList[idx];
else
button.textContent = option;
div.appendChild(button);
if (option === this.#defaultValue)
this.#index = idx;
button.addEventListener("click", (e: MouseEvent) => {
e.stopPropagation();
this.selectValue(idx);
});
return div;
}));
}
getOptionsList() {
return this.#optionsList;
}
getLabelsList() {
return this.#labelsList;
}
/** Manually set the HTMLElements of the dropdown values. Handling of the selection must be performed externally.
*
* @param optionsElements List of elements to be added to the dropdown
*/
setOptionsElements(optionsElements: HTMLElement[]) {
this.#optionsList = [];
this.#labelsList = [];
this.#options.replaceChildren(...optionsElements);
}
getOptionElements() {
return this.#options.children;
}
addOptionElement(optionElement: HTMLElement) {
this.#options.appendChild(optionElement);
}
/** Select the active value of the dropdown
*
* @param idx The index of the element to select
* @returns True if the index is valid, false otherwise
*/
selectValue(idx: number) {
if (idx < this.#optionsList.length) {
var option = this.#optionsList[idx];
var el = document.createElement("div");
el.classList.add("ol-ellipsed");
if (this.#labelsList && this.#labelsList.length == this.#optionsList.length)
el.innerText = this.#labelsList[idx];
else
el.innerText = option;
this.#value.replaceChildren();
this.#value.appendChild(el);
this.#index = idx;
this.close();
this.#callback(option);
return true;
}
else
return false;
}
reset() {
this.#options.replaceChildren();
this.#value.innerText = this.#defaultValue;
}
/** Manually set the selected value of the dropdown
*
* @param value The value to select. Must be one of the valid options
*/
setValue(value: string) {
var index = this.#optionsList.findIndex((option) => { return option === value });
if (index > -1)
this.selectValue(index);
}
getValue() {
return this.#value.innerText;
}
/** Force the selected value of the dropdown.
*
* @param value Any string. Will be shown as selected value even if not one of the options.
*/
forceValue(value: string) {
var el = document.createElement("div");
el.classList.add("ol-ellipsed");
el.innerText = value;
this.#value.replaceChildren();
this.#value.appendChild(el);
this.close();
}
getIndex() {
return this.#index;
}
clip() {
const options = this.#options;
const bounds = options.getBoundingClientRect();
this.#container.dataset.position = (bounds.bottom > window.innerHeight) ? "top" : "";
}
close() {
this.#container.classList.remove("is-open");
this.#container.dataset.position = "";
}
open() {
this.#container.classList.add("is-open");
this.#options.classList.toggle("scrollbar-visible", this.#options.scrollHeight > this.#options.clientHeight);
this.clip();
}
show() {
this.#container.classList.remove("hide");
this.#hidden = false;
}
hide() {
this.#container.classList.add("hide");
this.#hidden = true;
}
isHidden() {
return this.#hidden;
}
#toggle() {
this.#container.classList.contains("is-open") ? this.close() : this.open();
}
#createElement(defaultText: string | undefined) {
var div = document.createElement("div");
div.classList.add("ol-select");
var value = document.createElement("div");
value.classList.add("ol-select-value");
value.innerText = defaultText ? defaultText : "";
var options = document.createElement("div");
options.classList.add("ol-select-options");
div.append(value, options);
return div;
}
/** Sort the elements by their option keys
*
* @param optionsList The unsorted list of options
* @param sort The sorting method
*/
#sortByOptions(optionsList: string[], sort: string) {
if (sort === "number") {
this.#optionsList = JSON.parse(JSON.stringify(this.#numberSort(optionsList)));
} else if (sort === "string+number") {
this.#optionsList = JSON.parse(JSON.stringify(this.#stringNumberSort(optionsList)));
} else if (sort === "string") {
this.#optionsList = JSON.parse(JSON.stringify(this.#stringSort(optionsList)));
}
}
/** Sort the elements by their labels
*
* @param optionsList The unsorted list of options
* @param sort The sorting method
* @param labelsList The unsorted list of labels. The elements will be sorted according to these values
*/
#sortByLabels(optionsList: string[], sort: string, labelsList: string[]) {
/* Create a temporary deepcopied list. This is necessary because unlike options, labels can be repeated.
Once matched, labels are removed from the temporary array to avoid repeating the same key multiple times */
var tempLabelsList: (string | undefined)[] = JSON.parse(JSON.stringify(labelsList));
if (sort === "number") {
this.#labelsList = JSON.parse(JSON.stringify(this.#numberSort(labelsList)));
} else if (sort === "string+number") {
this.#labelsList = JSON.parse(JSON.stringify(this.#stringNumberSort(labelsList)));
} else if (sort === "string") {
this.#labelsList = JSON.parse(JSON.stringify(this.#stringSort(labelsList)));
}
/* Remap the options list to match their labels */
this.#optionsList = optionsList?.map((option: string, idx: number) => {
let originalIdx = tempLabelsList.indexOf(this.#labelsList? this.#labelsList[idx]: "");
/* After a match has been completed, set the label to undefined so it won't be matched again. This allows to have repeated labels */
tempLabelsList[originalIdx] = undefined;
return optionsList[originalIdx];
})
}
/** Sort elements by number. All elements must be parsable as numbers.
*
* @param elements List of strings
* @returns Sorted list
*/
#numberSort(elements: string[]) {
return elements.sort((elementA: string, elementB: string) => {
const a = parseFloat(elementA);
const b = parseFloat(elementB);
if (a > b)
return 1;
else
return (b > a) ? -1 : 0;
});
}
/** Sort elements by string, unless two elements are lexicographically identical up to a numeric value (e.g. "SA-2" and "SA-3"), in which case sort by number
*
* @param elements List of strings
* @returns Sorted list
*/
#stringNumberSort(elements: string[]) {
return elements.sort((elementA: string, elementB: string) => {
/* Check if there is a number in both strings */
var regex = /\d+/g;
var matchesA = elementA.match(regex);
var matchesB = elementB.match(regex);
/* Get the position of the number in the string */
var indexA = -1;
var indexB = -1;
if (matchesA != null && matchesA?.length > 0)
indexA = elementA.search(matchesA[0]);
if (matchesB != null && matchesB?.length > 0)
indexB = elementB.search(matchesB[0]);
/* If the two strings are the same up to the number, sort them using the number value, else sort them according to the string */
if ((matchesA != null && matchesA?.length > 0) && (matchesB != null && matchesB?.length > 0) && elementA.substring(0, indexA) === elementB.substring(0, indexB)) {
const a = parseInt(matchesA[0] ?? 0);
const b = parseInt(matchesB[0] ?? 0);
if (a > b)
return 1;
else
return (b > a) ? -1 : 0;
} else {
if (elementA > elementB)
return 1;
else
return (elementB > elementA) ? -1 : 0;
}
});
}
/** Sort by string. Just a wrapper for consistency.
*
* @param elements List of strings
* @returns Sorted list
*/
#stringSort(elements: string[]) {
return elements.sort();
}
}