PageSourceSearch

https://www.orient-latin.com/assets/controllers/search_controller-…4551b40fc34eebbdfa17ba5ef4d625016322d444.js

js orient-latin.com collected 2026-10-02 11:47:01 UTC 4,120 bytes, 133 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Autocomplétion de recherche de forteresses — remplace jQuery UI (retiré du gabarit).
4// Consomme l'endpoint JSON EXISTANT et inchangé : [{ id, label (HTML), value }], param `term`.
5// Au choix d'une suggestion → navigation vers la fiche /fortresses/:id.
6// Clavier (↓ ↑ Entrée Échap) et ARIA (combobox / listbox / option).
7export default class extends Controller {
8  static targets = ["input", "results"]
9  static values = {
10    url: String, // endpoint JSON de recherche
11    basePath: String, // préfixe des fiches (fortresses_path)
12    minLength: { type: Number, default: 2 }
13  }
14
15  connect() {
16    this.activeIndex = -1
17    this.onDocClick = this.onDocClick.bind(this)
18    document.addEventListener("click", this.onDocClick)
19  }
20
21  disconnect() {
22    document.removeEventListener("click", this.onDocClick)
23    clearTimeout(this.debounce)
24    if (this.abort) this.abort.abort()
25  }
26
27  onInput() {
28    // Ne pas trimer le terme envoyé : un espace en tête/queue vaut frontière
29    // de mot pour la recherche (cf. docs/CONVENTIONS.md). Le seuil de
30    // déclenchement, lui, s'évalue sur le terme trimé (tout-blanc = vide).
31    const term = this.inputTarget.value
32    clearTimeout(this.debounce)
33    if (term.trim().length < this.minLengthValue) {
34      this.close()
35      return
36    }
37    this.debounce = setTimeout(() => this.fetchResults(term), 180)
38  }
39
40  async fetchResults(term) {
41    if (this.abort) this.abort.abort()
42    this.abort = new AbortController()
43    try {
44      const resp = await fetch(`${this.urlValue}?term=${encodeURIComponent(term)}`, {
45        headers: { Accept: "application/json" },
46        signal: this.abort.signal
47      })
48      if (!resp.ok) return
49      this.render(await resp.json())
50    } catch (e) {
51      if (e.name !== "AbortError") this.close()
52    }
53  }
54
55  render(items) {
56    if (!Array.isArray(items) || items.length === 0) {
57      this.close()
58      return
59    }
60    // item.label est du HTML de confiance (généré côté serveur).
61    this.resultsTarget.innerHTML = items
62      .map(
63        (item, i) =>
64          `<li class="search-result" role="option" id="search-opt-${i}" data-id="${item.id}" data-action="mousedown->search#pick">${item.label}</li>`
65      )
66      .join("")
67    this.resultsTarget.hidden = false
68    this.inputTarget.setAttribute("aria-expanded", "true")
69    this.activeIndex = -1
70  }
71
72  pick(event) {
73    this.navigate(event.currentTarget.dataset.id)
74  }
75
76  navigate(id) {
77    if (!id) return
78    const path = `${this.basePathValue}/${id}`.replace(/\/{2,}/g, "/")
79    if (window.Turbo) window.Turbo.visit(path)
80    else window.location.href = path
81    this.close()
82  }
83
84  onKeydown(event) {
85    const options = this.options
86    if (this.resultsTarget.hidden || options.length === 0) return
87    switch (event.key) {
88      case "ArrowDown":
89        event.preventDefault()
90        this.setActive((this.activeIndex + 1) % options.length)
91        break
92      case "ArrowUp":
93        event.preventDefault()
94        this.setActive((this.activeIndex - 1 + options.length) % options.length)
95        break
96      case "Enter":
97        if (this.activeIndex >= 0) {
98          event.preventDefault()
99          this.navigate(options[this.activeIndex].dataset.id)
100        }
101        break
102      case "Escape":
103        this.close()
104        break
105    }
106  }
107
108  setActive(i) {
109    const options = this.options
110    options.forEach((o) => o.classList.remove("is-active"))
111    this.activeIndex = i
112    const opt = options[i]
113    opt.classList.add("is-active")
114    this.inputTarget.setAttribute("aria-activedescendant", opt.id)
115    opt.scrollIntoView({ block: "nearest" })
116  }
117
118  get options() {
119    return Array.from(this.resultsTarget.querySelectorAll(".search-result"))
120  }
121
122  close() {
123    this.resultsTarget.hidden = true
124    this.resultsTarget.innerHTML = ""
125    this.activeIndex = -1
126    this.inputTarget.setAttribute("aria-expanded", "false")
127    this.inputTarget.removeAttribute("aria-activedescendant")
128  }
129
130  onDocClick(event) {
131    if (!this.element.contains(event.target)) this.close()
132  }
133};

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.