PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/ecommerce/combo_search_controller-c05265d2.js

js littlebirdelectronics.com.au collected 2026-09-25 14:10:09 UTC 7,949 bytes, 253 lines download raw bytes

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 = "&times;"
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.