From d5098ed6278cd5c30c8e70cd7c1a221fbecbbce0 Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Wed, 23 Oct 2024 16:40:11 +0000 Subject: [PATCH] Bug 38222: Add combobox component MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Sponsored-by: Büchereizentrale Schleswig-Holstein Signed-off-by: LEBSimonsen Signed-off-by: Martin Renvoize Signed-off-by: Katrin Fischer --- koha-tmpl/intranet-tmpl/prog/js/combobox.js | 344 ++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 koha-tmpl/intranet-tmpl/prog/js/combobox.js diff --git a/koha-tmpl/intranet-tmpl/prog/js/combobox.js b/koha-tmpl/intranet-tmpl/prog/js/combobox.js new file mode 100644 index 0000000000..39f3aee077 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/combobox.js @@ -0,0 +1,344 @@ +(function (global, $) { + /** + * Initializes a combobox widget with the given configuration. + * + * @param {Object} config - Configuration object for the combobox. + * @param {string} config.inputId - The ID of the input element acting as the combobox. + * @param {string} config.dropdownId - The ID of the dropdown element containing the options. + * @param {Array} [config.data=[]] - Array of options to populate the dropdown. Each object should have a key matching the `displayProperty` and an optional unique identifier key matching the `valueProperty`. + * @param {string} [config.displayProperty='name'] - The property of the option objects to be displayed in the dropdown. + * @param {string} [config.valueProperty='id'] - The property of the option objects to use as the value of the input. If not set, `useKeyAsValue` must be true. + * @param {boolean} [config.useKeyAsValue=false] - Whether to use the option's key (either HTML data-* or JavaScript `valueProperty`) as the value of the input (default: false). + * @param {string} [config.placeholder='Select or type a value'] - Placeholder text for the input element. + * @param {string} [config.labelId=''] - Optional ID of the associated label element. + * + * @example + * ```html + *
+ * + * + * + *
+ * + * ``` + */ + function comboBox(config) { + const { + inputId, + dropdownId, + data = [], + displayProperty = "name", + valueProperty = "id", + placeholder = "Select or type a value", + labelId = "", + useKeyAsValue = false, + } = config; + + const input = document.getElementById(inputId); + const dropdownMenu = document.getElementById(dropdownId); + if (!input || !dropdownMenu) { + console.error("Invalid element IDs provided for combobox"); + return; + } + + const bootstrapDropdown = new bootstrap.Dropdown(input, { + autoClose: false, + }); + + // Existing options from HTML + const existingOptions = Array.from(dropdownMenu.querySelectorAll("li")) + .map(li => { + const actionElement = li.querySelector("button, a"); + return actionElement + ? { + [displayProperty]: actionElement.textContent.trim(), + [valueProperty]: + actionElement.dataset?.[valueProperty], + } + : null; + }) + .filter(option => option !== null); + + const combinedData = [...existingOptions, ...data]; + + let selectedValue = null; + let query = ""; + let focusedIndex = -1; + + // Setup input attributes + input.setAttribute("placeholder", placeholder); + input.setAttribute("aria-expanded", "false"); + input.setAttribute("autocomplete", "off"); + input.setAttribute("role", "combobox"); + input.setAttribute("aria-haspopup", "listbox"); + input.setAttribute("aria-controls", dropdownId); + if (labelId) { + input.setAttribute("aria-labelledby", labelId); + } + input.classList.add("form-control"); + + dropdownMenu.classList.add("dropdown-menu"); + dropdownMenu.setAttribute("role", "listbox"); + + const group = input.closest(".combobox-container"); + group.addEventListener("focusin", () => + input.setAttribute("aria-expanded", "true") + ); + group.addEventListener("focusout", e => { + setTimeout(() => { + if (!group.contains(document.activeElement)) { + hideDropdown(); + } + }, 0); + }); + + input.addEventListener("input", handleInputChange); + input.addEventListener("focus", () => showDropdown()); + input.addEventListener("keydown", handleKeyNavigation); + dropdownMenu.addEventListener("click", handleOptionSelect); + + /** + * Shows the dropdown and updates the options. + */ + function showDropdown() { + bootstrapDropdown.show(); + input.setAttribute("aria-expanded", "true"); + updateDropdown(); + } + + /** + * Hides the dropdown and resets focus. + */ + function hideDropdown() { + bootstrapDropdown.hide(); + input.setAttribute("aria-expanded", "false"); + focusedIndex = -1; + input.removeAttribute("aria-activedescendant"); + } + + /** + * Handles input changes, updates the query and dropdown. + * + * @param {Event} event - The input event. + */ + function handleInputChange(event) { + query = event.target.value.toLowerCase(); + updateDropdown(); + } + + /** + * Handles option selection from the dropdown. + * + * @param {Event} event - The click event. + */ + function handleOptionSelect(event) { + const actionElement = event.target.closest("button, a"); + if ( + actionElement && + actionElement.classList.contains("dropdown-item") + ) { + input.value = useKeyAsValue + ? actionElement.dataset?.[valueProperty] + : actionElement.textContent; + selectedValue = combinedData.find( + item => + item[displayProperty] === + actionElement.textContent.trim() + ); + hideDropdown(); + } + } + + /** + * Updates the dropdown based on the current query. + */ + function updateDropdown() { + dropdownMenu.innerHTML = ""; + const filteredData = query + ? combinedData.filter(item => + item[displayProperty].toLowerCase().includes(query) + ) + : combinedData; + + if (filteredData.length === 0 && query !== "") { + const noResultItem = document.createElement("li"); + noResultItem.innerHTML = + ''; + noResultItem.setAttribute("role", "option"); + dropdownMenu.appendChild(noResultItem); + return; + } + + filteredData.forEach((item, index) => { + const optionItem = document.createElement("li"); + optionItem.setAttribute("role", "option"); + optionItem.innerHTML = ``; + dropdownMenu.appendChild(optionItem); + }); + } + + /** + * Handles keyboard navigation within the dropdown. + * + * @param {KeyboardEvent} event - The keyboard event. + */ + function handleKeyNavigation(event) { + const items = dropdownMenu.querySelectorAll(".dropdown-item"); + if (!items || items.length === 0) return; + + switch (event.key) { + case "ArrowDown": + case "ArrowUp": + event.preventDefault(); + if (event.altKey) { + if (event.key === "ArrowDown") showDropdown(); + if (event.key === "ArrowUp") hideDropdown(); + return; + } + focusedIndex = + (focusedIndex + + (event.key === "ArrowDown" ? 1 : -1) + + items.length) % + items.length; + focusOption(items); + break; + case "Enter": + if (focusedIndex >= 0 && items[focusedIndex]) { + items[focusedIndex].click(); + } + break; + case "Tab": + hideDropdown(); + break; + case " ": + if (focusedIndex >= 0 && items[focusedIndex]) { + event.preventDefault(); + items[focusedIndex].click(); + } + break; + case "Escape": + hideDropdown(); + break; + default: + break; + } + } + + /** + * Focuses a specific option based on the index. + * + * @param {NodeListOf} items - The list of dropdown items. + */ + function focusOption(items) { + items.forEach((item, index) => { + item.classList.toggle("active", index === focusedIndex); + const actionElement = item.querySelector("button, a"); + if (index === focusedIndex && actionElement) { + actionElement.focus(); + input.setAttribute( + "aria-activedescendant", + actionElement.id + ); + } + }); + + if (focusedIndex >= items.length) { + focusedIndex = items.length - 1; + } else if (focusedIndex < 0) { + focusedIndex = 0; + } + } + + /** + * Resets the combobox to its initial state. + */ + function reset() { + input.value = ""; + query = ""; + focusedIndex = -1; + selectedValue = null; + hideDropdown(); + } + + return { + getSelectedValue: () => selectedValue, + reset, + }; + } + + if ($) { + $.fn.comboBox = function (methodOrOptions) { + if (typeof methodOrOptions === "string") { + const methodName = methodOrOptions; + const args = Array.prototype.slice.call(arguments, 1); + let returnValue; + + this.each(function () { + const instance = $(this).data("comboBoxInstance"); + if (!instance) { + console.error( + `comboBox not initialized on element with id: ${this.id}` + ); + return; + } + + if (typeof instance[methodName] === "function") { + returnValue = instance[methodName](...args); + } else { + console.error( + `Method ${methodName} does not exist on comboBox` + ); + } + }); + + return returnValue !== undefined ? returnValue : this; + } + + return this.each(function () { + const inputId = this.id; + const dropdownId = $(this).next("ul").attr("id"); + + if (!dropdownId) { + console.error( + "No associated dropdown
    found for input:", + inputId + ); + return; + } + + const instance = comboBox({ + ...methodOrOptions, + inputId: inputId, + dropdownId: dropdownId, + }); + + $(this).data("comboBoxInstance", instance); + }); + }; + } + + global.comboBox = comboBox; +})(window, window.jQuery); -- 2.39.5