1/** 2 * NUI â Blog Articles Grid + Category Archive (PJAX Navigation) 3 * 4 * Hybrid approach: 5 * - Server-side rendered HTML with real <a> links (SEO crawlable) 6 * - AJAX (PJAX) on top for smooth navigation without full page reloads 7 * - pushState updates the URL in the browser bar 8 * - Back/forward buttons work via popstate 9 * - If JS fails, links still work 10 * 11 * Supports an arbitrary number of filter dimensions (Categories, Topics, 12 * Products, â¦) â each rendered as its own [data-filter-wrapper] with a 13 * [data-url-param] telling this script which query var it controls. 14 * 15 * This JS handles: 16 * - Desktop filter dropdown toggle (any number of dropdowns, one open at a time) 17 * - Mobile bottom sheet open/close, with one section per filter dimension 18 * - Persistent search input with debounced live search 19 * - Checkbox "All" / individual term sync per filter dimension 20 * - PJAX navigation: pagination, filters, search â fetch â DOM update â pushState 21 */ 22(function () { 23 'use strict'; 24 25 /* ================================================================ 26 * Constants 27 * ================================================================ */ 28 var MOBILE_BP = 768; 29 var SEARCH_DEBOUNCE_MS = 450; 30 var SKELETON_FALLBACK_COUNT = 6; 31 32 /* ================================================================ 33 * Skeleton loading state 34 * 35 * While a PJAX request is in flight the grid is filled with 36 * shimmering placeholders instead of just being faded out â same 37 * pattern as the Newsroom pages (assets/js/itn-filter.js, 38 * press-releases-filter.js, authors.js). The placeholder count 39 * mirrors the number of cards currently on screen so the page 40 * height stays roughly stable. 41 * ================================================================ */ 42 43 function createSkeletonCard() { 44 var card = document.createElement('div'); 45 card.className = 'nui-ba__skeleton-card'; 46 card.setAttribute('aria-hidden', 'true'); 47 card.innerHTML = 48 '<div class="nui-ba__skeleton-media"></div>' + 49 '<span class="nui-ba__skeleton-line nui-ba__skeleton-meta"></span>' + 50 '<span class="nui-ba__skeleton-line nui-ba__skeleton-title"></span>' + 51 '<span class="nui-ba__skeleton-line nui-ba__skeleton-title-2"></span>'; 52 return card; 53 } 54 55 function showSkeleton(section, grid) { 56 if (!grid) return; 57 58 // Mirror what is on screen now (0 when coming from the empty state). 59 var count = grid.querySelectorAll('.nui-ba__card').length || SKELETON_FALLBACK_COUNT; 60 61 grid.classList.add('nui-ba__grid--skeleton'); 62 grid.setAttribute('aria-busy', 'true'); 63 grid.innerHTML = ''; 64 for (var i = 0; i < count; i += 1) { 65 grid.appendChild(createSkeletonCard()); 66 } 67 68 // Results count is stale until the response lands. 69 var countEl = section.querySelector('[data-results-count]'); 70 if (countEl && !countEl.classList.contains('nui-ba__results-count--skeleton')) { 71 countEl.dataset.nuiBaCountText = countEl.textContent; 72 countEl.textContent = ''; 73 countEl.classList.add('nui-ba__results-count--skeleton'); 74 } 75 76 var pagination = section.querySelector('.nui-ba__pagination'); 77 if (pagination) pagination.classList.add('is-loading'); 78 } 79 80 /** 81 * The â inside the search field is always in the DOM; it is shown only 82 * while the field has a value. Toggled on every keystroke (not on the 83 * debounced request) so it tracks the field, not the results. 84 */ 85 function syncClearButton(form) { 86 if (!form) return; 87 var btn = form.querySelector('[data-search-clear]'); 88 var input = form.querySelector('[data-search-input]'); 89 if (btn && input) btn.hidden = input.value.trim() === ''; 90 } 91 92 function clearSkeleton(section, grid) { 93 if (grid) { 94 grid.classList.remove('nui-ba__grid--skeleton'); 95 grid.removeAttribute('aria-busy'); 96 } 97 98 // Restore the previous count text if the response did not bring a 99 // replacement meta row (it normally does, via outerHTML). 100 var countEl = section.querySelector('.nui-ba__results-count--skeleton'); 101 if (countEl) { 102 countEl.classList.remove('nui-ba__results-count--skeleton'); 103 if (countEl.dataset.nuiBaCountText) { 104 countEl.textContent = countEl.dataset.nuiBaCountText; 105 delete countEl.dataset.nuiBaCountText; 106 } 107 } 108 109 var pagination = section.querySelector('.nui-ba__pagination.is-loading'); 110 if (pagination) pagination.classList.remove('is-loading'); 111 } 112 113 /* ================================================================ 114 * Page scroll lock (mobile bottom sheet) 115 * 116 * `body { overflow: hidden }` alone does not stop touch scrolling on 117 * iOS Safari â swipes still move the page behind the sheet. Pinning 118 * the body with position:fixed does, at the cost of the scroll 119 * position, so that is saved and restored on unlock. 120 * ================================================================ */ 121 var scrollLock = null; 122 123 function lockPageScroll() { 124 if (scrollLock) return; 125 var body = document.body; 126 scrollLock = { 127 y: window.pageYOffset || document.documentElement.scrollTop || 0, 128 style: body.getAttribute('style') 129 }; 130 body.style.position = 'fixed'; 131 body.style.top = -scrollLock.y + 'px'; 132 body.style.left = '0'; 133 body.style.right = '0'; 134 body.style.width = '100%'; 135 body.style.overflow = 'hidden'; 136 } 137 138 function unlockPageScroll() { 139 if (!scrollLock) return; 140 var body = document.body; 141 if (scrollLock.style === null) { 142 body.removeAttribute('style'); 143 } else { 144 body.setAttribute('style', scrollLock.style); 145 } 146 // Instant, not smooth â the page must not visibly scroll back. 147 var html = document.documentElement; 148 var prevBehavior = html.style.scrollBehavior; 149 html.style.scrollBehavior = 'auto'; 150 window.scrollTo(0, scrollLock.y); 151 html.style.scrollBehavior = prevBehavior; 152 scrollLock = null; 153 } 154 155 function openSheet(sheet) { 156 if (!sheet) return; 157 sheet.classList.add('open'); 158 lockPageScroll(); 159 } 160 161 function closeSheet(sheet) { 162 if (!sheet) return; 163 sheet.classList.remove('open'); 164 unlockPageScroll(); 165 } 166 167 /* ================================================================ 168 * URL Builder 169 * ================================================================ */ 170 171 /** 172 * Build a new URL preserving current params and merging new ones. 173 * Strips /page/N/ and resets to page 1 on filter/search changes. 174 */ 175 function buildUrl(params) { 176 var url = new URL(window.location.href); 177 178 // Reset page when filters change 179 url.searchParams.delete('paged'); 180 url.pathname = url.pathname.replace(/\/page\/\d+\/?$/, '/'); 181
182 Object.keys(params).forEach(function (key) { 183 var val = params[key]; 184 if (val === '' || val === null || val === undefined) { 185 url.searchParams.delete(key); 186 } else { 187 url.searchParams.set(key, val); 188 } 189 }); 190 191 return url.toString(); 192 } 193 194 /* ================================================================ 195 * Checkbox helpers (generic â work for any filter dimension) 196 * ================================================================ */ 197 198 function collectSelectedIds(container) { 199 var checked = container.querySelectorAll('[data-term-id]:checked'); 200 var ids = []; 201 checked.forEach(function (cb) { 202 ids.push(cb.getAttribute('data-term-id')); 203 }); 204 return ids; 205 } 206 207 /** 208 * Keep the "All" checkbox and the individual term checkboxes within 209 * one filter dimension's container mutually exclusive. 210 */ 211 function syncCheckboxGroup(container, changed) { 212 var allCb = container.querySelector('[data-all]'); 213 var itemCbs = container.querySelectorAll('[data-term-id]'); 214 215 if (changed.hasAttribute('data-all')) { 216 if (changed.checked) { 217 itemCbs.forEach(function (cb) { cb.checked = false; }); 218 } else { 219 changed.checked = true; 220 } 221 } else { 222 var anyChecked = Array.prototype.some.call(itemCbs, function (cb) { return cb.checked; }); 223 if (allCb) allCb.checked = !anyChecked; 224 } 225 } 226 227 /* ================================================================ 228 * PJAX Navigation 229 * 230 * Fetches the target URL, parses the returned HTML, extracts the 231 * updated grid + pagination + meta row + filter/search state, and 232 * patches the DOM. Then pushes the URL to history so back/forward 233 * work. 234 * ================================================================ */ 235 236 function pjaxNavigate(section, targetUrl, opts) { 237 opts = opts || {}; 238 var skipPush = opts.skipPushState || false; 239 var doScroll = opts.scrollToTop !== false; 240 241 var grid = section.querySelector('.nui-ba__grid'); 242 243 // Loading state â skeleton placeholders in place of the old cards 244 showSkeleton(section, grid); 245 246 fetch(targetUrl, { credentials: 'same-origin' }) 247 .then(function (res) { 248 if (!res.ok) throw new Error('HTTP ' + res.status); 249 return res.text(); 250 }) 251 .then(function (html) { 252 var doc = new DOMParser().parseFromString(html, 'text/html'); 253 254 // Find matching section in response â try by ID, then by class 255 var newSection = null; 256 if (section.id) { 257 newSection = doc.getElementById(section.id); 258 } 259 if (!newSection) { 260 newSection = doc.querySelector('.nui-blog-articles, .nui-cat-archive'); 261 } 262 if (!newSection) { 263 // Couldn't parse response â fall back to full reload 264 window.location.href = targetUrl; 265 return; 266 } 267 268 // 1) Grid content 269 var newGrid = newSection.querySelector('.nui-ba__grid'); 270 if (grid && newGrid) { 271 grid.innerHTML = newGrid.innerHTML; 272 grid.className = newGrid.className; 273 grid.removeAttribute('style'); 274 if (newGrid.getAttribute('style')) { 275 grid.setAttribute('style', newGrid.getAttribute('style')); 276 } 277 } 278 279 // 2) Pagination 280 var curPag = section.querySelector('.nui-ba__pagination'); 281 var newPag = newSection.querySelector('.nui-ba__pagination'); 282 if (curPag && newPag) { 283 curPag.outerHTML = newPag.outerHTML; 284 } else if (curPag && !newPag) { 285 curPag.remove(); 286 } else if (!curPag && newPag) { 287 var g = section.querySelector('.nui-ba__grid'); 288 if (g) g.insertAdjacentHTML('afterend', newPag.outerHTML); 289 } 290 291 // 3) Meta row (results count) 292 var curMeta = section.querySelector('.nui-ba__meta-row'); 293 var newMeta = newSection.querySelector('.nui-ba__meta-row'); 294 if (curMeta && newMeta) { 295 curMeta.outerHTML = newMeta.outerHTML; 296 } 297 298 // 3b) Active filter chips â the row is absent entirely when 299 // nothing is selected, so handle add/remove as well. 300 var curChips = section.querySelector('[data-active-filters]'); 301 var newChips = newSection.querySelector('[data-active-filters]'); 302 if (curChips && newChips) { 303 curChips.outerHTML = newChips.outerHTML; 304 } else if (curChips && !newChips) { 305 curChips.remove(); 306 } else if (!curChips && newChips) { 307 var controls = section.querySelector('.nui-ba__controls');
308 if (controls) controls.insertAdjacentHTML('afterend', newChips.outerHTML); 309 } 310 311 // 4) Desktop filter dropdowns â label + checkbox state, per dimension 312 newSection.querySelectorAll('[data-filter-wrapper]').forEach(function (newWrapper) { 313 var key = newWrapper.getAttribute('data-filter-key'); 314 var curWrapper = section.querySelector('[data-filter-wrapper][data-filter-key="' + key + '"]'); 315 if (!curWrapper) return; 316 317 var newLabel = newWrapper.querySelector('[data-filter-value]'); 318 var curLabel = curWrapper.querySelector('[data-filter-value]'); 319 if (curLabel && newLabel) curLabel.textContent = newLabel.textContent; 320 321 var newAll = newWrapper.querySelector('[data-all]'); 322 var curAll = curWrapper.querySelector('[data-all]'); 323 if (curAll && newAll) curAll.checked = newAll.checked; 324 325 newWrapper.querySelectorAll('[data-term-id]').forEach(function (newCb) { 326 var id = newCb.getAttribute('data-term-id'); 327 var curCb = curWrapper.querySelector('[data-term-id="' + id + '"]'); 328 if (curCb) curCb.checked = newCb.checked; 329 }); 330 }); 331 332 // 5) Mobile bottom-sheet sections â checkbox state, per dimension 333 newSection.querySelectorAll('[data-sheet-section]').forEach(function (newSheetSection) { 334 var key = newSheetSection.getAttribute('data-filter-key'); 335 var curSheetSection = section.querySelector('[data-sheet-section][data-filter-key="' + key + '"]'); 336 if (!curSheetSection) return; 337 338 var newAll = newSheetSection.querySelector('[data-all]'); 339 var curAll = curSheetSection.querySelector('[data-all]'); 340 if (curAll && newAll) curAll.checked = newAll.checked; 341 342 newSheetSection.querySelectorAll('[data-term-id]').forEach(function (newCb) { 343 var id = newCb.getAttribute('data-term-id'); 344 var curCb = curSheetSection.querySelector('[data-term-id="' + id + '"]'); 345 if (curCb) curCb.checked = newCb.checked; 346 }); 347 }); 348 349 // 6) Search form â sync hidden filter inputs + input value 350 // (skip the input itself if the user is actively typing in it) 351 var form = section.querySelector('[data-search-form]'); 352 var newForm = newSection.querySelector('[data-search-form]'); 353 if (form && newForm) { 354 ['ba_cat', 'ba_topic', 'ba_product'].forEach(function (name) { 355 var newHidden = newForm.querySelector('input[name="' + name + '"]'); 356 var curHidden = form.querySelector('input[name="' + name + '"]'); 357 if (newHidden) { 358 if (curHidden) { 359 curHidden.value = newHidden.value; 360 } else { 361 form.insertBefore(newHidden.cloneNode(true), form.firstChild); 362 } 363 } else if (curHidden) { 364 curHidden.remove(); 365 } 366 }); 367 368 var curInput = form.querySelector('[data-search-input]'); 369 var newInput = newForm.querySelector('[data-search-input]'); 370 if (curInput && newInput && document.activeElement !== curInput) { 371 curInput.value = newInput.value; 372 } 373 374 // Keep the â in step with the field (matters on back/forward). 375 syncClearButton(form); 376 } 377 378 // Drop any skeleton state the patches above did not replace 379 clearSkeleton(section, section.querySelector('.nui-ba__grid')); 380 381 // pushState (skip on popstate to avoid duplicate entries) 382 if (!skipPush) { 383 history.pushState({ nuiBaPjax: true }, '', targetUrl); 384 } 385 386 // Scroll to controls area 387 if (doScroll) { 388 var anchor = section.querySelector('.nui-ba__controls') || section; 389 anchor.scrollIntoView({ behavior: 'smooth', block: 'start' }); 390 } 391 }) 392 .catch(function () { 393 // Network error or parse failure â full page reload as fallback 394 window.location.href = targetUrl; 395 }); 396 } 397 398 /* ================================================================ 399 * Init Block â event delegation so dynamically replaced 400 * elements (pagination, meta row) keep working. 401 * ================================================================ */ 402 403 function initBlock(section) { 404 var searchDebounceTimer = null; 405 406 function triggerSearch(value) { 407 pjaxNavigate(section, buildUrl({ ba_search: value || '' }), { scrollToTop: false }); 408 } 409 410 /* --- Click delegation --- */ 411 section.addEventListener('click', function (e) { 412 413 // Pagination <a> links â PJAX 414 var pagLink = e.target.closest('.nui-ba__pagination a'); 415 if (pagLink) { 416 e.preventDefault(); 417 pjaxNavigate(section, pagLink.href); 418 return; 419 } 420 421 // Active filter chip / "Clear filters" â PJAX to the link's URL 422 var chipLink = e.target.closest('[data-filter-chip], [data-clear-filters]'); 423 if (chipLink) { 424 e.preventDefault(); 425 pjaxNavigate(section, chipLink.getAttribute('href'), { scrollToTop: false }); 426 return; 427 } 428 429 // Clear search button (â at the right of the search f
429ield) 430 if (e.target.closest('[data-search-clear]')) { 431 e.preventDefault(); 432 clearTimeout(searchDebounceTimer); 433 var form0 = e.target.closest('[data-search-form]'); 434 var inp0 = form0 ? form0.querySelector('[data-search-input]') : null; 435 if (inp0) { 436 inp0.value = ''; 437 inp0.focus(); 438 } 439 syncClearButton(form0); 440 triggerSearch(''); 441 return; 442 } 443 444 // Filter button â dropdown (desktop) / sheet (mobile) 445 var filterBtn = e.target.closest('[data-filter-btn]'); 446 if (filterBtn) { 447 e.preventDefault(); 448 if (window.innerWidth < MOBILE_BP) { 449 var sheet = section.querySelector('[data-bottom-sheet]'); 450 openSheet(sheet); 451 return; 452 } 453 454 var wrapper = filterBtn.closest('[data-filter-wrapper]'); 455 if (!wrapper) return; 456 457 var opening = !wrapper.classList.contains('active'); 458 459 // Only one dropdown open at a time 460 section.querySelectorAll('[data-filter-wrapper].active').forEach(function (w) { 461 if (w !== wrapper) { 462 w.classList.remove('active'); 463 var d = w.querySelector('[data-dropdown]'); 464 if (d) d.classList.remove('show'); 465 } 466 }); 467 468 wrapper.classList.toggle('active', opening); 469 var dd = wrapper.querySelector('[data-dropdown]'); 470 if (dd) dd.classList.toggle('show', opening); 471 return; 472 } 473 474 // Reset link inside a dropdown â clear that dimension â PJAX 475 if (e.target.closest('[data-reset]')) { 476 e.preventDefault(); 477 var resetWrapper = e.target.closest('[data-filter-wrapper]'); 478 if (!resetWrapper) return; 479 480 var resetDropdown = resetWrapper.querySelector('[data-dropdown]'); 481 if (resetDropdown) { 482 resetDropdown.querySelectorAll('[data-term-id]').forEach(function (cb) { cb.checked = false; }); 483 } 484 485 var resetParam = resetWrapper.getAttribute('data-url-param'); 486 var resetParams = {}; 487 if (resetParam) resetParams[resetParam] = ''; 488 489 resetWrapper.classList.remove('active'); 490 if (resetDropdown) resetDropdown.classList.remove('show'); 491 492 pjaxNavigate(section, buildUrl(resetParams)); 493 return; 494 } 495 496 // Close bottom sheet (Ã button) 497 if (e.target.closest('[data-close-sheet]')) { 498 var bs = section.querySelector('[data-bottom-sheet]'); 499 closeSheet(bs); 500 return; 501 } 502 503 // Click on sheet overlay backdrop 504 if (e.target.matches('[data-bottom-sheet]')) { 505 closeSheet(e.target); 506 return; 507 } 508 509 // Apply button (mobile sheet) â collect all dimensions â PJAX 510 if (e.target.closest('[data-apply-filters]')) { 511 var params = {}; 512 section.querySelectorAll('[data-sheet-section]').forEach(function (sheetSection) { 513 var urlParam = sheetSection.getAttribute('data-url-param'); 514 if (!urlParam) return; 515 params[urlParam] = collectSelectedIds(sheetSection).join(','); 516 }); 517 518 var url = buildUrl(params); 519 var bs2 = section.querySelector('[data-bottom-sheet]'); 520 closeSheet(bs2); 521 pjaxNavigate(section, url); 522 return; 523 } 524 }); 525 526 /* --- Checkbox changes (delegation) --- */ 527 section.addEventListener('change', function (e) { 528 if (!e.target.matches('input[type="checkbox"]')) return; 529 530 // Desktop dropdown 531 var dropdown = e.target.closest('[data-dropdown]'); 532 if (dropdown) { 533 syncCheckboxGroup(dropdown, e.target); 534 if (window.innerWidth >= MOBILE_BP) { 535 var wrapper = e.target.closest('[data-filter-wrapper]'); 536 var urlParam = wrapper ? wrapper.getAttribute('data-url-param') : null; 537 if (urlParam) { 538 var params = {}; 539 params[urlParam] = collectSelectedIds(dropdown).join(','); 540 var url = buildUrl(params); 541 542 if (wrapper) wrapper.classList.remove('active'); 543 dropdown.classList.remove('show'); 544 545 pjaxNavigate(section, url); 546 } 547 } 548 return; 549 } 550 551 // Mobile sheet 552 var sheetFilters = e.target.closest('.nui-ba__sheet-filters'); 553 if (sheetFilters) { 554 syncCheckboxGroup(sheetFilters, e.target); 555 } 556 }); 557 558 /* --- Persistent search: debounced live search + submit-on-enter --- */ 559 section.addEventListener('input', function (e) { 560 if (!e.target.matches('[data-search-input]')) return; 561 var value = e.target.value.trim(); 562 syncClearButton(e.target.closest('[data-search-form]')); 563 clearTimeout(searchDebounceTimer); 564 searchDebounceTimer = setTimeout(function () { 565 triggerSearch(value); 566 }, SEARCH_DEBOUNCE_MS); 567 }); 568 569 section.addEventListener('submit', function (e) { 570 var form = e.target.closest('[data-search-form]'); 571 if (!form) return; 572 e.preventDefault(); 573 574 clearTimeout(searchDebounceTimer); 575 var inp = form.querySelector('[data-search-input]'); 576 triggerSearch(inp ? inp.value.trim() : ''); 577 }); 578 579 /* --- Click outside a dropdown to close it --- */ 580 document.addEventListener('click', function (e) { 581 section.querySelectorAll('[data-filter-wrapper].active').forEach(function (w) { 582 if (!w.contains(e.target)) { 583 w.classList.remove('active'); 584 var d = w.querySelector('[data-dropdown]'); 585 if (d) d.classList.remove('show'); 586 } 587 }); 588 }); 589 590 /* --- ESC key --- */ 591 document.addEventListener('keydown', function (e) { 592 if (e.key !== 'Escape') return; 593 594 var openWrapper = section.querySelector('[data-filter-wrapper].active'); 595 if (openWrapper) { 596 openWrapper.classList.remove('active'); 597 var dd = openWrapper.querySelector('[data-dropdown]'); 598 if (dd) dd.classList.remove('show'); 599 return; 600 } 601 var bs = section.querySelector('[data-bottom-sheet]'); 602 if (bs && bs.classList.contains('open')) { 603 closeSheet(bs); 604 } 605 }); 606 } 607 608 /* ================================================================ 609 * Bootstrap 610 * ================================================================ */ 611 612 // Tag the initial history entry so popstate knows about us 613 history.replaceState({ nuiBaPjax: true }, '', window.location.href); 614 615 // Handle back/forward 616 window.addEventListener('popstate', function () { 617 var section = document.querySelector('.nui-blog-articles, .nui-cat-archive'); 618 if (section) { 619 pjaxNavigate(section, window.location.href, { 620 skipPushState: true, 621 scrollToTop: false 622 }); 623 } 624 }); 625 626 // Init all block instances 627 document.addEventListener('DOMContentLoaded', function () { 628 var blocks = document.querySelectorAll('.nui-blog-articles, .nui-cat-archive'); 629 blocks.forEach(initBlock); 630 }); 631})();
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.