Newer
Older
vmk-ui-kit / src / js / components / advanced-select.js
/**
 * advancedSelect browser helper.
 *
 * Mirrors gnexus-ui-kit advancedSelect public surface.
 *
 * @param {HTMLInputElement} input - Text input to enhance
 * @param {object} options - Map of value => displayValue
 * @param {string} [notFoundText='Nothing found'] - Empty state message
 * @returns {HTMLElement} Advanced select container
 */

function scrollToElementInFocus(container) {
	const focus = container.querySelector(".option.focus");
	if(!focus) return;

	const containerRect = container.getBoundingClientRect();
	const focusRect = focus.getBoundingClientRect();

	if(focusRect.top < containerRect.top) {
		container.scrollTop -= (containerRect.top - focusRect.top);
	} else if(focusRect.bottom > containerRect.bottom) {
		container.scrollTop += (focusRect.bottom - containerRect.bottom);
	}
}

function autoSetState(container) {
	const totalViewed = container.advancedSelect.optionsElements.length - container.querySelectorAll(".option.hide").length;
	if(totalViewed === 0) {
		container.advancedSelect.showState("not-found");
	} else {
		container.advancedSelect.showState("options");
	}
}

function firstVisibleOption(container) {
	return container.querySelector(".option:not(.hide)");
}

function lastVisibleOption(container) {
	const options = container.querySelectorAll(".option:not(.hide)");
	return options[options.length - 1] || null;
}

function selectOption(input, container, option) {
	if(!option) {
		return;
	}

	input.value = option.dataset.displayValue;
	input.blur();
	input.dispatchEvent(new Event("input", { bubbles: true }));
	input.dispatchEvent(new Event("change", { bubbles: true }));
	container.advancedSelect.dispatchEvent("selected");
	container.advancedSelect.closeList();
}

function existsOption(value, options) {
	for(const optionValue in options) {
		if(options[optionValue] === value) {
			return { [optionValue]: options[optionValue] };
		}
	}
	return false;
}

export default function advancedSelect(input, options, notFoundText) {
	const wrapper = document.createElement("div");
	wrapper.className = "advanced-select-container";
	input.parentElement?.insertBefore(wrapper, input);

	if(!input.classList.contains("input")) {
		input.classList.add("input");
	}

	wrapper.append(input);

	const container = document.createElement("div");
	container.className = "advanced-select";

	const popup = document.createElement("div");
	popup.className = "popup-options-container";

	const notFound = document.createElement("div");
	notFound.className = "not-found";
	notFound.textContent = notFoundText ?? "Nothing found";

	const optionsContainer = document.createElement("div");
	optionsContainer.className = "options";

	for(const optionValue in options) {
		const option = document.createElement("div");
		option.className = "option";
		option.dataset.value = optionValue;
		option.dataset.displayValue = options[optionValue];
		option.textContent = options[optionValue];
		optionsContainer.append(option);
	}

	popup.append(notFound, optionsContainer);
	container.append(popup);
	wrapper.append(container);

	container.advancedSelect = {
		isOpened: false,
		options,
		eventsHandlers: {
			openList: [],
			closeList: [],
			selected: [],
			changed: []
		},
		openList: () => {
			container.advancedSelect.isOpened = true;
			container.classList.add("a-show");
			autoSetState(container);
			container.advancedSelect.dispatchEvent("openList");
		},
		closeList: () => {
			container.advancedSelect.isOpened = false;
			container.classList.remove("a-show");
			autoSetState(container);
			container.advancedSelect.dispatchEvent("closeList");
		},
		showState: stateName => {
			if(stateName === "options") {
				container.querySelector(".options").classList.add("show");
				container.querySelector(".not-found").classList.remove("show");
			} else if(stateName === "not-found") {
				container.querySelector(".options").classList.remove("show");
				container.querySelector(".not-found").classList.add("show");
			}
		},
		optionsElements: container.querySelectorAll(".option"),
		value: () => {
			const option = existsOption(input.value, options);
			return {
				inputValue: input.value,
				isOption: option ? true : false,
				option
			};
		},
		addEventListener: (name, handler) => {
			if(typeof container.advancedSelect.eventsHandlers[name] !== "undefined") {
				container.advancedSelect.eventsHandlers[name].push(handler);
				return;
			}
			console.error("Advanced Select component.", "addEventListener()", "Invalid event name");
		},
		dispatchEvent: name => {
			if(typeof container.advancedSelect.eventsHandlers[name] === "undefined") {
				return console.error("Advanced Select component.", "dispatchEvent()", "Invalid event name");
			}

			for(const eventHandler of container.advancedSelect.eventsHandlers[name]) {
				eventHandler(container);
			}
		}
	};

	input.setAttribute("autocomplete", "nope");

	input.advancedSelect = {
		value: () => container.advancedSelect.value()
	};

	input.addEventListener("focus", () => {
		container.advancedSelect.openList();
	});

	input.addEventListener("blur", () => {
		requestAnimationFrame(() => {
			if(!container.matches(":hover")) {
				container.advancedSelect.closeList();
			}
		});
	});

	input.addEventListener("keydown", event => {
		if(event.key === "ArrowUp") {
			event.preventDefault();
			const current = container.querySelector(".option.focus");
			if(current) {
				current.classList.remove("focus");
				let prev = current.previousElementSibling;
				while(prev) {
					if(!prev.classList.contains("hide")) {
						break;
					}
					prev = prev.previousElementSibling;
				}
				if(!prev) {
					prev = lastVisibleOption(container);
				}
				prev?.classList.add("focus");
			} else {
				lastVisibleOption(container)?.classList.add("focus");
			}
			scrollToElementInFocus(container);
		} else if(event.key === "ArrowDown") {
			event.preventDefault();
			const current = container.querySelector(".option.focus");
			if(current) {
				current.classList.remove("focus");
				let next = current.nextElementSibling;
				while(next) {
					if(!next.classList.contains("hide")) {
						break;
					}
					next = next.nextElementSibling;
				}
				if(!next) {
					next = firstVisibleOption(container);
				}
				next?.classList.add("focus");
			} else {
				firstVisibleOption(container)?.classList.add("focus");
			}
			scrollToElementInFocus(container);
		} else if(event.key === "Enter") {
			event.preventDefault();
			const selected = container.querySelector(".option.focus");
			selectOption(input, container, selected);
		} else if(event.key === "Escape") {
			container.advancedSelect.closeList();
			input.blur();
		}
	});

	input.addEventListener("input", event => {
		const val = event.currentTarget.value.toLowerCase();
		if(val === "") {
			container.advancedSelect.optionsElements.forEach(i => i.classList.remove("hide"));
		} else {
			[...container.advancedSelect.optionsElements]
				.filter(i => i.dataset.displayValue.toLowerCase().includes(val))
				.forEach(i => i.classList.remove("hide"));

			[...container.advancedSelect.optionsElements]
				.filter(i => !i.dataset.displayValue.toLowerCase().includes(val))
				.forEach(i => i.classList.add("hide"));
		}

		autoSetState(container);
		container.querySelector(".option.focus")?.classList.remove("focus");
	});

	input.addEventListener("change", () => {
		container.advancedSelect.dispatchEvent("changed");
	});

	[...container.advancedSelect.optionsElements].forEach(option => {
		option.addEventListener("pointerdown", event => {
			event.preventDefault();
			selectOption(input, container, event.currentTarget);
		});
	});

	return container;
}