1/** 2 * TwAutocomplete â lightweight autocomplete without Bootstrap dependency. 3 * API-compatible with the subset of bootstrap5-autocomplete used in this project. 4 */ 5class TwAutocomplete { 6 /** 7 * @param {HTMLInputElement} input 8 * @param {Object} options 9 */ 10 constructor(input, options) { 11 this._input = input; 12 this._config = Object.assign({ 13 queryParam: 'query', 14 debounceTime: 300, 15 suggestionsThreshold: 1, 16 highlightTyped: true, 17 notFoundMessage: 'ÐиÑего не найдено', 18 labelField: 'label', 19 valueField: 'value', 20 server: '', 21 onBeforeFetch: null, 22 onServerResponse: null, 23 onSelectItem: null, 24 }, options); 25 26 this._items = []; 27 this._activeIndex = -1; 28 this._debounceTimer = null; 29 this._abortCtrl = null; 30 this._dropdown = null; 31 32 this._build(); 33 this._bind(); 34 } 35 36 /* ------------------------------------------------------------------ */ 37 /* Build DOM */ 38 /* ------------------------------------------------------------------ */ 39 40 _build() { 41 // Wrapper so dropdown can be positioned relative to input 42 const wrapper = document.createElement('div'); 43 wrapper.style.cssText = 'position:relative;flex:1;min-width:0;'; 44 45 this._input.parentNode.insertBefore(wrapper, this._input); 46 wrapper.appendChild(this._input); 47 48 this._dropdown = document.createElement('div'); 49 this._dropdown.setAttribute('role', 'listbox'); 50 this._dropdown.style.cssText = [ 51 'display:none', 52 'position:absolute', 53 'top:calc(100% + 4px)', 54 'left:0', 55 'right:0', 56 'z-index:9999', 57 'background:#fff', 58 'border:1px solid #e4e7ec', 59 'border-radius:0.5rem', 60 'box-shadow:0 4px 16px -2px rgba(16,24,40,.14),0 2px 6px -2px rgba(16,24,40,.08)', 61 'overflow-y:auto', 62 'max-height:280px', 63 'padding:4px 0', 64 ].join(';'); 65 66 wrapper.appendChild(this._dropdown); 67 68 // Spinner element (hidden by default) 69 this._spinner = document.createElement('div'); 70 this._spinner.style.cssText = [ 71 'display:none', 72 'position:absolute', 73 // Ðевее наÑивной кнопки оÑиÑÑки Ð¿Ð¾Ð»Ñ type="search", инаÑе ÑÐ¿Ð¸Ð½Ð½ÐµÑ Ð¸ кÑеÑÑик налезаÑÑ 74 'right:28px', 75 'top:50%', 76 'transform:translateY(-50%)', 77 'width:16px', 78 'height:16px', 79 'border:2px solid #d0d5dd', 80 'border-top-color:#465fff', 81 'border-radius:50%', 82 'animation:tw-ac-spin .6s linear infinite', 83 'pointer-events:none', 84 ].join(';'); 85 wrapper.appendChild(this._spinner); 86 87 // Inject keyframes once 88 if (!document.getElementById('tw-ac-spin-style')) { 89 const style = document.createElement('style'); 90 style.id = 'tw-ac-spin-style'; 91 style.textContent = '@keyframes tw-ac-spin{to{transform:translateY(-50%) rotate(360deg)}}'; 92 document.head.appendChild(style); 93 } 94 95 // Respect dark mode by watching body class 96 this._syncTheme(); 97 const mo = new MutationObserver(() => this._syncTheme()); 98 mo.observe(document.body, { attributes: true, attributeFilter: ['class'] }); 99 } 100 101 _syncTheme() { 102 const dark = document.body.classList.contains('dark'); 103 if (dark) { 104 this._dropdown.style.background = '#1d2939'; 105 this._dropdown.style.borderColor = '#344054'; 106 this._dropdown.style.color = '#d0d5dd'; 107 } else { 108 this._dropdown.style.background = '#fff'; 109 this._dropdown.style.borderColor = '#e4e7ec'; 110 this._dropdown.style.color = '#344054'; 111 } 112 } 113 114 /* ------------------------------------------------------------------ */ 115 /* Events */ 116 /* ------------------------------------------------------------------ */ 117 118 _bind() { 119 this._input.setAttribute('autocomplete', 'off'); 120 121 this._input.addEventListener('input', () => this._onInput()); 122 this._input.addEventListener('focus', () => this._onInput()); 123 this._input.addEventListener('keydown', (e) => this._onKeydown(e)); 124 125 document.addEventListener('click', (e) => { 126 if (!this._dropdown.parentNode.contains(e.target)) { 127 this._hide(); 128 } 129 }); 130 } 131 132 _onInput() { 133 clearTimeout(this._debounceTimer); 134 135 // ÐаблокиÑованное поле подÑказок не показÑваеÑ: знаÑение пÑоÑÑавлÑеÑÑÑ Ð°Ð²ÑомаÑиÑеÑки. 136 if (this._input.readOnly || this._input.disabled) { 137 this._hide(); 138 return; 139 } 140 141 const val = this._input.value; 142 143 if (val.length < this._config.suggestionsThreshold) { 144 // suggestionsThreshold === 0 means: fetch even when empty 145 if (this._config.suggestionsThreshold === 0) { 146 this._debounceTimer = setTimeout(() => this._fetchData(val), this._config.debounceTime); 147 } else { 148 this._hide(); 149 } 150 return; 151 } 152 153 this._debounceTimer = setTimeout(() => this._fetchData(val), this._config.debounceTime); 154 } 155 156 _onKeydown(e) { 157 if (!this._visible()) return; 158 159 const rows = this._dropdown.querySelectorAll('[role="option"]'); 160 switch (e.key) { 161 case 'ArrowDown': 162 e.preventDefault(); 163 this._setActive(Math.min(this._activeIndex + 1, rows.length - 1)); 164 break; 165 case 'ArrowUp': 166 e.preventDefault(); 167 this._setActive(Math.max(this._activeIndex - 1, 0)); 168 break; 169 case 'Enter': 170 e.preventDefault(); 171 if (this._activeIndex >= 0) this._select(this._activeIndex); 172 break; 173 case 'Escape': 174 this._hide(); 175 break; 176 } 177 } 178 179 /* ------------------------------------------------------------------ */ 180 /* Fetch */ 181 /* ------------------------------------------------------------------ */ 182 183 async _fetchData(query) { 184 // Allow caller to switch server URL before fetch 185 if (this._config.onBeforeFetch) { 186 this._config.onBeforeFetch(this); 187 } 188 189 if (this._abortCtrl) this._abortCtrl.abort(); 190 this._abortCtrl = new AbortController(); 191 192 const params = new URLSearchParams(); 193 params.set(this._config.queryParam, query); 194 195 const sep = this._config.server.includes('?') ? '&' : '?'; 196 const url = this._config.server + sep + params.toString(); 197 198 this._spinner.style.display = 'block'; 199 200 try { 201 const response = await fetch(url, { signal: this._abortCtrl.signal }); 202 203 let items; 204 if (this._config.onServerResponse) { 205 items = await this._config.onServerResponse(response, this); 206 } else { 207 items = await response.json(); 208 } 209 210 this._items = Array.isArray(items) ? items : []; 211 this._activeIndex = -1; 212 this._render(query); 213 if (!response.ok) { 214 this._showError('ÐÑибка ÑеÑвеÑа (' + response.status + ')'); 215 return; 216 } 217 218 } catch (err) { 219 if (err.name !== 'AbortError') { 220 console.error('[TwAutocomplete]', err); 221 this._showError('Ðе ÑдалоÑÑ Ð·Ð°Ð³ÑÑзиÑÑ Ð´Ð°Ð½Ð½Ñе'); 222 } 223 } finally { 224 this._spinner.style.display = 'none'; 225 } 226 } 227 228 /* ------------------------------------------------------------------ */ 229 /* Render */ 230 /* ------------------------------------------------------------------ */ 231 232 _render(query) { 233 this._dropdown.innerHTML = ''; 234 235 if (this._items.length === 0) { 236 const msg = document.createElement('div'); 237 msg.style.cssText = 'padding:10px 14px;font-size:13px;color:#98a2b3;';
238 msg.textContent = this._config.notFoundMessage; 239 this._dropdown.appendChild(msg); 240 this._show(); 241 return; 242 } 243 244 const dark = document.body.classList.contains('dark'); 245 246 this._items.forEach((item, index) => { 247 const el = document.createElement('div'); 248 el.setAttribute('role', 'option'); 249 el.style.cssText = [ 250 'padding:8px 14px', 251 'font-size:14px', 252 'line-height:1.45', 253 'cursor:pointer', 254 'white-space:normal', 255 'word-break:break-word', 256 dark ? 'color:#d0d5dd' : 'color:#344054', 257 ].join(';'); 258 259 // Get display label 260 const rawLabel = item.label 261 ?? item[this._config.labelField] 262 ?? item[this._config.valueField] 263 ?? ''; 264 265 const hasHtml = rawLabel.includes('<'); 266 267 if (hasHtml) { 268 el.innerHTML = rawLabel; 269 } else if (this._config.highlightTyped && query) { 270 el.innerHTML = this._highlight(rawLabel, query); 271 } else { 272 el.textContent = rawLabel; 273 } 274 275 el.addEventListener('mouseenter', () => this._setActive(index)); 276 el.addEventListener('mouseleave', () => this._clearActive()); 277 el.addEventListener('mousedown', (e) => { e.preventDefault(); this._select(index); }); 278 279 this._dropdown.appendChild(el); 280 }); 281 282 this._show(); 283 } 284 285 _highlight(text, query) { 286 const safe = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 287 return text.replace( 288 new RegExp(`(${safe})`, 'gi'), 289 '<mark style="background:none;color:#465fff;font-weight:600;padding:0">$1</mark>' 290 ); 291 } 292 293 /* ------------------------------------------------------------------ */ 294 /* Active item */ 295 /* ------------------------------------------------------------------ */ 296 297 _setActive(index) { 298 const rows = this._dropdown.querySelectorAll('[role="option"]'); 299 const dark = document.body.classList.contains('dark'); 300 301 // Clear previous 302 if (this._activeIndex >= 0 && rows[this._activeIndex]) { 303 rows[this._activeIndex].style.background = ''; 304 rows[this._activeIndex].style.color = dark ? '#d0d5dd' : '#344054'; 305 } 306 307 this._activeIndex = index; 308 309 if (index >= 0 && rows[index]) { 310 rows[index].style.background = dark ? '#344054' : '#f2f4f7'; 311 rows[index].style.color = dark ? '#ffffff' : '#101828'; 312 rows[index].scrollIntoView({ block: 'nearest' }); 313 } 314 } 315 316 _clearActive() { 317 this._setActive(-1); 318 } 319 320 /* ------------------------------------------------------------------ */ 321 /* Select */ 322 /* ------------------------------------------------------------------ */ 323 324 _select(index) { 325 const item = this._items[index]; 326 if (!item) return; 327 328 const value = item[this._config.valueField] ?? item.value ?? ''; 329 this._input.value = value; 330 this._hide(); 331 332 if (this._config.onSelectItem) { 333 this._config.onSelectItem(item, this._input); 334 } 335 } 336 337 /* ------------------------------------------------------------------ */ 338 /* Visibility */ 339 /* ------------------------------------------------------------------ */ 340 341 _show() { this._dropdown.style.display = 'block'; } 342 _hide() { this._dropdown.style.display = 'none'; this._activeIndex = -1; } 343 _visible() { return this._dropdown.style.display !== 'none'; } 344 345 _showError(text) { 346 this._dropdown.innerHTML = ''; 347 const msg = document.createElement('div'); 348 msg.style.cssText = 'padding:10px 14px;font-size:13px;color:#f04438;'; 349 msg.textContent = text; 350 this._dropdown.appendChild(msg); 351 this._show(); 352 } 353}
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.