1import { Controller } from "@hotwired/stimulus" 2 3// Single-select autocomplete for unit lookup. 4// Sets a hidden unit_id field. 5export default class extends Controller { 6 static targets = ["input", "results", "unitId"] 7 static values = { url: String } 8 9 connect() { 10 this.timeout = null 11 this.activeIndex = -1 12 this.handleClickOutside = this.handleClickOutside.bind(this) 13 document.addEventListener('click', this.handleClickOutside) 14 } 15 16 disconnect() { 17 document.removeEventListener('click', this.handleClickOutside) 18 } 19 20 handleClickOutside(event) { 21 if (!this.element.contains(event.target)) { 22 this.hideResults() 23 } 24 } 25 26 search() { 27 clearTimeout(this.timeout) 28 const query = this.inputTarget.value.trim() 29 30 this.unitIdTarget.value = '' 31 32 if (query.length < 2) { 33 this.hideResults() 34 return 35 } 36 37 this.timeout = setTimeout(() => this.performSearch(query), 300) 38 } 39 40 onKeydown(event) { 41 const items = this.resultItems 42 43 switch (event.key) { 44 case 'ArrowDown': 45 event.preventDefault() 46 if (this.resultsTarget.classList.contains('hidden')) return 47 this.activeIndex = Math.min(this.activeIndex + 1, items.length - 1) 48 this.highlightItem() 49 break 50 case 'ArrowUp': 51 event.preventDefault() 52 if (this.resultsTarget.classList.contains('hidden')) return 53 this.activeIndex = Math.max(this.activeIndex - 1, 0) 54 this.highlightItem() 55 break 56 case 'Enter': 57 event.preventDefault() 58 if (this.activeIndex >= 0 && items[this.activeIndex]) { 59 items[this.activeIndex].click() 60 } 61 break 62 case 'Escape': 63 this.hideResults() 64 this.inputTarget.blur() 65 break 66 } 67 } 68 69 get resultItems() { 70 return Array.from(this.resultsTarget.querySelectorAll('[data-id]')) 71 } 72 73 highlightItem() { 74 const items = this.resultItems 75 items.forEach((item, index) => { 76 if (index === this.activeIndex) { 77 item.classList.add('bg-gray-100', 'dark:bg-gray-700') 78 } else { 79 item.classList.remove('bg-gray-100', 'dark:bg-gray-700') 80 } 81 }) 82 if (items[this.activeIndex]) { 83 items[this.activeIndex].scrollIntoView({ block: 'nearest' }) 84 } 85 } 86 87 async performSearch(query) { 88 try { 89 const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`, { 90 headers: { 91 'Accept': 'application/json', 92 'X-Requested-With': 'XMLHttpRequest' 93 } 94 }) 95 if (!response.ok) throw new Error('Search failed') 96 const data = await response.json() 97 this.displayResults(data) 98 } catch (error) { 99 console.error('Unit select error:', error) 100 } 101 } 102 103 displayResults(items) { 104 this.activeIndex = -1 105 106 if (items.length === 0) { 107 this.hideResults() 108 return 109 } 110 111 this.resultsTarget.innerHTML = items.map(item => ` 112 <div class="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" 113 data-action="click->unit-select#selectItem" 114 data-id="${item.id}" 115 data-name="${this.escapeHtml(item.name)}"> 116 <div class="font-medium text-gray-900 dark:text-gray-100">${this.escapeHtml(item.name)}</div> 117 ${item.key ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${this.escapeHtml(item.key)}</div>` : ''} 118 </div> 119 `).join('') 120 121 this.showResults() 122 } 123 124 selectItem(event) { 125 const id = event.currentTarget.dataset.id 126 const name = event.currentTarget.dataset.name 127 128 this.unitIdTarget.value = id 129 this.inputTarget.value = name 130 131 this.hideResults() 132 } 133 134 clear() { 135 this.unitIdTarget.value = '' 136 this.inputTarget.value = '' 137 } 138 139 showResults() { 140 this.resultsTarget.classList.remove('hidden') 141 } 142 143 hideResults() { 144 this.activeIndex = -1 145 this.resultsTarget.classList.add('hidden') 146 } 147 148 escapeHtml(text) { 149 const div = document.createElement('div') 150 div.textContent = text 151 return div.innerHTML 152 } 153}
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.