1/** 2 * Grouped autosuggest search with server-side filtering. 3 * 4 * @package ontheme 5 * @author onblur.media <[email protected]> 6 */ 7document.addEventListener('DOMContentLoaded', () => { 8 const input = document.getElementById('search-content-input'); 9 const container = document.getElementById('modal-search-results'); 10 const modal = document.getElementById('modal-search'); 11 12 if (!input || !container || !modal) { 13 return; 14 } 15 16 const lang = input.getAttribute('data-lang') || 'de-DE'; 17 const DEBOUNCE_MS = 300; 18 const MIN_CHARS = 2; 19 20 let debounceTimer = null; 21 let abortController = null; 22 let activeIndex = -1; 23 let lastQuery = ''; 24 let trendingLoaded = false; 25 26 // Group labels from inline JS constants 27 const GROUP_LABELS = { 28 courses: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_COURSES !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_COURSES : 'Kurse', 29 categories: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_CATEGORIES !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_CATEGORIES : 'Kategorien', 30 vendors: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_VENDORS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_VENDORS : 'Anbieter', 31 articles: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_ARTICLES !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_ARTICLES : 'Magazin', 32 tutorials: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_TUTORIALS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_TUTORIALS : 'Tutorials', 33 news: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_NEWS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_NEWS : 'News', 34 media: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_MEDIA !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_MEDIA : 'Medien', 35 questions: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_QUESTIONS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_QUESTIONS : 'Fragen', 36 users: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_USERS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_USERS : 'Community', 37 events: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_EVENTS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_EVENTS : 'Events', 38 jobs: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_JOBS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_JOBS : 'Jobs', 39 }; 40 41 const TYPE_NAMES = { 42 0: 'course', 1: 'article', 2: 'video', 3: 'podcast', 43 4: 'news', 5: 'tutorial', 6: 'category', 7: 'question', 44 8: 'vendor', 9: 'user', 10: 'event', 11: 'job', 45 }; 46 47 /** 48 * Fetch search results from the server. 49 */ 50 async function fetchResults(query, typeFilter) { 51 if (abortController) { 52 abortController.abort(); 53 } 54 abortController = new AbortController(); 55 56 let url = '/index.php?option=com_ontemplate&task=search.suggest' 57 + '&q=' + encodeURIComponent(query) 58 + '&lang=' + encodeURIComponent(lang) 59 + '&format=json'; 60 61 if (typeFilter) { 62 url += '&type=' + encodeURIComponent(typeFilter); 63 } 64 65 input.classList.add('is-loading'); 66 67 try { 68 const response = await fetch(url, { signal: abortController.signal }); 69 70 if (response.status === 429) { 71 return null; 72 } 73 74 return await response.json(); 75 } catch (e) { 76 if (e.name !== 'AbortError') { 77 console.warn('Search fetch failed:', e); 78 } 79 return null; 80 } finally { 81 input.classList.remove('is-loading'); 82 } 83 } 84 85 /** 86 * Fetch trending terms (once per session). 87 */ 88 async function fetchTrending() { 89 const cached = sessionStorage.getItem('search_trending_' + lang); 90 91 if (cached) { 92 return JSON.parse(cached); 93 } 94 95 try { 96 const response = await fetch( 97 '/index.php?option=com_ontemplate&task=search.trending&lang=' 98 + encodeURIComponent(lang) + '&format=json' 99 ); 100 const terms = await response.json(); 101 102 if (Array.isArray(terms) && terms.length > 0) { 103 sessionStorage.setItem('search_trending_' + lang, JSON.stringify(terms)); 104 return terms; 105 } 106 } catch (e) { 107 // Silent fail 108 } 109 110 return []; 111 } 112 113 /** 114 * Highlight matching words in text. 115 */ 116 function highlight(text, query) { 117 if (!query) { 118 return text; 119 } 120 121 const words = query.trim().split(/\s+/).filter(w => w.length >= 2); 122 let result = text; 123 124 words.forEach(word => { 125 const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 126 const regex = new RegExp('(' + escaped + ')', 'gi'); 127 result = result.replace(regex, '<mark>$1</mark>'); 128 }); 129 130 return result; 131 } 132 133 /** 134 * Render grouped search results. 135 */ 136 function renderResults(data, query) { 137 container.innerHTML = ''; 138 activeIndex = -1; 139 140 if (!data || typeof data !== 'object') { 141 return; 142 } 143 144 const groups = Object.keys(data); 145 146 if (groups.length === 0) { 147 renderNoResults(); 148 return; 149 } 150 151 const wrapper = document.createElement('div'); 152 wrapper.className = 'search-results'; 153 wrapper.setAttribute('role', 'listbox'); 154 wrapper.setAttribute('aria-label', 'Search results'); 155 156 groups.forEach(groupKey => { 157 const items = data[groupKey]; 158 159 if (!Array.isArray(items) || items.length === 0) { 160 return; 161 } 162 163 // Section header 164 const header = document.createElement('div'); 165 header.className = 'search-results__group-header'; 166 header.textContent = GROUP_LABELS[groupKey] || groupKey; 167 wrapper.appendChild(header); 168 169 // Items
170 items.forEach(item => { 171 const el = document.createElement('a'); 172 el.className = 'search-results__item'; 173 el.href = item.u; 174 el.setAttribute('role', 'option'); 175 el.setAttribute('data-tt', item.tt); 176 el.setAttribute('data-id', item.id); 177 178 let html = ''; 179 180 // Thumbnail 181 html += '<div class="search-results__thumb">'; 182 if (item.i) { 183 html += '<img src="' + item.i + '" alt="" loading="lazy">'; 184 } 185 html += '</div>'; 186 187 // Content 188 html += '<div class="search-results__content">'; 189 html += '<div class="search-results__title">' + highlight(item.t, query) + '</div>'; 190 191 // Subline based on type 192 if (item.v) { 193 html += '<div class="search-results__sub">' + item.v + '</div>'; 194 } 195 if (item.d) { 196 const date = new Date(item.d); 197 html += '<div class="search-results__sub">' + date.toLocaleDateString('de-DE') + '</div>'; 198 } 199 if (item.c) { 200 html += '<div class="search-results__sub">' + item.c + '</div>'; 201 } 202 203 // Rating stars 204 if (item.r && item.r > 0.5) { 205 const width = Math.max(0, Math.min(5, item.r)) * 16; 206 html += '<span class="stars"><span style="width:' + width + 'px"></span></span>'; 207 } 208 209 html += '</div>'; 210 el.innerHTML = html; 211 212 el.addEventListener('click', (e) => { 213 trackClick(query, item); 214 }); 215 216 wrapper.appendChild(el); 217 }); 218 }); 219 220 container.appendChild(wrapper); 221 } 222 223 /** 224 * Render "no results" message. 225 */ 226 function renderNoResults() { 227 const msg = typeof TPL_ONTHEME_AUTOSUGGEST_NOTHING_FOUND !== 'undefined' 228 ? TPL_ONTHEME_AUTOSUGGEST_NOTHING_FOUND 229 : 'Keine Ergebnisse gefunden.'; 230 container.innerHTML = '<div class="search-results__empty">' + msg + '</div>'; 231 } 232 233 /** 234 * Render trending terms. 235 */ 236 function renderTrending(terms) { 237 if (!terms || terms.length === 0) { 238 return; 239 } 240 241 let html = '<div class="search-trending">'; 242 html += '<div class="search-trending__label">'; 243 html += (typeof TPL_ONTHEME_AUTOSUGGEST_TRENDING !== 'undefined' 244 ? TPL_ONTHEME_AUTOSUGGEST_TRENDING : 'Beliebte Suchen'); 245 html += '</div>'; 246 html += '<div class="search-trending__tags">'; 247 248 terms.forEach(term => { 249 html += '<button type="button" class="search-trending__tag">' 250 + term + '</button>'; 251 }); 252 253 html += '</div></div>'; 254 container.innerHTML = html; 255 256 container.querySelectorAll('.search-trending__tag').forEach(btn => { 257 btn.addEventListener('click', () => { 258 input.value = btn.textContent; 259 triggerSearch(btn.textContent); 260 }); 261 }); 262 } 263 264 /** 265 * Matomo tracking on result click. 266 */ 267 function trackClick(query, item) { 268 if (typeof _paq === 'undefined') { 269 return; 270 } 271 272 _paq.push(['trackSiteSearch', query.toLowerCase(), false, false]); 273 const typeName = TYPE_NAMES[item.tt] || 'unknown'; 274 _paq.push(['trackEvent', 'com_ontemplate', 'click-autosuggest', typeName + '-' + item.id]); 275 } 276 277 /** 278 * Trigger a search request. 279 */ 280 async function triggerSearch(query, typeFilter) { 281 lastQuery = query; 282 283 if (query.length < MIN_CHARS) { 284 if (!trendingLoaded) { 285 const terms = await fetchTrending(); 286 renderTrending(terms); 287 trendingLoaded = true; 288 } else if (query.length === 0) { 289 const cached = sessionStorage.getItem('search_trending_' + lang); 290 if (cached) { 291 renderTrending(JSON.parse(cached)); 292 } 293 } 294 return; 295 } 296 297 const data = await fetchResults(query, typeFilter); 298 299 if (data !== null && query === lastQuery) { 300 renderResults(data, query); 301 } 302 } 303 304 /** 305 * Get the currently active filter type from chips. 306 */ 307 function getActiveFilter() { 308 const filtersEl = document.getElementById('search-filters'); 309 310 if (!filtersEl) { 311 return ''; 312 } 313 314 const active = filtersEl.querySelector('.search-filters__btn.active[data-type]'); 315 return active ? active.getAttribute('data-type') : ''; 316 } 317 318 // Debounced input handler 319 input.addEventListener('input', () => { 320 clearTimeout(debounceTimer); 321 debounceTimer = setTimeout(() => { 322 triggerSearch(input.value.trim(), getActiveFilter() || undefined); 323 }, DEBOUNCE_MS); 324 }); 325 326 // Filter chip click handlers 327 const filtersEl = document.getElementById('search-filters'); 328 329 if (filtersEl) { 330 filtersEl.addEventListener('click', (e) => { 331 const btn = e.target.closest('.search-filters__btn'); 332 333 if (!btn) { 334 return; 335 } 336 337 filtersEl.querySelectorAll('.search-filters__btn').forEach(b => { 338 b.classList.remove('active'); 339 b.setAttribute('aria-selected', 'false'); 340 }); 341 btn.classList.add('active'); 342 btn.setAttribute('aria-selected', 'true'); 343 344 const query = input.value.trim(); 345 const typeFilter = btn.getAttribute('data-type'); 346 347 if (query.length >= MIN_CHARS) { 348 triggerSearch(query, typeFilter || undefined); 349 } 350 351 input.focus(); 352 }); 353 } 354 355 // Keyboard navigation 356 input.addEventListener('keydown', (e) => { 357 const items = container.querySelectorAll('.search-results__item'); 358 359 if (!items.length) { 360 return; 361 } 362 363 if (e.key === 'ArrowDown') { 364 e.preventDefault(); 365 activeIndex = Math.min(activeIndex + 1, items.length - 1); 366 updateActiveItem(items); 367 } else if (e.key === 'ArrowUp') { 368 e.preventDefault(); 369 activeIndex = Math.max(activeIndex - 1, -1); 370 updateActiveItem(items); 371 } else if (e.key === 'Enter' && activeIndex >= 0) { 372 e.preventDefault(); 373 items[activeIndex].click(); 374 } 375 }); 376 377 /** 378 * Update visual state of active keyboard-navigated item. 379 */ 380 function updateActiveItem(items) {
381 items.forEach((item, i) => { 382 item.classList.toggle('is-active', i === activeIndex); 383 384 if (i === activeIndex) { 385 item.setAttribute('aria-selected', 'true'); 386 item.scrollIntoView({ block: 'nearest' }); 387 input.setAttribute('aria-activedescendant', 'search-result-' + i); 388 item.id = 'search-result-' + i; 389 } else { 390 item.removeAttribute('aria-selected'); 391 } 392 }); 393 394 if (activeIndex < 0) { 395 input.removeAttribute('aria-activedescendant'); 396 } 397 } 398 399 // Show trending on modal open 400 modal.addEventListener('shown.bs.modal', async () => { 401 input.focus(); 402 403 if (input.value.trim().length < MIN_CHARS) { 404 const terms = await fetchTrending(); 405 renderTrending(terms); 406 trendingLoaded = true; 407 } 408 }); 409 410 // Prevent blur from closing results 411 input.addEventListener('blur', (e) => { 412 e.stopImmediatePropagation(); 413 }, true); 414 415 // ARIA attributes 416 input.setAttribute('role', 'combobox'); 417 input.setAttribute('aria-autocomplete', 'list'); 418 input.setAttribute('aria-expanded', 'false'); 419 input.setAttribute('aria-controls', 'modal-search-results'); 420 container.setAttribute('aria-live', 'polite'); 421});
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.