View | Details | Raw Unified | Return to bug 38222
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/js/combobox.js (-1 / +344 lines)
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);

Return to bug 38222