1/** 2 * GIP HEADER v4 â header.js 3 * Path: wwwroot/design-iiv/js/header.js 4 * 5 * Phase 4 modifications from new-header-design.html IIFE: 6 * 7 * A. COUNTDOWN 8 * - Hardcoded cutoff.setHours(14,0,0,0) REMOVED. 9 * - Now reads #cutoff-time-miliseconds value (server-supplied absolute 10 * Unix epoch ms from Model.CutOffTime.CutOffTimeMilliSeconds). 11 * - cutoffEpoch = ms directly (NOT Date.now() + ms - this value is 12 * already an absolute epoch, computed once at script execution time, 13 * before the interval starts. 14 * - All expired / urgent / weekend logic preserved intact. 15 * - jquery.countdown dependency dropped entirely. 16 * - gipCountdownSuffix element removed from original â suffix text 17 * is now a static "PST" span in the Razor markup; only the 18 * countdown time span and dot are updated by JS. 19 * 20 * B. SEARCH 21 * - GIP_PRODUCTS hardcoded array REMOVED. 22 * - Uses Categories[] from categories-json.js (already loaded before 23 * this script via defer ordering in _HomeLayout.cshtml). 24 * - Field mapping: labelâname, typeâcat, linkâurl, 25 * imageâimg (prefixed with CDN), StartingPriceâprice, sizesâsize, 26 * isSameDayâisSameDay filter flag. 27 * - gipSearchFilter(query, sameDayOnly) â new sameDayOnly parameter 28 * added; if true, skips items where isSameDay !== true. 29 * - gipRenderResults: "View All" footer link REMOVED per decision C. 30 * - Meta line in result item fixed: was incorrectly repeating p.size 31 * three times; now shows size once, matches categories-json.js data. 32 * - gipBindSearch(inputId, containerId, openClass, sameDayCheckId) 33 * â new sameDayCheckId parameter. Checkbox change event re-triggers 34 * search on its bound input if query length >= 2. 35 * - Document click-outside handler fixed: closest() guard now uses 36 * the actual wrapper selectors for each input context so it does 37 * not throw when called from panel search (which has no .gip-search 38 * ancestor). 39 * 40 * C. CORPORATE + ACCOUNT DROPDOWNS 41 * - New gipBindDropdown() helper wires the Corporate and Account 42 * dropdowns in the actions bar. Closes on outside click and Escape. 43 * Manages aria-expanded on the trigger button. 44 * 45 * D. MOBILE PANEL, MEGA MENU ARIA, ACCORDION 46 * - Carried over from new design IIFE, no logic changes. 47 * - Element IDs updated to match Default.cshtml: 48 * gipMobileClose â gipPanelClose 49 * gipMobileBackdrop â gipPanelBackdrop 50 * 51 * E. DEBOUNCED RESIZE 52 * - Carried over unchanged. 53 * 54 * Dependencies (must be loaded before this file, all defer): 55 * - categories-json.js (provides Categories[]) 56 * No jQuery, no jQuery UI, no jquery.countdown required. 57 */ 58 59(function () { 60 'use strict'; 61 62 63 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 64 A. COUNTDOWN 65 Reads server-supplied milliseconds from hidden input. 66 Computes absolute cutoff epoch once at execution time. 67 Updates every second via setInterval. 68 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 69 70 /** 71 * Detect current PST/PDT UTC offset. 72 * PST = UTC-8, PDT (daylight) = UTC-7. 73 * Uses Jan vs Jul offset comparison â no timezone string parsing needed. 74 * @returns {number} offset in hours (â8 or â7) 75 */ 76 function getPSTOffset() { 77 var jan = new Date(new Date().getFullYear(), 0, 1).getTimezoneOffset(); 78 var jul = new Date(new Date().getFullYear(), 6, 1).getTimezoneOffset(); 79 // If current offset is less than the max (i.e. we're in summer), we're in PDT (â7) 80 return new Date().getTimezoneOffset() < Math.max(jan, jul) ? -7 : -8; 81 } 82 83 /** 84 * Return the current moment expressed as a PST/PDT Date object. 85 * Converts local time â UTC â PST wall-clock time. 86 * @returns {Date} 87 */ 88 function getNowPST() { 89 var now = new Date(); 90 var utcMs = now.getTime() + now.getTimezoneOffset() * 60000; 91 return new Date(utcMs + getPSTOffset() * 3600000); 92 } 93
94 /** Zero-pad a number to 2 digits. */ 95 function pad(n) { return String(n).padStart(2, '0'); } 96 97 /* ââ Read server cutoff milliseconds once at script load time ââ */ 98 var cutoffEpoch = null; 99 (function () { 100 var el = document.getElementById('cutoff-time-miliseconds'); 101 if (!el) return; 102 var ms = parseInt(el.value, 10); 103 // ms is the cutoff's own absolute Unix epoch in milliseconds 104 // (HomeContent.GetCutoffTime() / MilliTimeStamp() â same value the old 105 // jquery.countdown widget consumed directly as its target date), not a 106 // duration relative to now. Do not add Date.now() to it. 107 if (!isNaN(ms) && ms > 0) { 108 cutoffEpoch = ms; 109 } 110 // If ms <= 0 the cutoff has already passed today; cutoffEpoch stays null 111 // and updateCountdown will render the expired state immediately. 112 })(); 113 114 function updateCountdown() { 115 var pill = document.getElementById('gipSameDayPill'); 116 var dot = document.getElementById('gipPillDot'); 117 var timer = document.getElementById('gipCountdownTime'); 118 if (!pill || !dot || !timer) return; 119 120 var nowPST = getNowPST(); 121 var day = nowPST.getDay(); // 0 = Sun, 6 = Sat 122 var isWeekday = day >= 1 && day <= 5; 123 124 /* ââ Weekend: same grayed-out/frozen treatment as a passed weekday cutoff ââ */ 125 if (!isWeekday) { 126 pill.classList.add('gip-pill--expired'); 127 dot.classList.add('gip-pill__dot--off'); 128 timer.classList.remove('gip-pill__time--urgent'); 129 timer.textContent = '00:00:00'; 130 return; 131 } 132 133 /* ââ Cutoff has passed / was not set: gray out but keep the HH:MM:SS 134 format frozen at 00:00:00 instead of swapping to a "closed" message ââ */ 135 if (cutoffEpoch === null || Date.now() >= cutoffEpoch) { 136 pill.classList.add('gip-pill--expired'); 137 dot.classList.add('gip-pill__dot--off'); 138 timer.classList.remove('gip-pill__time--urgent'); 139 timer.textContent = '00:00:00'; 140 return; 141 } 142 143 /* ââ Active state ââ */ 144 pill.classList.remove('gip-pill--expired'); 145 dot.classList.remove('gip-pill__dot--off'); 146 147 var secsLeft = Math.floor((cutoffEpoch - Date.now()) / 1000); 148 var h = Math.floor(secsLeft / 3600); 149 var m = Math.floor((secsLeft % 3600) / 60); 150 var s = secsLeft % 60; 151 timer.textContent = pad(h) + ':' + pad(m) + ':' + pad(s); 152 // Urgent styling: under 30 minutes remaining 153 timer.classList.toggle('gip-pill__time--urgent', secsLeft < 1800); 154 } 155 156 updateCountdown(); 157 setInterval(updateCountdown, 1000); 158 159 160 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 161 B. SEARCH ENGINE 162 Data source: Categories[] from categories-json.js. 163 Field mapping from categories-json.js schema: 164 label â p.name (product display name) 165 type â p.cat (category group label) 166 link â p.url (page href) 167 image â p.img (path; CDN prefix prepended in render) 168 StartingPrice â p.price (display string) 169 sizes â p.size (display string) 170 isSameDay â p.isSameDay (boolean filter flag) 171 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 172 173 var CDN_BASE = 'https://content.goldimageprinting.com/'; 174 175 /** 176 * Filter Categories[] by query string and optional same-day flag. 177 * Matches on product name only (not category type) - shows every matching 178 * product, no per-category or total cap. The results dropdown 179 * (.gip-search__results) already scrolls (max-height + overflow-y:auto), 180 * so a long match list is fine. 181 * @param {string} query â search term (must be >= 2 chars) 182 * @param {boolean} sameDayOnly â if true, only include isSameDay === true items 183 * @returns {Array} matched items mapped to {name, cat, url, img, price, size} 184 */ 185 function gipSearchFilter(query, sameDayOnly) { 186 var q = query.trim().toLowerCase(); 187 if (q.length < 2) return []; 188 189 var results = []; 190 191 // Categories is the global array from categories-json.js 192 if (typeof Categories === 'undefined' || !Array.isArray(Categories)) return []; 193
194 Categories.forEach(function (p) { 195 if (sameDayOnly && p.isSameDay !== true) return; 196 if (!p.label || !p.label.toLowerCase().includes(q)) return; 197 198 results.push({ 199 name : p.label || '', 200 cat : p.type || '', 201 url : '/' + (p.link || '').replace(/^\//, ''), 202 img : p.image ? CDN_BASE + p.image : '', 203 price : p.StartingPrice ? '$' + p.StartingPrice + '+' : '', 204 size : p.sizes || '', 205 isSameDay: p.isSameDay === true 206 }); 207 }); 208 209 return results; 210 } 211 212 /** 213 * Render search results into a container element. 214 * Results are grouped by category with a header row per group. 215 * "View All" footer is intentionally omitted per project decision C. 216 * @param {Array} results â from gipSearchFilter() 217 * @param {string} query â original query (for empty state message) 218 * @param {HTMLElement} container â results wrapper element 219 */ 220 function gipRenderResults(results, query, container) { 221 container.innerHTML = ''; 222 223 if (!results.length) { 224 var empty = document.createElement('div'); 225 empty.className = 'gip-search__results-empty'; 226 empty.textContent = 'No results for "' + query + '"'; 227 container.appendChild(empty); 228 return; 229 } 230 231 // Group results by category, preserving insertion order 232 var groups = {}; 233 var groupKeys = []; 234 results.forEach(function (p) { 235 if (!groups[p.cat]) { 236 groups[p.cat] = []; 237 groupKeys.push(p.cat); 238 } 239 groups[p.cat].push(p); 240 }); 241 242 groupKeys.forEach(function (cat) { 243 // Category header 244 var catEl = document.createElement('div'); 245 catEl.className = 'gip-search__results-cat'; 246 catEl.textContent = cat; 247 container.appendChild(catEl); 248 249 // Items 250 groups[cat].forEach(function (p) { 251 var a = document.createElement('a'); 252 a.className = 'gip-search__results-item'; 253 a.href = p.url; 254 a.setAttribute('role', 'option'); 255 256 // Thumb 257 var thumb = document.createElement('div'); 258 thumb.className = 'gip-search__results-thumb'; 259 if (p.img) { 260 var img = document.createElement('img'); 261 img.src = p.img; 262 img.alt = ''; 263 img.loading = 'lazy'; 264 img.width = 52; 265 img.height = 52; 266 thumb.appendChild(img); 267 } 268 269 // Info 270 var info = document.createElement('div'); 271 info.className = 'gip-search__results-info'; 272 273 var nameEl = document.createElement('div'); 274 nameEl.className = 'gip-search__results-name'; 275 nameEl.textContent = p.name; 276 277 var metaEl = document.createElement('div'); 278 metaEl.className = 'gip-search__results-meta'; 279 // Show size string once â original new-design bug (repeated p.size 3x) fixed here 280 metaEl.textContent = p.size ? 'Sizes: ' + p.size : ''; 281 282 info.appendChild(nameEl); 283 info.appendChild(metaEl); 284 285 // Price 286 var priceEl = document.createElement('div'); 287 priceEl.className = 'gip-search__results-price'; 288 priceEl.textContent = p.price || ''; 289 290 a.appendChild(thumb); 291 a.appendChild(info); 292 a.appendChild(priceEl); 293 container.appendChild(a); 294 }); 295 }); 296 // NOTE: "View all results" footer intentionally omitted (decision C). 297 } 298 299 /** 300 * Bind a search input to its results container. 301 * Debounces input at 180ms. Re-runs search when Same Day checkbox changes. 302 * 303 * @param {string} inputId â element ID of the <input> 304 * @param {string} containerId â element ID of the results container 305 * @param {string} openClass â CSS class to add when results are visible 306 * @param {string} sameDayCheckId â element ID of the same-day <input type="checkbox"> 307 */ 308 function gipBindSearch(inputId, containerId, openClass, sameDayCheckId) { 309 var input = document.getElementById(inputId); 310 var container = document.getElementById(containerId); 311 var checkbox = sameDayCheckId ? document.getElementById(sameDayCheckId) : null; 312 313 if (!input || !container) return; 314 315 var debounceTimer = null; 316 317 /** Run search with current input value and checkbox state. */ 318 function runSearch() { 319 var q = input.value.trim(); 320 var sameDayOnly = checkbox ? checkbox.checked : false; 321 322 if (q.length < 2) { 323 container.classList.remove(openClass); 324 container.innerHTML = ''; 325 return; 326 } 327 var results = gipSearchFilter(q, sameDayOnly); 328 gipRenderResults(results, q, container); 329 container.classList.add(openClass); 330 } 331 332 // Debounced input handler 333 input.addEventListener('input', function () { 334 clearTimeout(debounceTimer); 335 debounceTimer = setTimeout(runSearch, 180); 336 });
337 338 // Same Day checkbox â re-run immediately on change if query is active 339 if (checkbox) { 340 checkbox.addEventListener('change', function () { 341 if (input.value.trim().length >= 2) { 342 clearTimeout(debounceTimer); 343 runSearch(); 344 } 345 }); 346 } 347 348 // Escape: close results, clear input 349 input.addEventListener('keydown', function (e) { 350 if (e.key === 'Escape') { 351 container.classList.remove(openClass); 352 container.innerHTML = ''; 353 input.value = ''; 354 } 355 }); 356 } 357 358 // Bind all three search inputs with their respective Same Day checkbox IDs 359 gipBindSearch( 360 'gipDesktopSearch', 361 'gipDesktopResults', 362 'gip-search__results--open', 363 'gipDesktopSameDayCheck' 364 ); 365 gipBindSearch( 366 'gipMobileHeaderSearch', 367 'gipMobileHeaderResults', 368 'gip-search__results--open', 369 'gipMobileHeaderSameDayCheck' 370 ); 371 gipBindSearch( 372 'gipPanelSearch', 373 'gipPanelResults', 374 'gip-panel-search-results--open', 375 'gipPanelSameDayCheck' 376 ); 377 378 // Single consolidated outside-click handler for ALL search result dropdowns. 379 // Replaces three separate per-input document listeners â one listener, zero 380 // duplicate closures, correct scoping for every search context. 381 document.addEventListener('click', function (e) { 382 // If the click is inside any search wrapper, leave results open 383 if (e.target.closest('.gip-search, .gip-mobile-panel__search, .gip-mobile-search')) { 384 return; 385 } 386 // Outside all search wrappers â close every open result container 387 document.querySelectorAll( 388 '.gip-search__results--open, .gip-panel-search-results--open' 389 ).forEach(function (el) { 390 el.classList.remove('gip-search__results--open'); 391 el.classList.remove('gip-panel-search-results--open'); 392 }); 393 }, { passive: true }); 394 395 396 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 397 C. ACTION BAR DROPDOWNS 398 Corporate and Account dropdowns in .gip-actions. 399 Each has a trigger button and a .gip-account-dropdown__menu. 400 - Click trigger â toggle .show on menu, update aria-expanded 401 - Click outside â close all 402 - Escape key â close focused dropdown 403 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 404 405 /** 406 * Bind a simple toggle dropdown in the actions bar. 407 * @param {string} toggleId â ID of the <button> trigger 408 * @param {string} menuId â ID of the <ul> menu 409 */ 410 function gipBindDropdown(toggleId, menuId) { 411 var toggle = document.getElementById(toggleId); 412 var menu = document.getElementById(menuId); 413 if (!toggle || !menu) return; 414 415 toggle.addEventListener('click', function (e) { 416 e.stopPropagation(); 417 var isOpen = menu.classList.contains('show'); 418 419 // Close all other action-bar dropdowns first 420 document.querySelectorAll('.gip-account-dropdown__menu.show').forEach(function (m) { 421 m.classList.remove('show'); 422 var btn = document.getElementById(m.id.replace('Menu', 'Toggle')); 423 btn && btn.setAttribute('aria-expanded', 'false'); 424 }); 425 426 if (!isOpen) { 427 menu.classList.add('show'); 428 toggle.setAttribute('aria-expanded', 'true'); 429 } 430 }); 431 } 432 433 gipBindDropdown('gipCorporateToggle', 'gipCorporateMenu'); 434 gipBindDropdown('gipAccountToggle', 'gipAccountMenu'); // only present when signed in 435 436 // Close all action-bar dropdowns on outside click 437 document.addEventListener('click', function () { 438 document.querySelectorAll('.gip-account-dropdown__menu.show').forEach(function (m) { 439 m.classList.remove('show'); 440 var btn = document.getElementById(m.id.replace('Menu', 'Toggle')); 441 btn && btn.setAttribute('aria-expanded', 'false'); 442 });
443 }, { passive: true }); 444 445 // Escape closes the currently open action-bar dropdown 446 document.addEventListener('keydown', function (e) { 447 if (e.key !== 'Escape') return; 448 document.querySelectorAll('.gip-account-dropdown__menu.show').forEach(function (m) { 449 m.classList.remove('show'); 450 var btn = document.getElementById(m.id.replace('Menu', 'Toggle')); 451 if (btn) { 452 btn.setAttribute('aria-expanded', 'false'); 453 btn.focus(); // return focus to trigger 454 } 455 }); 456 }); 457 458 459 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 460 D. MOBILE PANEL 461 Opened by both #gipMobileToggle (â¤767px) and #gipHamburger (â¥768px). 462 Focus trapped inside drawer while open. 463 Escape key closes panel. 464 Backdrop click closes panel. 465 Element IDs updated to match Default.cshtml: 466 gipMobileClose â gipPanelClose 467 gipMobileBackdrop â gipPanelBackdrop 468 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 469 470 var mobileToggle = document.getElementById('gipMobileToggle'); 471 var hamburger = document.getElementById('gipHamburger'); 472 var mobilePanel = document.getElementById('gipMobilePanel'); 473 var panelClose = document.getElementById('gipPanelClose'); 474 var panelBackdrop = document.getElementById('gipPanelBackdrop'); 475 476 // Tracks which button last opened the panel so focus always returns to 477 // the correct opener (mobileToggle OR hamburger) regardless of how it 478 // was closed (X button, backdrop, Escape). 479 var lastPanelOpener = null; 480 481 // iOS Safari scroll-lock: save scroll position before fixing body, 482 // restore it on close so the page doesn't jump to top. 483 var savedScrollY = 0; 484 485 function openMobilePanel(openerEl) { 486 if (!mobilePanel) return; 487 lastPanelOpener = openerEl || null; 488 mobilePanel.classList.add('gip-mobile-panel--open'); 489 mobilePanel.setAttribute('aria-hidden', 'false'); 490 // Standard scroll-lock 491 document.body.style.overflow = 'hidden'; 492 // iOS Safari requires position:fixed to prevent background scroll. 493 // Save current scroll position first so we can restore it on close. 494 savedScrollY = window.scrollY || window.pageYOffset; 495 document.body.style.position = 'fixed'; 496 document.body.style.top = '-' + savedScrollY + 'px'; 497 document.body.style.width = '100%'; 498 if (openerEl) openerEl.setAttribute('aria-expanded', 'true'); 499 // Defer focus to close button until drawer slide animation completes 500 setTimeout(function () { panelClose && panelClose.focus(); }, 300); 501 } 502 503 function closeMobilePanel() { 504 if (!mobilePanel) return; 505 mobilePanel.classList.remove('gip-mobile-panel--open'); 506 mobilePanel.setAttribute('aria-hidden', 'true'); 507 // Restore body scroll â must restore scrollY AFTER clearing position:fixed 508 document.body.style.overflow = ''; 509 document.body.style.position = ''; 510 document.body.style.top = ''; 511 document.body.style.width = ''; 512 window.scrollTo(0, savedScrollY); 513 savedScrollY = 0; 514 // Reset aria-expanded on both openers 515 [mobileToggle, hamburger].forEach(function (el) { 516 el && el.setAttribute('aria-expanded', 'false'); 517 }); 518 // Return focus to the button that originally opened the panel 519 if (lastPanelOpener) lastPanelOpener.focus(); 520 lastPanelOpener = null; 521 } 522 523 mobileToggle && mobileToggle.addEventListener('click', function () { 524 openMobilePanel(mobileToggle); 525 }); 526 527 hamburger && hamburger.addEventListener('click', function () { 528 openMobilePanel(hamburger); 529 }); 530 531 panelClose && panelClose.addEventListener('click', function () { 532 closeDrilldown(); 533 closeMobilePanel(); 534 }); 535 536 panelBackdrop && panelBackdrop.addEventListener('click', function () { 537 closeDrilldown(); 538 closeMobilePanel(); 539 }); 540 541 // Escape closes panel 542 document.addEventListener('keydown', function (e) { 543 if (e.key === 'Escape' && 544 mobilePanel && 545 mobilePanel.classList.contains('gip-mobile-panel--open')) { 546 closeDrilldown(); 547 closeMobilePanel(); 548 } 549 }); 550 551 // Focus trap inside drawer 552 mobilePanel && mobilePanel.addEventListener('keydown', function (e) { 553 if (e.key !== 'Tab') return; 554 var focusable = mobilePanel.querySelectorAll(
555 'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])' 556 ); 557 if (!focusable.length) return; 558 var first = focusable[0]; 559 var last = focusable[focusable.length - 1]; 560 if (e.shiftKey) { 561 if (document.activeElement === first) { e.preventDefault(); last.focus(); } 562 } else { 563 if (document.activeElement === last) { e.preventDefault(); first.focus(); } 564 } 565 }); 566 567 568 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 569 E. MEGA DROPDOWN â ARIA STATE SYNC 570 CSS :hover handles show/hide. JS keeps aria-expanded in sync 571 so assistive technology knows the state. 572 Keyboard: Enter/Space opens, Escape closes, Tab stays inside 573 via :focus-within in CSS. 574 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 575 576 var navItems = document.querySelectorAll('.gip-nav__item'); 577 navItems.forEach(function (item) { 578 var trigger = item.querySelector('.gip-nav__trigger'); 579 if (!trigger) return; 580 581 // Mouse: sync aria-expanded with hover 582 item.addEventListener('mouseenter', function () { 583 trigger.setAttribute('aria-expanded', 'true'); 584 }); 585 item.addEventListener('mouseleave', function () { 586 // Only reset if not keyboard-forced open (style.display === 'block') 587 var mega = item.querySelector('.gip-mega'); 588 if (!mega || mega.style.display !== 'block') { 589 trigger.setAttribute('aria-expanded', 'false'); 590 } 591 }); 592 593 // Keyboard: Enter/Space toggles mega open/closed 594 trigger.addEventListener('keydown', function (e) { 595 if (e.key === 'Enter' || e.key === ' ') { 596 e.preventDefault(); 597 var mega = item.querySelector('.gip-mega'); 598 if (!mega) return; 599 var isOpen = mega.style.display === 'block'; 600 mega.style.display = isOpen ? '' : 'block'; 601 trigger.setAttribute('aria-expanded', String(!isOpen)); 602 if (!isOpen) { 603 var firstLink = mega.querySelector('a[href], button'); 604 if (firstLink) firstLink.focus(); 605 } 606 } 607 if (e.key === 'Escape') { 608 var mega = item.querySelector('.gip-mega'); 609 if (mega) mega.style.display = ''; 610 trigger.setAttribute('aria-expanded', 'false'); 611 trigger.focus(); 612 } 613 }); 614 }); 615 616 // Close any keyboard-forced-open mega on outside click 617 document.addEventListener('click', function (e) { 618 navItems.forEach(function (item) { 619 var mega = item.querySelector('.gip-mega'); 620 if (mega && mega.style.display === 'block' && !item.contains(e.target)) { 621 mega.style.display = ''; 622 var trigger = item.querySelector('.gip-nav__trigger'); 623 trigger && trigger.setAttribute('aria-expanded', 'false'); 624 } 625 }); 626 }, { passive: true }); 627 628 629 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 630 F. MOBILE ACCORDION â DRILL-DOWN NAVIGATION 631 Vistaprint pattern (verified live 2026-07-17/18): tapping a category 632 replaces the category list with a dedicated screen for just that
633 category (back arrow + title), not an inline accordion expand. 634 Only one screen visible at a time. .gip-mobile-item--active marks 635 the single item shown; .drilldown-active on .gip-mobile-panel__body 636 hides everything else (search, other items, Quick Links/Account). 637 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 638 639 var mobileItems = document.querySelectorAll('.gip-mobile-item'); 640 var mobilePanelBody = document.querySelector('.gip-mobile-panel__body'); 641 // .gip-mobile-panel__body itself has no overflow - the actual scrolling 642 // element (overflow-y:auto) is its parent .gip-mobile-panel__drawer, so 643 // scroll-reset has to target that, not .body (fixed 2026-07-18 - resetting 644 // .body's scrollTop was a no-op, so switching categories left the drawer 645 // wherever it had last been scrolled instead of snapping back to top). 646 var mobilePanelDrawer = document.querySelector('.gip-mobile-panel__drawer'); 647 var drilldownBack = document.getElementById('gipDrilldownBack'); 648 var drilldownTitle = document.getElementById('gipDrilldownTitle'); 649 var drilldownViewAll = document.getElementById('gipDrilldownViewAll'); 650 651 function closeDrilldown() { 652 mobileItems.forEach(function (other) { 653 other.classList.remove('gip-mobile-item--open', 'gip-mobile-item--active'); 654 var otherTrigger = other.querySelector('.gip-mobile-item__trigger'); 655 otherTrigger && otherTrigger.setAttribute('aria-expanded', 'false'); 656 }); 657 mobilePanelBody && mobilePanelBody.classList.remove('drilldown-active'); 658 if (mobilePanelDrawer) mobilePanelDrawer.scrollTop = 0; 659 } 660 661 mobileItems.forEach(function (item) { 662 var trigger = item.querySelector('.gip-mobile-item__trigger'); 663 if (!trigger) return; 664 665 trigger.addEventListener('click', function () { 666 // Collapse all items first, then open only this one â one screen 667 // visible at a time, matching how the drill-down back button works. 668 mobileItems.forEach(function (other) { 669 other.classList.remove('gip-mobile-item--open', 'gip-mobile-item--active'); 670 var otherTrigger = other.querySelector('.gip-mobile-item__trigger'); 671 otherTrigger && otherTrigger.setAttribute('aria-expanded', 'false'); 672 }); 673 674 item.classList.add('gip-mobile-item--open', 'gip-mobile-item--active'); 675 trigger.setAttribute('aria-expanded', 'true'); 676 677 if (mobilePanelBody) mobilePanelBody.classList.add('drilldown-active'); 678 if (drilldownTitle) { 679 var label = trigger.querySelector('.gip-mobile-item__label'); 680 drilldownTitle.textContent = label ? label.textContent.trim() : ''; 681 } 682 // "View All X" now lives on the drill-down header row, not its own 683 // promoted band per category - the per-category .gip-mobile-item__sub-viewall 684 // anchor stays in the DOM (hidden) as the source of truth for href/label, 685 // copied into the shared link here each time a category is opened. 686 if (drilldownViewAll) { 687 var viewAllSource = item.querySelector('.gip-mobile-item__sub-viewall'); 688 if (viewAllSource) { 689 drilldownViewAll.href = viewAllSource.getAttribute('href'); 690 // Just "View All", not the full "View All <Category>" text - the category 691 // name is already shown in the title right next to it, and the full text 692 // (nowrap, never shrinks) was crushing long titles down to a truncated 693 // "Large For..." to make room for itself. 694 drilldownViewAll.textContent = 'View All'; 695 drilldownViewAll.title = viewAllSource.textContent.trim(); 696 drilldownViewAll.style.display = ''; 697 } else { 698 drilldownViewAll.style.display = 'none'; 699 } 700 } 701 if (mobilePanelDrawer) mobilePanelDrawer.scrollTop = 0; 702 }); 703 }); 704 705 drilldownBack && drilldownBack.addEventListener('click', closeDrilldown); 706 707 // Sub-groups and long flat lists (Business Cards, Same Day) render fully 708 // expanded now (UPrinting reference, 2026-07-18) - no per-group collapse 709 // toggle and no "show more" truncation, so no JS is needed for either. 710 711 712 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 713 G. DEBOUNCED RESIZE LISTENER 714 Passive, debounced at 150ms. 715 Also re-measures mega top offset on resize. 716 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 717 718 /** 719 * Measure the bottom edge of the sticky .gip-header element and apply 720 * it as an inline top value on all .gip-mega elements. 721 * 722 * Why: .gip-util is NOT sticky â it scrolls away. If we used a CSS calc 723 * based on all three row heights (util + top + nav = 152px), the mega 724 * would appear 38px too low once the user has scrolled past the util bar. 725 * getBoundingClientRect().bottom always returns the correct viewport Y 726 * of the sticky header's bottom edge regardless of scroll position. 727 */ 728 function measureMegaTop() { 729 var header = document.querySelector('.gip-header'); 730 if (!header) return; 731 var bottom = header.getBoundingClientRect().bottom; 732 document.querySelectorAll('.gip-mega').forEach(function (mega) { 733 mega.style.top = bottom + 'px'; 734 });
735 } 736 737 // Set on load (deferred, so DOM is ready) 738 measureMegaTop(); 739 740 var resizeTimer; 741 window.addEventListener('resize', function () { 742 clearTimeout(resizeTimer); 743 resizeTimer = setTimeout(function () { 744 measureMegaTop(); 745 }, 150); 746 }, { passive: true }); 747 748 // Re-measure on scroll because sticky header height can change 749 // (e.g. mobile search row visibility change at resize crossing 768px) 750 window.addEventListener('scroll', function () { 751 measureMegaTop(); 752 }, { passive: true }); 753 754 755})();
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.