Line 0
Link Here
|
0 |
- |
1 |
(function (global, $) { |
|
|
2 |
/** |
3 |
* Initializes a combobox widget with the given configuration. |
4 |
* |
5 |
* @param {Object} config - Configuration object for the combobox. |
6 |
* @param {string} config.inputId - The ID of the input element acting as the combobox. |
7 |
* @param {string} config.dropdownId - The ID of the dropdown element containing the options. |
8 |
* @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`. |
9 |
* @param {string} [config.displayProperty='name'] - The property of the option objects to be displayed in the dropdown. |
10 |
* @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. |
11 |
* @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). |
12 |
* @param {string} [config.placeholder='Select or type a value'] - Placeholder text for the input element. |
13 |
* @param {string} [config.labelId=''] - Optional ID of the associated label element. |
14 |
* |
15 |
* @example |
16 |
* ```html |
17 |
* <div class="combobox-container"> |
18 |
* <label for="generic-combobox" class="form-label">Choose an Option:</label> |
19 |
* <input type="text" id="generic-combobox" class="form-control" placeholder="Select or type an option" /> |
20 |
* <ul id="generic-list" class="dropdown-menu position-fixed" style="max-height: 200px; overflow-y: auto; max-width: fit-content;"> |
21 |
* <li> |
22 |
* <button type="button" class="dropdown-item" data-id="1">Option 1</button> |
23 |
* </li> |
24 |
* <li> |
25 |
* <button type="button" class="dropdown-item" data-id="2">Option 2</button> |
26 |
* </li> |
27 |
* </ul> |
28 |
* </div> |
29 |
* <script> |
30 |
* [% Asset.js("js/combobox.js" | $raw %] |
31 |
* comboBox({ |
32 |
* inputId: 'generic-combobox', |
33 |
* dropdownId: 'generic-list', |
34 |
* data: [{ name: 'Option 3', id: '3' }, { name: 'Option 4', id: '4' }], |
35 |
* displayProperty: 'name', |
36 |
* valueProperty: 'id', |
37 |
* useKeyAsValue: true, |
38 |
* }); |
39 |
* // or using jQuery |
40 |
* $("#generic-combobox").comboBox({ ... }); |
41 |
* </script> |
42 |
* ``` |
43 |
*/ |
44 |
function comboBox(config) { |
45 |
const { |
46 |
inputId, |
47 |
dropdownId, |
48 |
data = [], |
49 |
displayProperty = "name", |
50 |
valueProperty = "id", |
51 |
placeholder = "Select or type a value", |
52 |
labelId = "", |
53 |
useKeyAsValue = false, |
54 |
} = config; |
55 |
|
56 |
const input = document.getElementById(inputId); |
57 |
const dropdownMenu = document.getElementById(dropdownId); |
58 |
if (!input || !dropdownMenu) { |
59 |
console.error("Invalid element IDs provided for combobox"); |
60 |
return; |
61 |
} |
62 |
|
63 |
const bootstrapDropdown = new bootstrap.Dropdown(input, { |
64 |
autoClose: false, |
65 |
}); |
66 |
|
67 |
// Existing options from HTML |
68 |
const existingOptions = Array.from(dropdownMenu.querySelectorAll("li")) |
69 |
.map(li => { |
70 |
const actionElement = li.querySelector("button, a"); |
71 |
return actionElement |
72 |
? { |
73 |
[displayProperty]: actionElement.textContent.trim(), |
74 |
[valueProperty]: |
75 |
actionElement.dataset?.[valueProperty], |
76 |
} |
77 |
: null; |
78 |
}) |
79 |
.filter(option => option !== null); |
80 |
|
81 |
const combinedData = [...existingOptions, ...data]; |
82 |
|
83 |
let selectedValue = null; |
84 |
let query = ""; |
85 |
let focusedIndex = -1; |
86 |
|
87 |
// Setup input attributes |
88 |
input.setAttribute("placeholder", placeholder); |
89 |
input.setAttribute("aria-expanded", "false"); |
90 |
input.setAttribute("autocomplete", "off"); |
91 |
input.setAttribute("role", "combobox"); |
92 |
input.setAttribute("aria-haspopup", "listbox"); |
93 |
input.setAttribute("aria-controls", dropdownId); |
94 |
if (labelId) { |
95 |
input.setAttribute("aria-labelledby", labelId); |
96 |
} |
97 |
input.classList.add("form-control"); |
98 |
|
99 |
dropdownMenu.classList.add("dropdown-menu"); |
100 |
dropdownMenu.setAttribute("role", "listbox"); |
101 |
|
102 |
const group = input.closest(".combobox-container"); |
103 |
group.addEventListener("focusin", () => |
104 |
input.setAttribute("aria-expanded", "true") |
105 |
); |
106 |
group.addEventListener("focusout", e => { |
107 |
setTimeout(() => { |
108 |
if (!group.contains(document.activeElement)) { |
109 |
hideDropdown(); |
110 |
} |
111 |
}, 0); |
112 |
}); |
113 |
|
114 |
input.addEventListener("input", handleInputChange); |
115 |
input.addEventListener("focus", () => showDropdown()); |
116 |
input.addEventListener("keydown", handleKeyNavigation); |
117 |
dropdownMenu.addEventListener("click", handleOptionSelect); |
118 |
|
119 |
/** |
120 |
* Shows the dropdown and updates the options. |
121 |
*/ |
122 |
function showDropdown() { |
123 |
bootstrapDropdown.show(); |
124 |
input.setAttribute("aria-expanded", "true"); |
125 |
updateDropdown(); |
126 |
} |
127 |
|
128 |
/** |
129 |
* Hides the dropdown and resets focus. |
130 |
*/ |
131 |
function hideDropdown() { |
132 |
bootstrapDropdown.hide(); |
133 |
input.setAttribute("aria-expanded", "false"); |
134 |
focusedIndex = -1; |
135 |
input.removeAttribute("aria-activedescendant"); |
136 |
} |
137 |
|
138 |
/** |
139 |
* Handles input changes, updates the query and dropdown. |
140 |
* |
141 |
* @param {Event} event - The input event. |
142 |
*/ |
143 |
function handleInputChange(event) { |
144 |
query = event.target.value.toLowerCase(); |
145 |
updateDropdown(); |
146 |
} |
147 |
|
148 |
/** |
149 |
* Handles option selection from the dropdown. |
150 |
* |
151 |
* @param {Event} event - The click event. |
152 |
*/ |
153 |
function handleOptionSelect(event) { |
154 |
const actionElement = event.target.closest("button, a"); |
155 |
if ( |
156 |
actionElement && |
157 |
actionElement.classList.contains("dropdown-item") |
158 |
) { |
159 |
input.value = useKeyAsValue |
160 |
? actionElement.dataset?.[valueProperty] |
161 |
: actionElement.textContent; |
162 |
selectedValue = combinedData.find( |
163 |
item => |
164 |
item[displayProperty] === |
165 |
actionElement.textContent.trim() |
166 |
); |
167 |
hideDropdown(); |
168 |
} |
169 |
} |
170 |
|
171 |
/** |
172 |
* Updates the dropdown based on the current query. |
173 |
*/ |
174 |
function updateDropdown() { |
175 |
dropdownMenu.innerHTML = ""; |
176 |
const filteredData = query |
177 |
? combinedData.filter(item => |
178 |
item[displayProperty].toLowerCase().includes(query) |
179 |
) |
180 |
: combinedData; |
181 |
|
182 |
if (filteredData.length === 0 && query !== "") { |
183 |
const noResultItem = document.createElement("li"); |
184 |
noResultItem.innerHTML = |
185 |
'<button type="button" class="dropdown-item text-muted" disabled>No matches found</button>'; |
186 |
noResultItem.setAttribute("role", "option"); |
187 |
dropdownMenu.appendChild(noResultItem); |
188 |
return; |
189 |
} |
190 |
|
191 |
filteredData.forEach((item, index) => { |
192 |
const optionItem = document.createElement("li"); |
193 |
optionItem.setAttribute("role", "option"); |
194 |
optionItem.innerHTML = `<button type="button" class="dropdown-item combobox-option" id="${inputId}-option-${index}" data-index="${index}" data-${ |
195 |
valueProperty ?? "id" |
196 |
}="${item[valueProperty] || ""}">${ |
197 |
item[displayProperty] |
198 |
}</button>`; |
199 |
dropdownMenu.appendChild(optionItem); |
200 |
}); |
201 |
} |
202 |
|
203 |
/** |
204 |
* Handles keyboard navigation within the dropdown. |
205 |
* |
206 |
* @param {KeyboardEvent} event - The keyboard event. |
207 |
*/ |
208 |
function handleKeyNavigation(event) { |
209 |
const items = dropdownMenu.querySelectorAll(".dropdown-item"); |
210 |
if (!items || items.length === 0) return; |
211 |
|
212 |
switch (event.key) { |
213 |
case "ArrowDown": |
214 |
case "ArrowUp": |
215 |
event.preventDefault(); |
216 |
if (event.altKey) { |
217 |
if (event.key === "ArrowDown") showDropdown(); |
218 |
if (event.key === "ArrowUp") hideDropdown(); |
219 |
return; |
220 |
} |
221 |
focusedIndex = |
222 |
(focusedIndex + |
223 |
(event.key === "ArrowDown" ? 1 : -1) + |
224 |
items.length) % |
225 |
items.length; |
226 |
focusOption(items); |
227 |
break; |
228 |
case "Enter": |
229 |
if (focusedIndex >= 0 && items[focusedIndex]) { |
230 |
items[focusedIndex].click(); |
231 |
} |
232 |
break; |
233 |
case "Tab": |
234 |
hideDropdown(); |
235 |
break; |
236 |
case " ": |
237 |
if (focusedIndex >= 0 && items[focusedIndex]) { |
238 |
event.preventDefault(); |
239 |
items[focusedIndex].click(); |
240 |
} |
241 |
break; |
242 |
case "Escape": |
243 |
hideDropdown(); |
244 |
break; |
245 |
default: |
246 |
break; |
247 |
} |
248 |
} |
249 |
|
250 |
/** |
251 |
* Focuses a specific option based on the index. |
252 |
* |
253 |
* @param {NodeListOf<Element>} items - The list of dropdown items. |
254 |
*/ |
255 |
function focusOption(items) { |
256 |
items.forEach((item, index) => { |
257 |
item.classList.toggle("active", index === focusedIndex); |
258 |
const actionElement = item.querySelector("button, a"); |
259 |
if (index === focusedIndex && actionElement) { |
260 |
actionElement.focus(); |
261 |
input.setAttribute( |
262 |
"aria-activedescendant", |
263 |
actionElement.id |
264 |
); |
265 |
} |
266 |
}); |
267 |
|
268 |
if (focusedIndex >= items.length) { |
269 |
focusedIndex = items.length - 1; |
270 |
} else if (focusedIndex < 0) { |
271 |
focusedIndex = 0; |
272 |
} |
273 |
} |
274 |
|
275 |
/** |
276 |
* Resets the combobox to its initial state. |
277 |
*/ |
278 |
function reset() { |
279 |
input.value = ""; |
280 |
query = ""; |
281 |
focusedIndex = -1; |
282 |
selectedValue = null; |
283 |
hideDropdown(); |
284 |
} |
285 |
|
286 |
return { |
287 |
getSelectedValue: () => selectedValue, |
288 |
reset, |
289 |
}; |
290 |
} |
291 |
|
292 |
if ($) { |
293 |
$.fn.comboBox = function (methodOrOptions) { |
294 |
if (typeof methodOrOptions === "string") { |
295 |
const methodName = methodOrOptions; |
296 |
const args = Array.prototype.slice.call(arguments, 1); |
297 |
let returnValue; |
298 |
|
299 |
this.each(function () { |
300 |
const instance = $(this).data("comboBoxInstance"); |
301 |
if (!instance) { |
302 |
console.error( |
303 |
`comboBox not initialized on element with id: ${this.id}` |
304 |
); |
305 |
return; |
306 |
} |
307 |
|
308 |
if (typeof instance[methodName] === "function") { |
309 |
returnValue = instance[methodName](...args); |
310 |
} else { |
311 |
console.error( |
312 |
`Method ${methodName} does not exist on comboBox` |
313 |
); |
314 |
} |
315 |
}); |
316 |
|
317 |
return returnValue !== undefined ? returnValue : this; |
318 |
} |
319 |
|
320 |
return this.each(function () { |
321 |
const inputId = this.id; |
322 |
const dropdownId = $(this).next("ul").attr("id"); |
323 |
|
324 |
if (!dropdownId) { |
325 |
console.error( |
326 |
"No associated dropdown <ul> found for input:", |
327 |
inputId |
328 |
); |
329 |
return; |
330 |
} |
331 |
|
332 |
const instance = comboBox({ |
333 |
...methodOrOptions, |
334 |
inputId: inputId, |
335 |
dropdownId: dropdownId, |
336 |
}); |
337 |
|
338 |
$(this).data("comboBoxInstance", instance); |
339 |
}); |
340 |
}; |
341 |
} |
342 |
|
343 |
global.comboBox = comboBox; |
344 |
})(window, window.jQuery); |