1import { Controller } from "@hotwired/stimulus" 2 3/** 4 * Client-side searchable combobox for large <select> elements. 5 * Replaces legacy jQuery Select2 on enrollment customer account picker. 6 */ 7export default class extends Controller { 8 static targets = ["select", "input", "listbox"] 9 static values = { 10 placeholder: { type: String, default: "Search..." } 11 } 12 13 #options = [] 14 #activeIndex = -1 15 16 connect() { 17 this.#options = Array.from(this.selectTarget.options).map((option) => ({ 18 value: option.value, 19 label: option.textContent.trim(), 20 disabled: option.disabled 21 })) 22 23 const selected = this.selectTarget.selectedOptions[0] 24 if (selected?.value) { 25 this.inputTarget.value = selected.textContent.trim() 26 } else { 27 this.inputTarget.placeholder = this.placeholderValue 28 } 29 30 this.boundCloseOnClickOutside = this.closeOnClickOutside.bind(this) 31 document.addEventListener("click", this.boundCloseOnClickOutside) 32 } 33 34 disconnect() { 35 document.removeEventListener("click", this.boundCloseOnClickOutside) 36 } 37 38 open() { 39 this.#updateListbox() 40 this.#showListbox() 41 } 42 43 close() { 44 this.listboxTarget.classList.add("hidden") 45 this.inputTarget.setAttribute("aria-expanded", "false") 46 this.#activeIndex = -1 47 } 48 49 filter() { 50 this.#updateListbox() 51 this.#showListbox() 52 } 53 54 #updateListbox() { 55 const query = this.inputTarget.value.trim().toLowerCase() 56 const selected = this.selectTarget.selectedOptions[0] 57 58 if (query === "") { 59 this.selectTarget.value = "" 60 } else if (!selected || selected.textContent.trim().toLowerCase() !== query) { 61 this.selectTarget.value = "" 62 } 63 64 const matches = this.#options.filter((option) => { 65 if (option.disabled || option.value === "") return false 66 return query === "" || option.label.toLowerCase().includes(query) 67 }) 68 69 this.#renderOptions(matches) 70 } 71 72 #showListbox() { 73 this.listboxTarget.classList.remove("hidden") 74 this.inputTarget.setAttribute("aria-expanded", "true") 75 } 76 77 selectOption(event) { 78 const button = event.currentTarget 79 const value = button.dataset.value 80 const label = button.dataset.label 81 82 this.selectTarget.value = value 83 this.inputTarget.value = label 84 this.selectTarget.dispatchEvent(new Event("change", { bubbles: true })) 85 this.close() 86 } 87 88 clearSelection() { 89 this.selectTarget.value = "" 90 this.inputTarget.value = "" 91 this.selectTarget.dispatchEvent(new Event("change", { bubbles: true })) 92 this.filter() 93 } 94 95 handleKeydown(event) { 96 const buttons = this.listboxTarget.querySelectorAll("[data-value]") 97 if (buttons.length === 0) return 98 99 if (event.key === "ArrowDown") { 100 event.preventDefault() 101 this.#activeIndex = Math.min(this.#activeIndex + 1, buttons.length - 1) 102 this.#highlightActive(buttons) 103 } else if (event.key === "ArrowUp") { 104 event.preventDefault() 105 this.#activeIndex = Math.max(this.#activeIndex - 1, 0) 106 this.#highlightActive(buttons) 107 } else if (event.key === "Enter" && this.#activeIndex >= 0) { 108 event.preventDefault() 109 buttons[this.#activeIndex].click() 110 } else if (event.key === "Escape") { 111 this.close() 112 } 113 } 114 115 closeOnClickOutside(event) { 116 if (!this.element.contains(event.target)) { 117 this.close() 118 } 119 } 120 121 #highlightActive(buttons) { 122 buttons.forEach((button, index) => { 123 button.classList.toggle("bg-sc-raised", index === this.#activeIndex) 124 }) 125 buttons[this.#activeIndex]?.scrollIntoView({ block: "nearest" }) 126 } 127 128 #renderOptions(matches) { 129 if (matches.length === 0) { 130 this.listboxTarget.innerHTML = '<p class="px-3 py-2 text-sm text-sc-text-3">No matches found</p>' 131 return 132 } 133 134 this.listboxTarget.innerHTML = matches.map((option) => ` 135 <button type="button" 136 role="option" 137 data-action="searchable-select#selectOption" 138 data-value="${this.#escape(option.value)}" 139 data-label="${this.#escape(option.label)}" 140 class="block w-full px-3 py-2 text-left text-sm text-sc-text hover:bg-sc-raised"> 141 ${this.#escape(option.label)} 142 </button> 143 `).join("") 144 145 this.#activeIndex = -1 146 } 147 148 #escape(text) { 149 return text 150 .replace(/&/g, "&") 151 .replace(/"/g, """) 152 .replace(/</g, "<") 153 .replace(/>/g, ">") 154 } 155} 156;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.