PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/typeahead_controller-21790530.js

js littlebirdelectronics.com.au collected 2026-09-25 14:11:59 UTC 3,029 bytes, 92 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Lightweight typeahead. Fetches /ecommerce/admin/search/{source}.json?q=...
4// Inserts the selected `handle` into the input.
5export default class extends Controller {
6  static targets = ["input", "results"]
7  static values = { source: String }
8
9  connect() {
10    this.abortCtrl = null
11    this.debounceTimer = null
12  }
13
14  disconnect() {
15    if (this.abortCtrl) this.abortCtrl.abort()
16    clearTimeout(this.debounceTimer)
17  }
18
19  search() {
20    clearTimeout(this.debounceTimer)
21    const q = this.inputTarget.value
22    if (!q || !q.trim()) {
23      if (this.abortCtrl) this.abortCtrl.abort()
24      this.resultsTarget.classList.add("hidden")
25      this.resultsTarget.innerHTML = ""
26      return
27    }
28    this.debounceTimer = setTimeout(() => this.fetchResults(q), 150)
29  }
30
31  async fetchResults(q) {
32    if (this.abortCtrl) this.abortCtrl.abort()
33    this.abortCtrl = new AbortController()
34    try {
35      const url = `/admin/search/${this.sourceValue}.json?q=${encodeURIComponent(q || "")}`
36      const res = await fetch(url, { signal: this.abortCtrl.signal, headers: { Accept: "application/json" } })
37      if (!res.ok) return
38      const data = await res.json()
39      this.render(data)
40    } catch (e) {
41      if (e.name === "AbortError") return
42      console.error(e)
43    }
44  }
45
46  render(items) {
47    const typed = this.inputTarget.value.trim().toLowerCase()
48    const rank = (h) => {
49      const handle = (h || "").toLowerCase()
50      if (handle === typed) return 0
51      if (typed && handle.startsWith(typed)) return 1
52      return 2
53    }
54    items = [...items].sort((a, b) => {
55      const ra = rank(a.handle)
56      const rb = rank(b.handle)
57      if (ra !== rb) return ra - rb
58      return a.handle.length - b.handle.length
59    })
60    if (!items.length) {
61      this.resultsTarget.classList.add("hidden")
62      this.resultsTarget.innerHTML = ""
63      return
64    }
65    this.resultsTarget.innerHTML = items.map(i => `
66      <button type="button" data-handle="${escapeHtml(i.handle)}" class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm">
67        <div class="font-medium">${escapeHtml(i.title)}</div>
68        <div class="text-xs text-gray-500 font-mono">${escapeHtml(i.handle)}</div>
69      </button>
70    `).join("")
71    this.resultsTarget.classList.remove("hidden")
72    this.resultsTarget.querySelectorAll("button").forEach(btn => {
73      btn.addEventListener("mousedown", (e) => {
74        e.preventDefault()
75        this.inputTarget.value = btn.dataset.handle
76        this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
77        this.resultsTarget.classList.add("hidden")
78      })
79    })
80  }
81
82  hide() {
83    setTimeout(() => {
84      this.resultsTarget.classList.add("hidden")
85      this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
86    }, 150)
87  }
88}
89
90function escapeHtml(str) {
91  return String(str).replace(/[&<>"']/g, s => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[s]))
92}

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.