1import { Controller } from "@hotwired/stimulus" 2 3// Async combo search box that replaces <select> with a searchable input. 4// Fetches results from a URL endpoint as the user types. 5// 6// Usage: 7// <div data-controller="ecommerce--combo-search" 8// data-ecommerce--combo-search-url-value="/admin/products/search_variants" 9// data-ecommerce--combo-search-param-value="q"> 10// <input data-ecommerce--combo-search-target="input" type="text" placeholder="Search..."> 11// <input data-ecommerce--combo-search-target="hidden" type="hidden" name="field_name"> 12// <ul data-ecommerce--combo-search-target="results"></ul> 13// </div> 14export default class extends Controller { 15 static targets = ["input", "hidden", "results", "selected", "clearButton", "chips"] 16 static values = { 17 url: String, 18 param: { type: String, default: "q" }, 19 extraParams: { type: String, default: "" }, 20 minChars: { type: Number, default: 2 }, 21 multiple: { type: Boolean, default: false }, 22 hiddenName: { type: String, default: "" } 23 } 24 25 connect() { 26 this.timeout = null 27 this.selectedIndex = -1 28 this.resultsVisible = false 29 this.selectedIds = new Set() 30 31 this.boundClose = this.closeOnOutside.bind(this) 32 document.addEventListener("click", this.boundClose) 33 } 34 35 disconnect() { 36 clearTimeout(this.timeout) 37 document.removeEventListener("click", this.boundClose) 38 } 39 40 search() { 41 clearTimeout(this.timeout) 42 const query = this.inputTarget.value.trim() 43 44 if (query.length < this.minCharsValue) { 45 this.hideResults() 46 return 47 } 48 49 this.timeout = setTimeout(() => this.fetchResults(query), 250) 50 } 51 52 async fetchResults(query) { 53 const url = new URL(this.urlValue, window.location.origin) 54 url.searchParams.set(this.paramValue, query) 55 56 // Append extra params (e.g. "exclude_product_id=123&min_variants=2") 57 if (this.extraParamsValue) { 58 const extra = new URLSearchParams(this.extraParamsValue) 59 for (const [key, value] of extra) { 60 if (key.endsWith("[]")) { 61 url.searchParams.append(key, value) 62 } else { 63 url.searchParams.set(key, value) 64 } 65 } 66 } 67 68 // In multi-select mode, exclude already-chosen chips so they can't be picked twice 69 if (this.multipleValue) { 70 for (const id of this.selectedIds) { 71 url.searchParams.append("exclude_ids[]", id) 72 } 73 } 74 75 try { 76 const response = await fetch(url, { 77 headers: { "Accept": "application/json" } 78 }) 79 if (!response.ok) return 80 81 const data = await response.json() 82 this.renderResults(data) 83 } catch (e) { 84 // silently fail 85 } 86 } 87 88 renderResults(items) { 89 this.selectedIndex = -1 90 const list = this.resultsTarget 91 92 if (items.length === 0) { 93 // list-none like the result rows below: base.css bullets any li that is 94 // still display:list-item, and this one isn't a flex row. 95 list.innerHTML = `<li class="list-none px-3 py-2 text-sm text-gray-500 dark:text-gray-400">No results found</li>` 96 this.showResults() 97 return 98 } 99 100 list.innerHTML = items.map((item, i) => { 101 const meta = item.meta 102 ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono mt-0.5">${this.escapeHtml(item.meta)}</div>` 103 : "" 104 const thumb = item.image_url 105 ? `<img src="${this.escapeHtml(item.image_url)}" alt="" loading="lazy" class="shrink-0 w-10 h-10 rounded object-cover border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">` 106 : `<div class="shrink-0 w-10 h-10 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"></div>` 107 return `<li class="combo-result list-none px-3 py-2 text-sm text-gray-900 dark:text-gray-100 cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-900/30 flex items-center gap-3" 108 data-id="${item.id}" 109 data-label="${this.escapeHtml(item.label)}" 110 data-action="click->ecommerce--combo-search#select" 111 data-index="${i}">${thumb}<div class="min-w-0"><div class="truncate">${this.escapeHtml(item.label)}</div>${meta}</div></li>` 112 }).join("") 113 114 this.showResults() 115 } 116 117 select(event) { 118 const li = event.currentTarget 119 const id = li.dataset.id 120 const label = li.dataset.label 121 122 if (this.multipleValue) { 123 if (this.selectedIds.has(id)) return 124 this.selectedIds.add(id) 125 this.addChip(id, label) 126 this.inputTarget.value = "" 127 this.hideResults()
128 this.inputTarget.focus() 129 return 130 } 131 132 if (this.hasHiddenTarget) this.hiddenTarget.value = id 133 this.inputTarget.value = label 134 135 if (this.hasSelectedTarget) { 136 this.selectedTarget.textContent = label 137 this.selectedTarget.classList.remove("hidden") 138 } 139 140 if (this.hasClearButtonTarget) { 141 this.clearButtonTarget.classList.remove("hidden") 142 } 143 144 this.hideResults() 145 } 146 147 addChip(id, label) { 148 if (!this.hasChipsTarget) return 149 150 const chip = document.createElement("span") 151 chip.className = "inline-flex items-center gap-1.5 bg-indigo-100 dark:bg-indigo-900/40 text-indigo-800 dark:text-indigo-200 rounded-full pl-3 pr-1.5 py-1 text-xs" 152 chip.dataset.chipId = id 153 154 const text = document.createElement("span") 155 text.textContent = label 156 chip.appendChild(text) 157 158 const hidden = document.createElement("input") 159 hidden.type = "hidden" 160 hidden.name = this.hiddenNameValue || "ids[]" 161 hidden.value = id 162 chip.appendChild(hidden) 163 164 const remove = document.createElement("button") 165 remove.type = "button" 166 remove.className = "text-indigo-600 dark:text-indigo-300 hover:text-indigo-900 dark:hover:text-indigo-50 leading-none text-base" 167 remove.innerHTML = "×" 168 remove.setAttribute("aria-label", `Remove ${label}`) 169 remove.dataset.action = "click->ecommerce--combo-search#removeChip" 170 chip.appendChild(remove) 171 172 this.chipsTarget.appendChild(chip) 173 } 174 175 removeChip(event) { 176 const chip = event.currentTarget.closest("[data-chip-id]") 177 if (!chip) return 178 this.selectedIds.delete(chip.dataset.chipId) 179 chip.remove() 180 } 181 182 keydown(event) { 183 const items = this.resultsTarget.querySelectorAll(".combo-result") 184 if (!items.length || !this.resultsVisible) return 185 186 switch (event.key) { 187 case "ArrowDown": 188 event.preventDefault() 189 this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1) 190 this.highlightItem(items) 191 break 192 case "ArrowUp": 193 event.preventDefault() 194 this.selectedIndex = Math.max(this.selectedIndex - 1, 0) 195 this.highlightItem(items) 196 break 197 case "Enter": 198 event.preventDefault() 199 if (this.selectedIndex >= 0 && items[this.selectedIndex]) { 200 items[this.selectedIndex].click() 201 } 202 break 203 case "Escape": 204 this.hideResults() 205 break 206 } 207 } 208 209 clear() { 210 this.hiddenTarget.value = "" 211 this.inputTarget.value = "" 212 if (this.hasSelectedTarget) { 213 this.selectedTarget.classList.add("hidden") 214 } 215 if (this.hasClearButtonTarget) { 216 this.clearButtonTarget.classList.add("hidden") 217 } 218 this.hideResults() 219 this.inputTarget.focus() 220 } 221 222 highlightItem(items) { 223 items.forEach((item, i) => { 224 if (i === this.selectedIndex) { 225 item.classList.add("bg-indigo-50", "dark:bg-indigo-900/30") 226 } else { 227 item.classList.remove("bg-indigo-50", "dark:bg-indigo-900/30") 228 } 229 }) 230 } 231 232 showResults() { 233 this.resultsTarget.classList.remove("hidden") 234 this.resultsVisible = true 235 } 236 237 hideResults() { 238 this.resultsTarget.classList.add("hidden") 239 this.resultsVisible = false 240 } 241 242 closeOnOutside(event) { 243 if (!this.element.contains(event.target)) { 244 this.hideResults() 245 } 246 } 247 248 escapeHtml(text) { 249 const div = document.createElement("div") 250 div.textContent = text 251 return div.innerHTML 252 } 253}
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.