PageSourceSearch

https://wallpapers.tw/static/js/wp-nav.js?v=2

js wallpapers.tw collected 2026-10-10 00:44:54 UTC 30,583 bytes, 667 lines download raw bytes

1/* wp-nav.js — behaviour for partials/nav.html.
2   Extracted from inline <script> blocks so the ~28 KB is fetched once per
3   visitor instead of re-downloaded with every HTML response.
4
5   Loaded with `defer` from partials/head.html, so every IIFE below runs after
6   the nav markup is parsed but before DOMContentLoaded — the same ordering the
7   inline blocks had relative to each other, which matters for
8   window.wpcRefreshStickyChrome (defined near the bottom, called defensively
9   from the blocks above it).
10
11   No gettext here: a static .js file can't call _() and the site ships 33
12   locales, so translated strings arrive via data-attrs on the markup. */
13
14/* ── mobile hamburger panels + deferred top-pix thumbnails ─────────────────────────────────── */
15    (function(){
16      var rail = document.querySelector('.wpc-hm-rail');
17      if (!rail) return;
18
19      // Deferred top-pix: build a panel's thumbnail row from its JSON payload
20      // the first time the panel is shown. Keeps ~12 <a>/<img> per panel out of
21      // the initial HTML (and Googlebot's parsed DOM) until actually needed.
22      // Built via DOM APIs (not innerHTML) so payload strings can't inject HTML.
23      function buildToppix(panel){
24        if (!panel) return;
25        var row = panel.querySelector('.wpc-hm-toppix-row[data-toppix]');
26        if (!row) return;
27        var items;
28        try { items = JSON.parse(row.getAttribute('data-toppix')); } catch (e) { return; }
29        var label = row.getAttribute('data-toppix-label') || '';
30        var scroll = document.createElement('div');
31        scroll.className = 'wpc-hm-toppix-scroll';
32        items.forEach(function(it){
33          var a = document.createElement('a');
34          a.className = 'wpc-hm-toppix'; a.href = it.url; a.title = it.alt || '';
35          var img = document.createElement('img');
36          img.src = it.thumb_url;
37          img.alt = it.alt || it.title || (label + ' thumbnail');
38          img.loading = 'lazy'; img.width = 72; img.height = 72;
39          a.appendChild(img); scroll.appendChild(a);
40        });
41        row.appendChild(scroll);
42        row.removeAttribute('data-toppix');
43      }
44
45      // Hydrate decorative panel art only after the user opens the hamburger
46      // and selects that panel. Text links/names remain server-rendered and
47      // crawlable; the closed global nav no longer competes with page imagery.
48      function hydratePanelImages(panel){
49        if (!panel) return;
50        panel.querySelectorAll('source[data-srcset]').forEach(function(source){
51          source.srcset = source.getAttribute('data-srcset');
52          source.removeAttribute('data-srcset');
53        });
54        panel.querySelectorAll('img[data-src]').forEach(function(img){
55          img.src = img.getAttribute('data-src');
56          img.removeAttribute('data-src');
57        });
58      }
59
60      function activatePanel(panel){
61        buildToppix(panel);
62        hydratePanelImages(panel);
63      }
64      window.wpcActivateHmPanel = activatePanel;
65
66      rail.addEventListener('click', function(e){
67        var btn = e.target.closest('.wpc-hm-tab');
68        if (!btn) return;
69        var key = btn.dataset.tab;
70        rail.querySelectorAll('.wpc-hm-tab').forEach(function(t){ t.classList.remove('is-active'); });
71        btn.classList.add('is-active');
72        var hm = btn.closest('.wpc-hm');
73        var panels = hm.querySelectorAll('.wpc-hm-panel');
74        panels.forEach(function(p){
75          p.classList.toggle('is-active', p.dataset.panel === key);
76        });
77        activatePanel(hm.querySelector('.wpc-hm-panel.is-active'));
78        var main = hm.querySelector('.wpc-hm-main');
79        if (main) main.scrollTop = 0;
80      });
81
82      // Toggle `.is-scrolled-x` on the tool-row and kw-grid scrollers so the
83      // CSS can paint a LEFT fade once the user has scrolled right (signals
84      // they can scroll back). Single delegated listener via capture phase
85      // so we catch scrolls on any descendant without per-element binding.
86      document.querySelectorAll('.wpc-hm-tools-scroll, .wpc-hm-kw-grid').forEach(function(el){
87        if (el.classList.contains('wpc-hm-kw-grid--stack')) return;
88        el.addEventListener('scroll', function(){
89          el.classList.toggle('is-scrolled-x', el.scrollLeft > 8);
90        }, {passive: true});
91      });
92    })();
93
94/* ── cart badge (desktop + mobile + rail) ─────────────────────────────────── */
95      (function(){
96        function paintBadge(n) {
97          var badge = document.getElementById('nav-cart-badge');
98          if (badge) {
99            if (n > 0) {
100              badge.textContent = n > 99 ? '99+' : n;
101              badge.style.display = 'inline-block';
102            } else {
103              badge.style.display = 'none';
104            }
105          }
106          // Mobile cart (next to the hamburger) — only appears when the cart
107          // has items; the whole icon links to /cart for checkout.
108          var mCart  = document.getElementById('m-nav-cart');
109          var mBadge = document.getElementById('m-nav-cart-badge');
110          if (mCart) {
111            mCart.style.display = n > 0 ? 'inline-flex' : 'none';
112            if (mBadge && n > 0) mBadge.textContent = n > 99 ? '99+' : n;
113          }
114          // Cart entry inside the hamburger rail (first item).
115          var rCart  = document.getElementById('m-rail-cart');
116          var rBadge = document.getElementById('m-rail-cart-badge');
117          if (rCart) {
118            rCart.style.display = n > 0 ? 'flex' : 'none';
119            if (rBadge && n > 0) rBadge.textContent = n > 99 ? '99+' : n;
120          }
121        }
122        // Accept an OPTIONAL pre-fetched count so a caller (Add-to-Cart
123        // handler) can paint immediately without racing the cookie-Set vs
124        // /cart/count fetch (the cookie was sometimes not yet committed
125        // when the fetch fired, leaving the badge at 0).
126        function updateCartBadge(passedCount){
127          if (typeof passedCount === 'number') {
128            paintBadge(passedCount);
129            return;
130          }
131          var sid = document.cookie.split(';').reduce(function(a,c){
132            var p=c.trim().split('='); return p[0]==='wpc_cart' ? p[1] : a; }, '');
133          if (!sid) { paintBadge(0); return; }
134          fetch('/cart/count', {credentials:'include'})
135            .then(function(r){ return r.ok ? r.json() : null; })
136            .then(function(j){ if (j) paintBadge(j.count || 0); })
137            .catch(function(){});
138        }
139        updateCartBadge();
140        window.refreshCartBadge = updateCartBadge;
141      })();
142
143/* ── account dropdown: click-outside closes ─────────────────────────────────── */
144          // Click-outside closes the touch-toggle dropdown.
145          (function(){
146            var cb = document.getElementById('wpc-acc-toggle');
147            if (!cb) return;
148            document.addEventListener('click', function(e){
149              if (!cb.checked) return;
150              if (e.target.closest('.wpc-acc-menu')) return;
151              cb.checked = false;
152            });
153          })();
154
155/* ── mobile search branded overlay toggle ─────────────────────────────────── */
156        // Toggle the `.search-images` overlay based on focus/value (live-site pattern).
157        (function(){
158          var inp = document.getElementById('m-headerSearch');
159          if (!inp) return;
160          var ovr = inp.parentElement.querySelector('.search-images');
161          if (!ovr) return;
162          function refresh(){
163            var hide = (document.activeElement === inp) || inp.value.length > 0;
164            ovr.classList.toggle('active', !hide);
165          }
166          inp.addEventListener('focus', refresh);
167          inp.addEventListener('blur',  refresh);
168          inp.addEventListener('input', refresh);
169          refresh();
170        })();
171
172/* ── scroll chrome: drop the top ad after a short scroll ─────────────────────────────────── */
173        // Scroll chrome: the ad starts above the logo/search row, then drops
174        // away after a short scroll. The header/nav rows remain sticky.
175        (function(){
176          function refresh(){
177            if (document.body.classList.contains('menu-open')) {
178              document.body.classList.add('wpc-top-ad-hidden');
179              if (window.wpcRefreshStickyChrome) window.wpcRefreshStickyChrome();
180              return;
181            }
182            var isDesktop = window.matchMedia && window.matchMedia('(min-width: 768px)').matches;
183            var threshold = isDesktop ? 96 : 44;
184            var y = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
185            document.body.classList.toggle('wpc-top-ad-hidden', y > threshold);
186            if (window.wpcRefreshStickyChrome) window.wpcRefreshStickyChrome();
187          }
188          window.addEventListener('scroll', refresh, {passive:true});
189          window.addEventListener('resize', refresh);
190          refresh();
191        })();
192
193/* ── hamburger open/close + scroll lock ─────────────────────────────────── */
194        // Hamburger toggle — adds/removes `.show` on .navbar (CSS-driven open).
195        // Also click-outside-to-close + ESC-to-close + scroll-lock when open.
196        (function(){
197          var btn = document.querySelector('.navbar-switch');
198          var nav = document.getElementById('navbarMainContent');
199          if (!btn || !nav) return;
200          function refreshMenuOffset(){
201            var header = document.querySelector('header.new-sticky');
202            var bottom = 50;
203            if (header) {
204              var rect = header.getBoundingClientRect();
205              bottom = Math.max(0, Math.round(rect.bottom || rect.height || 50));
206            }
207            document.documentElement.style.setProperty('--wpc-mobile-menu-top', bottom + 'px');
208          }
209          function setOpen(open){
210            nav.classList.toggle('show', open);
211            btn.setAttribute('aria-expanded', open ? 'true' : 'false');
212            document.body.classList.toggle('menu-open', open);
213            if (open && window.wpcActivateHmPanel) {
214              window.wpcActivateHmPanel(nav.querySelector('.wpc-hm-panel.is-active'));
215            }
216            var y = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
217            var isDesktop = window.matchMedia && window.matchMedia('(min-width: 768px)').matches;
218            var threshold = isDesktop ? 96 : 44;
219            document.body.classList.toggle('wpc-top-ad-hidden', open || y > threshold);
220            // Hard scroll-lock: `overflow:hidden` alone leaks on iOS Safari
221            // (touch-scroll still propagates to the body). Pin the body at
222            // its current scroll offset with position:fixed, then restore
223            // the saved offset on close so the user lands back where they
224            // were instead of jumping to the top.
225            if (open) {
226              document.body.dataset.scrollLockY = y;
227              document.body.style.position = 'fixed';
228              document.body.style.top = '-' + y + 'px';
229              document.body.style.left = '0';
230              document.body.style.right = '0';
231              document.body.style.width = '100%';
232              document.body.style.overflow = 'hidden';
233            } else {
234              var savedY = parseInt(document.body.dataset.scrollLockY || '0', 10);
235              document.body.style.position = '';
236              document.body.style.top = '';
237              document.body.style.left = '';
238              document.body.style.right = '';
239              document.body.style.width = '';
240              document.body.style.overflow = '';
241              if (savedY) window.scrollTo(0, savedY);
242              delete document.body.dataset.scrollLockY;
243            }
244            if (window.wpcRefreshStickyChrome) window.wpcRefreshStickyChrome();
245            if (open) {
246              refreshMenuOffset();
247              window.requestAnimationFrame(refreshMenuOffset);
248            }
249          }
250          btn.addEventListener('click', function(e){
251            e.stopPropagation();
252            setOpen(!nav.classList.contains('show'));
253          });
254          document.addEventListener('click', function(e){
255            if (!nav.classList.contains('show')) return;
256            if (nav.contains(e.target) || btn.contains(e.target)) return;
257            setOpen(false);
258          });
259          document.addEventListener('keydown', function(e){
260            if (e.key === 'Escape' && nav.classList.contains('show')) setOpen(false);
261          });
262          window.addEventListener('resize', function(){
263            if (nav.classList.contains('show')) refreshMenuOffset();
264          });
265          window.addEventListener('orientationchange', function(){
266            if (nav.classList.contains('show')) window.setTimeout(refreshMenuOffset, 80);
267          });
268        })();
269
270/* ── search scope dropdown ─────────────────────────────────── */
271      (function(){
272        document.querySelectorAll('.scope-item').forEach(function(el){
273          el.addEventListener('click', function(e){
274            e.preventDefault();
275            var v = this.getAttribute('data-value');
276            var label = this.querySelector('span:nth-child(2)').textContent;
277            var icon  = this.querySelector('span:nth-child(1)').innerHTML;
278            document.getElementById('scope-input').value = v;
279            document.getElementById('scope-label').textContent = label;
280            document.getElementById('scope-icon').innerHTML = icon;
281            // Keep the input placeholder in sync with the chosen scope.
282            // These strings are gettext-translated, so the server hands them
283            // over as data-attrs — a static .js file can never call _().
284            var hs = document.getElementById('headerSearch');
285            var placeholder = (v === 'all')
286              ? (hs.getAttribute('data-ph-all') || '')
287              : ((hs.getAttribute('data-ph-prefix') || '') + label.toLowerCase());
288            hs.setAttribute('placeholder', placeholder);
289            document.querySelectorAll('.scope-item .ml-auto').forEach(function(c){ c.remove(); });
290            var check = document.createElement('span');
291            check.className = 'ml-auto';
292            check.style.color = '#10b981';
293            check.textContent = '✓';
294            this.appendChild(check);
295          });
296        });
297      })();
298
299/* ── middle-nav + top-strip overflow collapse ─────────────────────────────────── */
300    (function () {
301      var nav     = document.getElementById('nav-middle');
302      var row     = document.getElementById('nav-top-row');
303      var wrap    = document.getElementById('nav-more-wrap');
304      var btn     = document.getElementById('nav-more-btn');
305      var menu    = document.getElementById('nav-more-menu');
306      var cart    = document.getElementById('nav-cart-btn');
307      if (!nav || !row || !wrap || !btn || !menu) return;
308
309      // Snapshot the original ordered list of collapsible items
310      // (everything in nav EXCEPT the overflow wrap + cart).
311      var items = Array.prototype.filter.call(nav.children, function (el) {
312        return el !== wrap && el !== cart;
313      });
314      // Each item gets a placeholder so we can restore it to its original slot.
315      items.forEach(function (el, idx) { el.dataset.navIdx = String(idx); });
316
317      function inMenu(el)  { return el.parentNode === menu; }
318      function inNav(el)   { return el.parentNode === nav; }
319
320      function styleMenuItem(el) {
321        el.classList.add('nav-more-item');
322        // Override display so the inline-flex/none from d-lg-inline-flex
323        // doesn't fight us inside the dropdown.
324        el.style.display = 'flex';
325        el.style.alignItems = 'center';
326        el.style.gap = '.5rem';
327        el.style.padding = '.55rem .75rem';
328        el.style.borderRadius = '6px';
329        el.style.color = '#1f2937';
330        el.style.fontSize = '.95rem';
331        el.style.fontWeight = '600';
332      }
333      function unstyleMenuItem(el) {
334        el.classList.remove('nav-more-item');
335        // Reset the props we set above; everything else (color/weight from
336        // the inline `style=...` on the source markup) is intact.
337        el.style.display = '';
338        el.style.alignItems = '';
339        el.style.gap = '';
340        el.style.padding = '';
341        el.style.borderRadius = '';
342        el.style.color = '';
343        el.style.fontSize = '';
344        el.style.fontWeight = '';
345      }
346
347      function moveToMenu(el) {
348        if (inMenu(el)) return;
349        menu.appendChild(el);
350        styleMenuItem(el);
351      }
352      function moveToNav(el) {
353        if (inNav(el)) return;
354        unstyleMenuItem(el);
355        // Reinsert before the overflow wrap so the cart icon stays last
356        // and the original order (by data-nav-idx) is preserved.
357        var idx = parseInt(el.dataset.navIdx, 10);
358        var anchor = wrap;
359        // Find the next item with a higher idx that's currently in nav.
360        for (var i = 0; i < items.length; i++) {
361          var other = items[i];
362          if (other === el) continue;
363          if (inNav(other) && parseInt(other.dataset.navIdx, 10) > idx) {
364            anchor = other; break;
365          }
366        }
367        nav.insertBefore(el, anchor);
368      }
369
370      // Hide menu on outside click / Escape.
371      function closeMenu() {
372        menu.hidden = true; btn.setAttribute('aria-expanded', 'false');
373      }
374      btn.addEventListener('click', function (e) {
375        e.stopPropagation();
376        var open = !menu.hidden;
377        if (open) { closeMenu(); return; }
378        menu.hidden = false; btn.setAttribute('aria-expanded', 'true');
379      });
380      document.addEventListener('click', function (e) {
381        if (!wrap.contains(e.target)) closeMenu();
382      });
383      document.addEventListener('keydown', function (e) {
384        if (e.key === 'Escape') closeMenu();
385      });
386
387      function fits() {
388        // True only when both the middle nav and the full top row fit.
389        // Measuring the row catches cases where right-side CTAs squeeze the
390        // available width before the nav reports its own overflow.
391        var navFits = nav.scrollWidth <= nav.clientWidth + 1;
392        var rowFits = row.scrollWidth <= row.clientWidth + 1;
393        if (!rowFits) {
394          var rowRect = row.getBoundingClientRect();
395          var lastRect = row.lastElementChild ? row.lastElementChild.getBoundingClientRect() : rowRect;
396          rowFits = lastRect.right <= rowRect.right + 1;
397        }
398        return navFits && rowFits;
399      }
400
401      function layout() {
402        // Step 1: bring everything back into the nav. Then progressively
403        // move items into the menu (from the END of the source order,
404        // EXCLUDING the cart which stays in the nav) until it fits.
405        items.forEach(moveToNav);
406        wrap.style.display = 'none';
407        menu.hidden = true;
408        btn.setAttribute('aria-expanded', 'false');
409
410        if (fits()) return;
411
412        wrap.style.display = 'inline-flex'; // show "…"
413        // Iterate items right-to-left, hiding them until it fits.
414        for (var i = items.length - 1; i >= 0; i--) {
415          moveToMenu(items[i]);
416          if (fits()) break;
417        }
418        wrap.style.display = menu.children.length ? 'inline-flex' : 'none';
419      }
420
421      // Initial pass + on resize. RAF-debounce so we don't thrash.
422      var raf = null;
423      function schedule() {
424        if (raf) return;
425        raf = requestAnimationFrame(function () { raf = null; layout(); });
426      }
427      schedule();
428      window.addEventListener('resize', schedule);
429      window.addEventListener('orientationchange', schedule);
430      window.addEventListener('load', schedule);
431      // Webfont swap changes every label's width, so the first pass measured
432      // with the fallback face is wrong. Re-run the moment the real font is
433      // ready — the row is nowrap so this only moves items horizontally.
434      if (document.fonts && document.fonts.ready) {
435        document.fonts.ready.then(schedule).catch(function () {});
436      }
437      if ('ResizeObserver' in window) {
438        new ResizeObserver(schedule).observe(row);
439      }
440    })();
441
442    /* Top type-strip overflow — collapse trailing BLACK flat tiles into the
443       "⋯" dropdown when the strip is wider than the viewport (instead of
444       scrolling them off the right edge). Same priority+ pattern as the
445       middle-nav script above. */
446    (function () {
447      var strip  = document.querySelector('nav.navgation ul.navbar-nav.d-lg-flex');
448      var moreLi = document.getElementById('wpc-strip-more');
449      var btn    = document.getElementById('wpc-strip-more-btn');
450      var menu   = document.getElementById('wpc-strip-more-menu');
451      if (!strip || !moreLi || !btn || !menu) return;
452
453      // Collapsible = flat (black) tiles AND colored type tiles, in DOM
454      // order. We collapse from the END, so the black tiles (which sit last)
455      // always go first; colored type tiles only collapse as a last resort
456      // at the narrowest widths where the black ones aren't enough.
457      var items = Array.prototype.filter.call(strip.children, function (el) {
458        return el.classList.contains('wpc-h-flat') || el.classList.contains('wpc-h-type');
459      });
460      if (!items.length) return;
461
462      function fits()   { return strip.scrollWidth <= strip.clientWidth + 1; }
463      function restore() {
464        items.forEach(function (el) {
465          if (el.parentNode !== strip) strip.insertBefore(el, moreLi);
466        });
467      }
468      function closeMenu() { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); }
469
470      function layout() {
471        restore();
472        moreLi.classList.remove('is-shown');
473        closeMenu();
474        if (fits()) return;
475        moreLi.classList.add('is-shown');
476        for (var i = items.length - 1; i >= 0; i--) {
477          menu.insertBefore(items[i], menu.firstChild);  // front-insert keeps order
478          if (fits()) break;
479        }
480        if (!menu.children.length) moreLi.classList.remove('is-shown');
481      }
482
483      function positionMenu() {
484        // Anchor the fixed dropdown under the "⋯", right-aligned to it, and
485        // kept within the viewport's right edge.
486        var r = btn.getBoundingClientRect();
487        menu.style.top = r.bottom + 'px';
488        menu.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
489        menu.style.left = 'auto';
490      }
491      btn.addEventListener('click', function (e) {
492        e.stopPropagation();
493        if (!menu.hidden) { closeMenu(); return; }
494        positionMenu();
495        menu.hidden = false; btn.setAttribute('aria-expanded', 'true');
496      });
497      document.addEventListener('click', function (e) {
498        if (!moreLi.contains(e.target) && !menu.contains(e.target)) closeMenu();
499      });
500      document.addEventListener('keydown', function (e) {
501        if (e.key === 'Escape') closeMenu();
502      });
503
504      var sraf = null;
505      function sschedule() {
506        if (sraf) return;
507        sraf = requestAnimationFrame(function () { sraf = null; layout(); });
508      }
509      sschedule();
510      window.addEventListener('resize', sschedule);
511      window.addEventListener('orientationchange', sschedule);
512      window.addEventListener('load', sschedule);
513      if ('ResizeObserver' in window && strip.parentElement) {
514        new ResizeObserver(sschedule).observe(strip.parentElement);
515      }
516    })();
517
518/* ── sticky-chrome measurement + type-bar toggle ─────────────────────────────────── */
519  /* Keep every sticky layer in one measured stack:
520     ad rail -> logo/search header -> desktop typebar -> related pills. */
521  (function(){
522    function px(n) { return Math.max(0, Math.round(n || 0)) + 'px'; }
523    window.wpcRefreshStickyChrome = function(){
524      var header = document.querySelector('header.new-sticky');
525      var typebar = document.querySelector('.wpc-typebar');
526      var headerRect = header ? header.getBoundingClientRect() : {top:0, height:0};
527      var headerTop = Math.max(0, headerRect.top || 0);
528      var headerH = headerRect.height || 0;
529      var typebarH = 0;
530      if (typebar && window.getComputedStyle(typebar).display !== 'none') {
531        typebarH = typebar.getBoundingClientRect().height || 0;
532      }
533      document.documentElement.style.setProperty('--sticky-typebar-top', px(headerTop + headerH));
534      document.documentElement.style.setProperty('--sticky-header-h', px(headerTop + headerH + typebarH));
535    };
536    ['DOMContentLoaded','load','resize','orientationchange'].forEach(function(evt){
537      window.addEventListener(evt, window.wpcRefreshStickyChrome, {passive:true});
538    });
539    window.addEventListener('scroll', window.wpcRefreshStickyChrome, {passive:true});
540    window.wpcRefreshStickyChrome();
541  })();
542
543  /* Click-to-toggle for keyboard / mobile users (hover handles desktop). */
544  (function(){
545    var pills = document.querySelectorAll('.wpc-typebar-pill');
546    pills.forEach(function(pill){
547      var caret = pill.querySelector('.wpc-typebar-caret');
548      if (!caret) return;
549      caret.addEventListener('click', function(e){
550        e.preventDefault(); e.stopPropagation();
551        var open = pill.classList.toggle('is-open');
552        caret.setAttribute('aria-expanded', open ? 'true' : 'false');
553        // Close other open pills
554        pills.forEach(function(p){
555          if (p !== pill) {
556            p.classList.remove('is-open');
557            var c = p.querySelector('.wpc-typebar-caret');
558            if (c) c.setAttribute('aria-expanded', 'false');
559          }
560        });
561      });
562    });
563    // Click outside closes all
564    document.addEventListener('click', function(e){
565      if (!e.target.closest('.wpc-typebar-pill')) {
566        pills.forEach(function(p){
567          p.classList.remove('is-open');
568          var c = p.querySelector('.wpc-typebar-caret');
569          if (c) c.setAttribute('aria-expanded', 'false');
570        });
571      }
572    });
573  })();
574
575/* ── search autocomplete ─────────────────────────────────── */
576(function(){
577  var inputs = ['headerSearch', 'm-headerSearch']
578    .map(function(id){ return document.getElementById(id); })
579    .filter(Boolean);
580  if (!inputs.length) return;
581
582  function attach(input){
583    // Anchor parent: the <form> is positioned/inline already; we make the
584    // popover absolute relative to the form's bounding box.
585    var form = input.closest('form'); if (!form) return;
586    if (getComputedStyle(form).position === 'static') form.style.position = 'relative';
587
588    var pop = document.createElement('div');
589    pop.className = 'wpc-ac-pop';
590    pop.setAttribute('role', 'listbox');
591    // Inline styles win over any stray global rule that was making the
592    // dropdown look transparent. Anchored just below the search box.
593    pop.style.cssText =
594      'position:absolute;left:0;right:0;top:calc(100% + 4px);' +
595      'background:#fff;background-color:#fff;opacity:1;' +
596      'border:1px solid #e5e7eb;border-radius:8px;' +
597      'box-shadow:0 14px 36px rgba(0,0,0,.18);' +
598      'max-height:360px;overflow-y:auto;z-index:12000;isolation:isolate;';
599    form.appendChild(pop);
600
601    var timer = null;
602    var items = [];
603    var active = -1;
604
605    function render(rows, q){
606      pop.innerHTML = '';
607      items = rows;
608      active = -1;
609      if (!rows.length) { pop.classList.remove('show'); return; }
610      var qre = q ? new RegExp('('+q.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&')+')', 'ig') : null;
611      rows.forEach(function(r, i){
612        var a = document.createElement('a');
613        a.className = 'wpc-ac-item';
614        a.href = r.url;
615        var label = qre ? r.name.replace(qre, '<mark>$1</mark>') : r.name;
616        // Foreign-locale matches carry a flag (e.g. 🇩🇪 for a German keyword on
617        // an English page) so the source locale is obvious at a glance.
618        var flag = r.flag ? '<span class="ac-flag" title="' + (r.locale||'') + '">' + r.flag + '</span>' : '';
619        a.innerHTML = '<span class="ac-icon">🔎</span><span>' + label + '</span>' + flag;
620        a.addEventListener('mousedown', function(e){
621          e.preventDefault();
622          window.location.href = r.url;
623        });
624        pop.appendChild(a);
625      });
626      pop.classList.add('show');
627    }
628
629    function setActive(idx){
630      var nodes = pop.querySelectorAll('.wpc-ac-item');
631      nodes.forEach(function(n, i){ n.classList.toggle('active', i === idx); });
632      active = idx;
633    }
634
635    function fetchSuggest(q){
636      fetch('/api/search/autocomplete?q=' + encodeURIComponent(q)
637              + '&locale=' + encodeURIComponent(
638                  (document.body.getAttribute('data-locale') || 'en')),
639            {credentials: 'same-origin'})
640        .then(function(r){ return r.ok ? r.json() : []; })
641        .then(function(rows){ render(rows || [], q); })
642        .catch(function(){ pop.classList.remove('show'); });
643    }
644
645    input.addEventListener('input', function(){
646      var q = input.value.trim();
647      clearTimeout(timer);
648      if (q.length < 2) { pop.classList.remove('show'); return; }
649      timer = setTimeout(function(){ fetchSuggest(q); }, 180);
650    });
651    input.addEventListener('keydown', function(e){
652      if (!pop.classList.contains('show')) return;
653      if (e.key === 'ArrowDown') { e.preventDefault(); setActive((active + 1) % items.length); }
654      else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((active - 1 + items.length) % items.length); }
655      else if (e.key === 'Enter' && active >= 0) {
656        e.preventDefault();
657        window.location.href = items[active].url;
658      }
659      else if (e.key === 'Escape') { pop.classList.remove('show'); }
660    });
661    input.addEventListener('blur', function(){
662      // Delay so click handlers fire first
663      setTimeout(function(){ pop.classList.remove('show'); }, 180);
664    });
665  }
666  inputs.forEach(attach);
667})();

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.