Bug 38222: Add combobox component

Sponsored-by: Büchereizentrale Schleswig-Holstein <https://www.bz-sh.de/>
Signed-off-by: LEBSimonsen <simonsen@bz-sh.de>
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de>
This commit is contained in:
Paul Derscheid 2024-10-23 16:40:11 +00:00 committed by Katrin Fischer
parent 5f2807b370
commit d5098ed627
No known key found for this signature in database
GPG Key ID: 0EF6E2C03357A834
1 changed files with 344 additions and 0 deletions

View File

@ -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<Object>} [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
* <div class="combobox-container">
* <label for="generic-combobox" class="form-label">Choose an Option:</label>
* <input type="text" id="generic-combobox" class="form-control" placeholder="Select or type an option" />
* <ul id="generic-list" class="dropdown-menu position-fixed" style="max-height: 200px; overflow-y: auto; max-width: fit-content;">
* <li>
* <button type="button" class="dropdown-item" data-id="1">Option 1</button>
* </li>
* <li>
* <button type="button" class="dropdown-item" data-id="2">Option 2</button>
* </li>
* </ul>
* </div>
* <script>
* [% Asset.js("js/combobox.js" | $raw %]
* comboBox({
* inputId: 'generic-combobox',
* dropdownId: 'generic-list',
* data: [{ name: 'Option 3', id: '3' }, { name: 'Option 4', id: '4' }],
* displayProperty: 'name',
* valueProperty: 'id',
* useKeyAsValue: true,
* });
* // or using jQuery
* $("#generic-combobox").comboBox({ ... });
* </script>
* ```
*/
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 =
'<button type="button" class="dropdown-item text-muted" disabled>No matches found</button>';
noResultItem.setAttribute("role", "option");
dropdownMenu.appendChild(noResultItem);
return;
}
filteredData.forEach((item, index) => {
const optionItem = document.createElement("li");
optionItem.setAttribute("role", "option");
optionItem.innerHTML = `<button type="button" class="dropdown-item combobox-option" id="${inputId}-option-${index}" data-index="${index}" data-${
valueProperty ?? "id"
}="${item[valueProperty] || ""}">${
item[displayProperty]
}</button>`;
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<Element>} 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 <ul> found for input:",
inputId
);
return;
}
const instance = comboBox({
...methodOrOptions,
inputId: inputId,
dropdownId: dropdownId,
});
$(this).data("comboBoxInstance", instance);
});
};
}
global.comboBox = comboBox;
})(window, window.jQuery);