PageSourceSearch

https://cellebrite.com/wp-content/themes/cellebrite/assets/js/nui-blog-articles.js?ver=1790864007

js cellebrite.com collected 2026-10-02 02:20:13 UTC 26,687 bytes, 631 lines download raw bytes

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.