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.