xref: /plugin/struct/script/vanilla-combobox.js (revision 24eec657c05b0d72bf071b3cc0551de15e9d5f32)
1/**
2 * A custom web component that transforms a standard HTML select into a combo box,
3 * allowing users to both select from a dropdown and search by typing.
4 */
5/* */
6class VanillaCombobox extends HTMLElement {
7    #select;
8    #input;
9    #dropdown;
10    #separator;
11    #multiple;
12    #placeholder;
13    #outsideClickListener;
14
15    // region initialization
16
17    /**
18     * Creates a new VanillaCombobox instance.
19     * Initializes the shadow DOM.
20     */
21    constructor() {
22        super();
23        this.attachShadow({mode: 'open'});
24    }
25
26    /**
27     * Called when the element is connected to the DOM.
28     * Initializes the component, sets up the shadow DOM, and binds event listeners.
29     * @returns {void}
30     */
31    connectedCallback() {
32        this.#separator = this.getAttribute('separator') || ',';
33        this.#placeholder = this.getAttribute('placeholder') || '';
34        this.#setupShadowDOM();
35        this.#initializeStyles();
36        this.#updateInputFromSelect();
37        this.#registerEventListeners();
38    }
39
40    /**
41     * Most event handlers will be garbage collected when the element is removed from the DOM.
42     * However, we need to remove the outside click listener attached to the document to prevent memory leaks.
43     * @returns {void}
44     */
45    disconnectedCallback() {
46        document.removeEventListener('click', this.#outsideClickListener);
47    }
48
49    /**
50     * Sets up the shadow DOM with the necessary elements and styles.
51     * Creates the input field and dropdown container.
52     * @private
53     * @returns {void}
54     */
55    #setupShadowDOM() {
56        // Get the select element from the light DOM
57        this.#select = this.querySelector('select');
58        if (!this.#select) {
59            console.error('VanillaCombobox: No select element found');
60            return;
61        }
62
63        this.#multiple = this.#select.multiple;
64
65        // Create the input field
66        this.#input = document.createElement('input');
67        this.#input.type = 'text';
68        this.#input.autocomplete = 'off';
69        this.#input.part = 'input';
70        this.#input.placeholder = this.#placeholder;
71        this.#input.required = this.#select.required;
72        this.#input.disabled = this.#select.disabled;
73
74        // Create the dropdown container
75        this.#dropdown = document.createElement('div');
76        this.#dropdown.className = 'dropdown';
77        this.#dropdown.part = 'dropdown';
78        this.#dropdown.style.display = 'none';
79
80        // Add styles to the shadow DOM
81        const style = document.createElement('style');
82        style.textContent = `
83            :host {
84                display: inline-block;
85                position: relative;
86            }
87            .dropdown {
88                position: absolute;
89                max-height: 200px;
90                overflow-y: auto;
91                border: 1px solid FieldText;
92                background-color: Field;
93                color: FieldText;
94                z-index: 1000;
95                width: max-content;
96                box-sizing: border-box;
97            }
98            .option {
99                padding: 5px 10px;
100                cursor: pointer;
101            }
102            .option:hover, .option.selected {
103                background-color: Highlight;
104                color: HighlightText;
105            }
106        `;
107
108        // Append elements to the shadow DOM
109        this.shadowRoot.appendChild(style);
110
111        this.shadowRoot.appendChild(this.#input);
112        this.shadowRoot.appendChild(this.#dropdown);
113    }
114
115    /**
116     * Initializes the styles for the combobox components.
117     * Copies styles from browser defaults and applies them to the custom elements.
118     * @private
119     * @returns {void}
120     */
121    #initializeStyles() {
122        // create a temporary input element to copy styles from
123        const input = document.createElement('input');
124        this.parentElement.insertBefore(input, this);
125        const defaultStyles = window.getComputedStyle(input);
126
127        // browser default styles
128        const inputStyles = window.getComputedStyle(this.#input);
129        const dropdownStyles = window.getComputedStyle(this.#dropdown);
130
131        // copy styles from the temporary input to the input element
132        for (const property of defaultStyles) {
133            const newValue = defaultStyles.getPropertyValue(property);
134            const oldValue = inputStyles.getPropertyValue(property);
135            if (newValue === oldValue) continue;
136            this.#input.style.setProperty(property, newValue);
137        }
138        this.#input.style.outline = 'none';
139
140        // copy select styles to the dropdown
141        for (const property of defaultStyles) {
142            const newValue = defaultStyles.getPropertyValue(property);
143            const oldValue = dropdownStyles.getPropertyValue(property);
144            if (newValue === oldValue) continue;
145            if (!property.match(/^(border|color|background|font|padding)/)) continue;
146            this.#dropdown.style.setProperty(property, newValue);
147        }
148        this.#dropdown.style.minWidth = `${this.#input.offsetWidth}px`;
149        this.#dropdown.style.borderTop = 'none';
150
151        // remove the temporary input element
152        this.parentElement.removeChild(input);
153    }
154
155    // endregion
156    // region Event Handling
157
158    /**
159     * Registers all event listeners for the combobox.
160     * Sets up input, focus, blur, and keyboard events.
161     * @private
162     * @returns {void}
163     */
164    #registerEventListeners() {
165        this.#input.addEventListener('focus', this.#onFocus.bind(this));
166        // Delay to allow click event on dropdown
167        this.#input.addEventListener('blur', () => setTimeout(() => this.#onBlur(), 150));
168        this.#input.addEventListener('input', this.#onInput.bind(this));
169        this.#input.addEventListener('keydown', this.#onKeyDown.bind(this));
170
171        this.#outsideClickListener = (event) => {
172            if (this.contains(event.target) || this.shadowRoot.contains(event.target)) return;
173            this.#closeDropdown();
174        };
175        document.addEventListener('click', this.#outsideClickListener);
176    }
177
178
179    /**
180     * Handles the focus event on the input field.
181     * Updates the values and appends the separator if multiple selection is enabled.
182     * Shows the dropdown with available options.
183     * @private
184     * @returns {void}
185     */
186    #onFocus() {
187        this.#updateInputFromSelect();
188        this.#showDropdown();
189    }
190
191    /**
192     * Handles the blur event on the input field.
193     * Closes the dropdown and updates the input field (removes the separator).
194     * Synchronizes the select element with the input value.
195     * @private
196     * @returns {void}
197     */
198    #onBlur() {
199        this.#closeDropdown();
200        this.#updateSelectFromInput();
201        this.#updateInputFromSelect();
202    }
203
204    /**
205     * Handles the input event on the input field.
206     * Shows the dropdown with filtered options based on the current input.
207     * @private
208     * @returns {void}
209     */
210    #onInput() {
211        this.#showDropdown();
212    }
213
214    /**
215     * Handles keyboard navigation in the dropdown.
216     * Supports arrow keys, Enter, and Escape.
217     * @private
218     * @param {KeyboardEvent} event - The keyboard event
219     * @returns {void}
220     */
221    #onKeyDown(event) {
222        // Only handle keyboard navigation if dropdown is visible
223        if (this.#dropdown.style.display !== 'block') return;
224
225        const items = this.#dropdown.querySelectorAll('.option');
226        const selectedItem = this.#dropdown.querySelector('.option.selected');
227        let selectedIndex = Array.from(items).indexOf(selectedItem);
228
229        switch (event.key) {
230            case 'ArrowDown':
231                event.preventDefault();
232                selectedIndex = (selectedIndex + 1) % items.length;
233                this.#highlightItem(items, selectedIndex);
234                break;
235
236            case 'ArrowUp':
237                event.preventDefault();
238                selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
239                this.#highlightItem(items, selectedIndex);
240                break;
241
242            case 'Enter':
243                event.preventDefault();
244                if (selectedItem) {
245                    selectedItem.click();
246                }
247                break;
248
249            case 'Escape':
250                event.preventDefault();
251                this.#dropdown.style.display = 'none';
252                break;
253        }
254    }
255
256    // endregion
257    // region Data Handling
258
259    /**
260     * Updates the input field value based on the selected options in the select element.
261     * Joins multiple selections with the separator if multiple selection is enabled.
262     * @private
263     * @returns {void}
264     */
265    #updateInputFromSelect() {
266        const selectedOptions = Array.from(this.#select.selectedOptions);
267
268        if (selectedOptions.length > 0) {
269            this.#input.value = selectedOptions
270                .map(option => option.textContent)
271                .join(`${this.#separator} `);
272        } else {
273            this.#input.value = '';
274        }
275
276        // If the input is focused and multiple selection is enabled, append the separator
277        if ((this.shadowRoot.activeElement === this.#input) && this.#multiple && this.#input.value !== '') {
278            this.#input.value += this.#separator + ' ';
279        }
280    }
281
282    /**
283     * Gets all selected options and unselects those whose text is no longer in the input.
284     * Synchronizes the select element with the input field content.
285     * @private
286     * @returns {void}
287     */
288    #updateSelectFromInput() {
289        const selectedOptions = Array.from(this.#select.selectedOptions);
290        let inputTexts = [this.#input.value];
291        if (this.#multiple) {
292            inputTexts = this.#input.value.split(this.#separator).map(text => text.trim());
293        }
294
295        selectedOptions.forEach(option => {
296            if (!inputTexts.includes(option.textContent)) {
297                option.selected = false;
298            }
299        })
300    }
301
302    // endregion
303    // region Dropdown Handling
304
305    /**
306     * Shows the dropdown with options filtered by the current input value.
307     * Creates option elements for each matching option.
308     * @private
309     * @returns {void}
310     */
311    #showDropdown() {
312        // get the currently edited value
313        let query = this.#input.value.trim();
314        if (this.#multiple) {
315            query = query.split(this.#separator).pop().trim()
316        }
317
318        // Filter the options matching the input value
319        const options = Array.from(this.#select.options);
320        const filteredOptions = options.filter(
321            option => option.textContent.toLowerCase().includes(query.toLowerCase())
322        );
323        if (filteredOptions.length === 0) {
324            this.#closeDropdown();
325            return;
326        }
327
328        // Create the dropdown items
329        this.#dropdown.innerHTML = '';
330        filteredOptions.forEach(option => {
331            if (this.#multiple && option.value === '') return; // Ignore empty options in multiple mode
332
333            const div = document.createElement('div');
334            div.textContent = option.textContent;
335            div.className = 'option';
336            div.part = 'option';
337            this.#dropdown.appendChild(div);
338
339            // Add click event to each option
340            div.addEventListener('click', () => this.#selectOption(option));
341        });
342
343        // Show the dropdown
344        this.#dropdown.style.display = 'block';
345    }
346
347    /**
348     * Closes the dropdown by hiding it.
349     * @private
350     * @returns {void}
351     */
352    #closeDropdown() {
353        this.#dropdown.style.display = 'none';
354    }
355
356    /**
357     * Highlights a specific item in the dropdown.
358     * Removes selection from all items and adds it to the specified one.
359     * @private
360     * @param {NodeListOf<Element>} items - The dropdown items
361     * @param {number} index - The index of the item to highlight
362     * @returns {void}
363     */
364    #highlightItem(items, index) {
365        // Remove selection from all items
366        items.forEach(item => item.classList.remove('selected'));
367
368        // Add selection to current item
369        if (items[index]) {
370            items[index].classList.add('selected');
371            // Ensure the selected item is visible in the dropdown
372            items[index].scrollIntoView({block: 'nearest'});
373        }
374    }
375
376    /**
377     * Selects an option from the dropdown.
378     * Updates the select element and input field, then closes the dropdown.
379     * @private
380     * @param {HTMLOptionElement} option - The option to select
381     * @returns {void}
382     */
383    #selectOption(option) {
384        option.selected = true;
385        this.#updateInputFromSelect();
386        this.#closeDropdown();
387        this.#input.focus();
388    }
389
390    // endregion
391}
392
393// Register the custom element
394customElements.define('vanilla-combobox', VanillaCombobox);
395