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