1/** 2 * Global JavaScript for Cividata 3 * This file contains general UI logic like the global search 4 */ 5 6// Escape HTML to prevent XSS 7function escapeHtml(str) { 8 if (!str) return ''; 9 return String(str) 10 .replace(/&/g, '&') 11 .replace(/</g, '<') 12 .replace(/>/g, '>') 13 .replace(/"/g, '"') 14 .replace(/'/g, '''); 15} 16 17// Helper to initialize Bootstrap Tooltips 18function initializeTooltips() { 19 var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) 20 tooltipTriggerList.forEach(function (tooltipTriggerEl) { 21 // Only initialize if not already initialized 22 if (!bootstrap.Tooltip.getInstance(tooltipTriggerEl)) { 23 new bootstrap.Tooltip(tooltipTriggerEl); 24 } 25 }); 26} 27 28document.addEventListener('DOMContentLoaded', initializeTooltips); 29 30// --------------------------------------- 31// Feature 5: Mobile Tabellen â Jahres-Spalte togglen (LocalStorage) 32// --------------------------------------- 33(function () { 34 const STORAGE_KEY = 'cividata_show_year'; 35 36 function isEnabled() { 37 try { return window.localStorage.getItem(STORAGE_KEY) === '1'; } 38 catch (e) { return false; } 39 } 40 41 function setEnabled(enabled) { 42 document.body.classList.toggle('show-year', !!enabled); 43 try { window.localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0'); } 44 catch (e) { /* ignore */ } 45 document.querySelectorAll('.js-year-toggle').forEach((btn) => { 46 btn.setAttribute('aria-pressed', enabled ? 'true' : 'false'); 47 btn.textContent = enabled ? btn.getAttribute('data-label-on') : btn.getAttribute('data-label-off'); 48 }); 49 } 50 51 function getLabels() { 52 const body = document.body; 53 return { 54 on: body.getAttribute('data-year-hide') || 'Hide year', 55 off: body.getAttribute('data-year-show') || 'Show year' 56 }; 57 } 58 59 document.addEventListener('DOMContentLoaded', function () { 60 const tables = Array.from(document.querySelectorAll('.table-responsive')) 61 .filter((wrap) => wrap.querySelector('th.year-column, td.year-column')); 62 63 if (tables.length === 0) return; 64 65 const labels = getLabels(); 66 tables.forEach((wrap) => { 67 // Only add once 68 if (wrap.previousElementSibling && wrap.previousElementSibling.classList.contains('table-mobile-tools')) return; 69 70 const tools = document.createElement('div'); 71 tools.className = 'table-mobile-tools d-flex d-md-none'; 72 73 const btn = document.createElement('button'); 74 btn.type = 'button'; 75 btn.className = 'btn btn-outline-secondary btn-sm js-year-toggle'; 76 btn.setAttribute('aria-pressed', 'false'); 77 btn.setAttribute('data-label-on', labels.on); 78 btn.setAttribute('data-label-off', labels.off); 79 btn.textContent = labels.off; 80 btn.addEventListener('click', () => setEnabled(!document.body.classList.contains('show-year'))); 81 82 tools.appendChild(btn); 83 wrap.parentElement.insertBefore(tools, wrap); 84 }); 85 86 // Apply saved state 87 setEnabled(isEnabled()); 88 }); 89})(); 90 91// --------------------------------------- 92// Feature 6: Global Search â Autocomplete mit Keyboard + ARIA 93// --------------------------------------- 94(function () { 95 const searchInput = document.getElementById('globalSearchInput'); 96 const resultsContainer = document.getElementById('globalSearchResults'); 97 const searchForm = document.getElementById('globalSearchForm'); 98 99 if (!searchInput || !resultsContainer) return; 100 101 let globalSearchTimeout = null; 102 let items = []; 103 let activeIndex = -1; 104 105 function setExpanded(expanded) { 106 searchInput.setAttribute('aria-expanded', expanded ? 'true' : 'false'); 107 } 108 109 function closeList() { 110 items = []; 111 activeIndex = -1; 112 searchInput.removeAttribute('aria-activedescendant'); 113 resultsContainer.innerHTML = ''; 114 resultsContainer.classList.remove('show'); 115 setExpanded(false); 116 } 117 118 function openList() { 119 resultsContainer.classList.add('show'); 120 setExpanded(true); 121 } 122 123 function setActive(idx) { 124 activeIndex = idx; 125 const options = Array.from(resultsContainer.querySelectorAll('[role="option"]')); 126 options.forEach((el, i) => { 127 const isActive = i === activeIndex; 128 el.classList.toggle('active', isActive); 129 el.setAttribute('aria-selected', isActive ? 'true' : 'false'); 130 if (isActive) searchInput.setAttribute('aria-activedescendant', el.
130id); 131 }); 132 } 133 134 function selectItem(item) { 135 if (!item) return; 136 if (window.CividataModal && typeof window.CividataModal.open === 'function') { 137 window.CividataModal.open(item.qid, item.label); 138 } 139 searchInput.value = ''; 140 closeList(); 141 } 142 143 function renderResults(nextItems) { 144 items = Array.isArray(nextItems) ? nextItems : []; 145 activeIndex = -1; 146 resultsContainer.innerHTML = ''; 147 148 if (items.length === 0) { 149 closeList(); 150 return; 151 } 152 153 items.forEach((item, idx) => { 154 const btn = document.createElement('button'); 155 btn.type = 'button'; 156 btn.className = 'list-group-item list-group-item-action border-0 py-2'; 157 btn.id = `globalSearchOption-${idx}`; 158 btn.setAttribute('role', 'option'); 159 btn.setAttribute('aria-selected', 'false'); 160 161 const flag = item.country_flag ? ` ${escapeHtml(item.country_flag)}` : ''; 162 btn.innerHTML = `<div class="global-search-item-title"><strong>${escapeHtml(item.label)}${flag}</strong></div>`; 163 164 btn.addEventListener('click', () => selectItem(item)); 165 resultsContainer.appendChild(btn); 166 }); 167 168 openList(); 169 } 170 171 searchInput.addEventListener('input', function () { 172 const q = this.value.trim(); 173 if (globalSearchTimeout) clearTimeout(globalSearchTimeout); 174 175 if (q.length < 2) { 176 closeList(); 177 return; 178 } 179 180 globalSearchTimeout = setTimeout(async function () { 181 const lang = document.documentElement.lang || 'de'; 182 try { 183 const response = await fetch('/inc/global_search.php?q=' + encodeURIComponent(q) + '&lang=' + lang); 184 const data = await response.json(); 185 renderResults(data.items || []); 186 } catch (e) { 187 closeList(); 188 } 189 }, 250); 190 }); 191 192 searchInput.addEventListener('keydown', function (e) { 193 const isOpen = resultsContainer.classList.contains('show'); 194 const optionEls = Array.from(resultsContainer.querySelectorAll('[role="option"]')); 195 196 if (e.key === 'ArrowDown') { 197 if (!isOpen && optionEls.length > 0) openList(); 198 if (optionEls.length > 0) { 199 e.preventDefault(); 200 const next = Math.min(activeIndex + 1, optionEls.length - 1);
201 setActive(next); 202 } 203 } else if (e.key === 'ArrowUp') { 204 if (optionEls.length > 0) { 205 e.preventDefault(); 206 const next = Math.max(activeIndex - 1, 0); 207 setActive(next); 208 } 209 } else if (e.key === 'Enter') { 210 if (isOpen && activeIndex >= 0 && items[activeIndex]) { 211 e.preventDefault(); 212 selectItem(items[activeIndex]); 213 } 214 } else if (e.key === 'Escape') { 215 if (isOpen) { 216 e.preventDefault(); 217 closeList(); 218 } 219 } 220 }); 221 222 document.addEventListener('click', function (e) { 223 if (e.target === searchInput) return; 224 if (resultsContainer.contains(e.target)) return; 225 closeList(); 226 }); 227 228 if (searchForm) searchForm.addEventListener('submit', (e) => e.preventDefault()); 229})(); 230 231// --------------------------------------- 232// Feature: List Table with Tabs and Sortable Columns 233// --------------------------------------- 234(function () { 235 // Check if we're on a list page with table configuration 236 if (typeof listTableConfig === 'undefined') return; 237 238 const config = listTableConfig;
239 let currentProperty = config.currentProperty || (config.properties ? config.properties[0] : null); 240 let currentSort = { field: 'value', direction: 'desc' }; 241 let currentPage = 1; 242 let searchTerm = ''; 243 let totalCount = Number(config.initialTotalCount || 0); 244 let isLoading = false; 245 246 // DOM elements 247 const tableBody = document.getElementById('tableBody'); 248 const orgCountSpan = document.getElementById('orgCount'); 249 const valueHeader = document.getElementById('valueHeader'); 250 const loadMoreBtn = document.getElementById('loadMoreBtn'); 251 const searchInput = document.getElementById('list_search'); 252 const tabs = document.querySelectorAll('#propertyTabs .nav-link'); 253 const sortableHeaders = document.querySelectorAll('#listTable th.sortable'); 254 const propertyDropdown = document.getElementById('propertyDropdown'); 255 256 if (!tableBody || !currentProperty || !config.ajaxUrl) return; 257 258 function reinitTooltips() { 259 if (typeof bootstrap === 'undefined' || !bootstrap.Tooltip) return; 260 const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); 261 tooltips.forEach((el) => { 262 if (!bootstrap.Tooltip.getInstance(el)) { 263 new bootstrap.Tooltip(el); 264 } 265 }); 266 } 267 268 function updateHeader() { 269 const label = config.tableLabels[currentProperty] || currentProperty; 270 if (valueHeader) valueHeader.textContent = label; 271 if (orgCountSpan) orgCountSpan.textContent = `(${totalCount})`; 272 } 273 274 function updateLoadMoreButton(lastHtmlChunk) { 275 if (!loadMoreBtn) return; 276 const hasMore = (currentPage * config.itemsPerPage) < totalCount && (lastHtmlChunk || '').trim() !== ''; 277 loadMoreBtn.classList.toggle('is-hidden', !hasMore); 278 } 279 280 function updateSortIndicators() { 281 sortableHeaders.forEach((th) => { 282 const field = th.getAttribute('data-sort'); 283 const icon = th.querySelector('.sort-icon'); 284 285 if (field === currentSort.field) { 286 th.classList.add('active'); 287 th.setAttribute('data-direction', currentSort.direction); 288 if (icon) { 289 icon.className = currentSort.direction === 'asc' 290 ? 'fas fa-sort-up sort-icon' 291 : 'fas fa-sort-down sort-icon'; 292 } 293 } else { 294 th.classList.remove('active'); 295 th.removeAttribute('data-direction'); 296 if (icon) { 297 icon.className = 'fas fa-sort sort-icon'; 298 } 299 } 300 }); 301 } 302 303 async function loadTablePage(append) { 304 if (isLoading) return; 305 isLoading = true; 306 if (loadMoreBtn) loadMoreBtn.disabled = true; 307 308 const params = new URLSearchParams(); 309 params.set('ajax', 'true'); 310 params.set('table', currentProperty); 311 params.set('search', searchTerm); 312 params.set(`${currentProperty}_page`, String(currentPage)); 313 params.set('sort_field', currentSort.field); 314 params.set('sort_direction', currentSort.direction); 315 params.set('csrf_token', config.csrfToken || ''); 316 317 try { 318 const response = await fetch(config.ajaxUrl, { 319 method: 'POST', 320 headers: { 321 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' 322 }, 323 body: params.toString() 324 }); 325 326 if (!response.ok) { 327 throw new Error(`HTTP ${response.status}`); 328 } 329 330 const payload = await response.json(); 331 const htmlKey = `${currentProperty}_html`; 332 const html = payload[htmlKey] || ''; 333 totalCount = Number(payload.total_count || 0); 334 335 if (append) { 336 tableBody.insertAdjacentHTML('beforeend', html); 337 } else { 338 tableBody.innerHTML = html || `<tr><td colspan="5" class="text-center text-muted py-4">${escapeHtml(config.uiLabels.no_data || 'No data available')}</td></tr>`; 339 } 340 341 updateHeader(); 342 updateLoadMoreButton(html); 343 reinitTooltips(); 344 } catch (error) { 345 if (!append) { 346 tableBody.innerHTML = `<tr><td colspan="5" class="text-center text-muted py-4">${escapeHtml(config.uiLabels.no_data || 'No data available')}</td></tr>`; 347 } 348 updateLoadMoreButton(''); 349 } finally { 350 isLoading = false;
351 if (loadMoreBtn) loadMoreBtn.disabled = false; 352 } 353 } 354 355 // Event delegation: single listener handles all row/button clicks 356 tableBody.addEventListener('click', function(e) { 357 // Ignore clicks on external links 358 if (e.target.tagName === 'A' || e.target.closest('a')) return; 359 360 const btn = e.target.closest('.org-open-modal'); 361 if (!btn) return; 362 363 const qid = btn.getAttribute('data-qid'); 364 const label = (btn.getAttribute('data-label') || btn.textContent || '').trim(); 365 if (qid && window.CividataModal && typeof window.CividataModal.open === 'function') { 366 window.CividataModal.open(qid, label); 367 } 368 }); 369 370 // Switch to a property tab 371 function switchTab(property) { 372 if (!config.properties || !config.properties.includes(property)) return; 373 currentProperty = property; 374 currentPage = 1; 375 376 // Update active tab (desktop) 377 tabs.forEach((tab) => { 378 const tabProp = tab.getAttribute('data-property'); 379 if (tabProp === property) { 380 tab.classList.add('active'); 381 tab.setAttribute('aria-selected', 'true'); 382 } else { 383 tab.classList.remove('active'); 384 tab.setAttribute('aria-selected', 'false'); 385 } 386 }); 387 388 // Update dropdown (mobile) 389 if (propertyDropdown) propertyDropdown.value = property; 390 updateHeader(); 391 loadTablePage(false); 392 } 393 394 // Event: Tab click (desktop)
395 tabs.forEach((tab) => { 396 tab.addEventListener('click', function () { 397 const property = this.getAttribute('data-property'); 398 switchTab(property); 399 }); 400 }); 401 402 // Event: Dropdown change (mobile) 403 if (propertyDropdown) { 404 propertyDropdown.addEventListener('change', function () { 405 switchTab(this.value); 406 }); 407 } 408 409 // Event: Sortable header click 410 sortableHeaders.forEach((th) => { 411 th.addEventListener('click', function () { 412 const field = this.getAttribute('data-sort'); 413 414 if (currentSort.field === field) { 415 currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; 416 } else { 417 currentSort.field = field; 418 currentSort.direction = field === 'name' ? 'asc' : 'desc'; 419 } 420 421 currentPage = 1; 422 updateSortIndicators(); 423 loadTablePage(false); 424 }); 425 426 // Keyboard accessibility 427 th.addEventListener('keydown', function (e) { 428 if (e.key === 'Enter' || e.key === ' ') { 429 e.preventDefault(); 430 this.click(); 431 } 432 }); 433 }); 434 435 // Event: Search input 436 if (searchInput) { 437 let searchTimeout = null; 438 searchInput.addEventListener('input', function () { 439 if (searchTimeout) clearTimeout(searchTimeout); 440 searchTimeout = setTimeout(() => { 441 searchTerm = this.value.trim(); 442 currentPage = 1; 443 loadTablePage(false); 444 }, 200); 445 }); 446 } 447 448 // Event: Load more button 449 if (loadMoreBtn) { 450 loadMoreBtn.addEventListener('click', function () { 451 currentPage += 1; 452 loadTablePage(true); 453 }); 454 } 455 456 // Initial setup (table body is already SSR-rendered) 457 updateSortIndicators(); 458 updateHeader(); 459 updateLoadMoreButton('initial'); 460 reinitTooltips(); 461})();
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.