1/* eslint-env browser */ 2/** 3 * Landing page header (Claude): the glass bar after resend.com. 4 * 5 * Scroll: past a few pixels the bar takes its tint and lit hairline. Menus: 6 * two triggers share one panel under the bar; hovering (with a short intent 7 * delay) or clicking a trigger opens it, centered under that trigger and 8 * sized to the menu it shows, and moving to the other trigger slides the 9 * content sideways while the panel resizes. Leaving the bar and panel, a 10 * click elsewhere, or Escape closes it. Phones get a full-screen menu with 11 * drill-down views for the two menus. Reduced motion shortens the 12 * transitions through CSS; nothing here depends on their duration. 13 */ 14(() => { 15 'use strict'; 16 17 const header = document.querySelector('[data-sahc-nav]'); 18 19 if (!header) { 20 return; 21 } 22 23 const inner = header.querySelector('.sahc-nav__inner'); 24 const popup = header.querySelector('[data-sahc-nav-popup]'); 25 const triggers = Array.from( 26 header.querySelectorAll('[data-sahc-nav-trigger]') 27 ); 28 const panels = Array.from(header.querySelectorAll('[data-sahc-nav-panel]')); 29 const plain = Array.from(header.querySelectorAll('[data-sahc-nav-plain]')); 30 const burger = header.querySelector('[data-sahc-nav-burger]'); 31 const mobile = header.querySelector('[data-sahc-nav-mobile]'); 32 const desktop = window.matchMedia('(min-width: 1024px)'); 33 const order = triggers.map((t) => t.dataset.sahcNavTrigger); 34 const SHIFT = 40; 35 36 let openKey = null; 37 let openTimer = 0; 38 let closeTimer = 0; 39 let hideTimer = 0; 40 let scrollFrame = 0; 41 42 // Scroll: the bar slides away as the page scrolls down and comes back on 43 // the first scroll up, or near the top. Never while the phone menu is 44 // open. An open popup goes with it. 45 let lastY = window.scrollY; 46 let hidden = false; 47 function setHidden(on) { 48 if (hidden === on) { 49 return; 50 } 51 hidden = on; 52 header.classList.toggle('is-hidden', on); 53 // Pages with their own sticky rails read this to take the bar's place. 54 document.documentElement.classList.toggle('sahc-nav-hidden', on); 55 if (on && openKey !== null) { 56 hide(); 57 } 58 } 59 // The careers filter bar and the article's contents bar take turns with 60 // the bar too, and need to know when they have stuck (the model pages' 61 // tabs work that out themselves). 62 const turnBars = Array.from( 63 document.querySelectorAll( 64 '.samx-cbar, .single-articles .sa-article-body > .sa-article-body__toc.is-position-sticky' 65 ) 66 ); 67 function markStuck() { 68 turnBars.forEach((bar) => { 69 const top = parseFloat(window.getComputedStyle(bar).top) || 0; 70 bar.classList.toggle( 71 'is-stuck', 72 bar.getBoundingClientRect().top <= top + 1 73 ); 74 }); 75 } 76 function onScroll() { 77 if (scrollFrame) { 78 return; 79 } 80 scrollFrame = window.requestAnimationFrame(() => { 81 scrollFrame = 0; 82 const y = window.scrollY; 83 header.classList.toggle('is-scrolled', y > 6); 84 markStuck(); 85 if (header.classList.contains('is-menu-open')) { 86 lastY = y; 87 return; 88 } 89 const delta = y - lastY; 90 // A page can ask the bar to stay away for a stretch (the landing 91 // page's folder does while it holds the screen). 92 if ( 93 y > 96 && 94 document.documentElement.classList.contains('sahc-nav-quiet') 95 ) { 96 setHidden(true); 97 lastY = y; 98 return; 99 } 100 if (y <= 8) { 101 setHidden(false); 102 } else if (delta > 6 && y > 96) { 103 setHidden(true); 104 } else if (delta < -6) { 105 setHidden(false); 106 } 107 lastY = y; 108 }); 109 } 110 window.addEventListener('scroll', onScroll, { passive: true }); 111 markStuck(); 112 113 // On the landing page the bar arrives held above the screen (the pattern 114 // sends it so) and comes in once the hero says its entrance has settled, 115 // or after a ceiling if that word never comes. 116 if (header.classList.contains('is-held')) { 117 let released = false; 118 const release = () => { 119 if (released) { 120 return; 121 } 122 released = true; 123 header.classList.add('is-arriving'); 124 header.classList.remove('is-held'); 125 window.setTimeout( 126 () => header.classList.remove('is-arriving'), 127 1000 128 ); 129 }; 130 document.addEventListener('sahc:hero-settled', release, { once: true }); 131 window.setTimeout(release, 5200); 132 if (!document.querySelector('[data-sahc-v2]')) { 133 release(); 134 } 135 } 136 onScroll(); 137 138 const panelFor = (key) => 139 panels.find((p) => p.dataset.sahcNavPanel === key) || null; 140 const triggerFor = (key) => 141 triggers.find((t) => t.dataset.sahcNavTrigger === key) || null; 142 143 // Centers the panel under its trigger, kept inside the bar's content 144 // width, and sizes it to the menu it shows. 145 function place(key) { 146 const trigger = triggerFor(key);
147 const panel = panelFor(key); 148 if (!trigger || !panel || !popup || !inner) { 149 return; 150 } 151 const w = panel.offsetWidth; 152 const h = panel.offsetHeight; 153 const hr = header.getBoundingClientRect(); 154 const ir = inner.getBoundingClientRect(); 155 const tr = trigger.getBoundingClientRect(); 156 let x = tr.left + tr.width / 2 - hr.left; 157 const min = ir.left - hr.left + 8 + w / 2; 158 const max = ir.right - hr.left - 8 - w / 2; 159 x = Math.max(min, Math.min(max, x)); 160 popup.style.setProperty('--sahc-pop-x', x.toFixed(1) + 'px'); 161 // The pointer sits under the trigger's centre even when the tray 162 // itself had to shift to stay inside the bar. 163 const caret = tr.left + tr.width / 2 - hr.left - (x - w / 2); 164 popup.style.setProperty('--sahc-caret-x', caret.toFixed(1) + 'px'); 165 popup.style.width = w + 'px'; 166 popup.style.height = h + 'px'; 167 } 168 169 // Parks the incoming panel to one side without a transition so its move 170 // to rest reads as travel from the trigger's direction. 171 function prime(panel, from) { 172 panel.style.transition = 'none'; 173 panel.style.transform = 'translate3d(' + from + 'px,0,0)'; 174 panel.getBoundingClientRect(); 175 panel.style.transition = ''; 176 panel.style.transform = ''; 177 } 178 179 function show(key) { 180 window.clearTimeout(closeTimer); 181 window.clearTimeout(hideTimer); 182 if (!popup || openKey === key) { 183 return; 184 } 185 const previous = openKey; 186 openKey = key; 187 const forward = 188 previous === null || order.indexOf(key) > order.indexOf(previous); 189 190 popup.hidden = false; 191 popup.inert = false; 192 panels.forEach((panel) => { 193 const on = panel.dataset.sahcNavPanel === key; 194 if (on && previous !== null) { 195 prime(panel, forward ? SHIFT : -SHIFT); 196 } else if (panel.dataset.sahcNavPanel === previous) { 197 panel.style.setProperty( 198 '--sahc-out', 199 (forward ? -SHIFT : SHIFT) + 'px' 200 ); 201 } 202 panel.classList.toggle('is-active', on); 203 }); 204 triggers.forEach((t) => { 205 t.setAttribute( 206 'aria-expanded', 207 t.dataset.sahcNavTrigger === key ? 'true' : 'false' 208 ); 209 }); 210 place(key); 211 if (previous === null) { 212 // Start from the closed style, then open on the next frame. 213 popup.getBoundingClientRect(); 214 window.requestAnimationFrame(() => { 215 if (openKey) { 216 popup.classList.add('is-open'); 217 header.classList.add('has-popup'); 218 } 219 }); 220 } 221 } 222 223 function hide() { 224 window.clearTimeout(openTimer); 225 if (!popup || openKey === null) { 226 return; 227 } 228 openKey = null; 229 triggers.forEach((t) => t.setAttribute('aria-expanded', 'false')); 230 popup.classList.remove('is-open'); 231 header.classList.remove('has-popup'); 232 popup.inert = true; 233 hideTimer = window.setTimeout(() => { 234 if (openKey === null) { 235 popup.hidden = true; 236 panels.forEach((panel) => panel.classList.remove('is-active')); 237 } 238 }, 400); 239 } 240 241 function scheduleClose(delay) { 242 window.clearTimeout(closeTimer); 243 closeTimer = window.setTimeout(hide, delay); 244 } 245 246 triggers.forEach((trigger) => { 247 const key = trigger.dataset.sahcNavTrigger; 248 trigger.addEventListener('pointerenter', (event) => { 249 if (event.pointerType === 'touch' || !desktop.matches) { 250 return; 251 } 252 window.clearTimeout(closeTimer); 253 window.clearTimeout(openTimer); 254 openTimer = window.setTimeout(() => show(key), 60); 255 }); 256 trigger.addEventListener('pointerleave', () => { 257 window.clearTimeout(openTimer); 258 }); 259 trigger.addEventListener('click', () => { 260 window.clearTimeout(openTimer); 261 if (openKey === key) { 262 hide(); 263 } else { 264 show(key); 265 } 266 }); 267 trigger.addEventListener('keydown', (event) => { 268 if (event.key === 'ArrowDown') { 269 event.preventDefault(); 270 show(key); 271 const panel = panelFor(key); 272 const first = panel && panel.querySelector('a'); 273 if (first) { 274 first.focus(); 275 } 276 } 277 }); 278 }); 279 280 // Hovering the plain links or the actions closes the menu; leaving the 281 // bar and its panel closes it after a short grace. 282 plain.forEach((el) => { 283 el.addEventListener('pointerenter', () => { 284 if (openKey !== null) { 285 scheduleClose(120); 286 } 287 }); 288 }); 289 header.addEventListener('pointerleave', () => { 290 if (openKey !== null) { 291 scheduleClose(160); 292 } 293 }); 294 header.addEventListener('pointerenter', () => { 295 window.clearTimeout(closeTimer); 296 }); 297 document.addEventListener('pointerdown', (event) => { 298 if (openKey !== null && !header.contains(event.target)) { 299 hide(); 300 } 301 }); 302 header.addEventListener('focusout', (event) => { 303 if ( 304 openKey !== null && 305 event.relatedTarget && 306 !header.contains(event.relatedTarget) 307 ) { 308 hide(); 309 } 310 }); 311 312 // Phones: the burger opens a full-screen menu with drill-down views. 313 function setMobile(open) { 314 if (!mobile || !burger) { 315 return; 316 } 317 if (open) { 318 setHidden(false); 319 }
320 header.classList.toggle('is-menu-open', open); 321 mobile.hidden = !open; 322 burger.setAttribute('aria-expanded', open ? 'true' : 'false'); 323 burger.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); 324 document.documentElement.classList.toggle('sahc-nav-lock', open); 325 if (!open) { 326 setView('root'); 327 } 328 } 329 330 function setView(key) { 331 if (!mobile) { 332 return; 333 } 334 mobile.dataset.view = key; 335 mobile.querySelectorAll('[data-sahc-nav-view]').forEach((view) => { 336 view.classList.toggle( 337 'is-active', 338 view.dataset.sahcNavView === key 339 ); 340 }); 341 mobile.scrollTop = 0; 342 } 343 344 if (burger && mobile) { 345 burger.addEventListener('click', () => 346 setMobile(!header.classList.contains('is-menu-open')) 347 ); 348 mobile 349 .querySelectorAll('[data-sahc-nav-mobile-trigger]') 350 .forEach((b) => 351 b.addEventListener('click', () => 352 setView(b.dataset.sahcNavMobileTrigger) 353 ) 354 ); 355 mobile 356 .querySelectorAll('[data-sahc-nav-back]') 357 .forEach((b) => b.addEventListener('click', () => setView('root'))); 358 setView('root'); 359 } 360 361 document.addEventListener('keydown', (event) => { 362 if (event.key !== 'Escape') { 363 return; 364 } 365 if (openKey !== null) { 366 const trigger = triggerFor(openKey); 367 hide(); 368 if (trigger) { 369 trigger.focus(); 370 } 371 } 372 if (header.classList.contains('is-menu-open')) { 373 setMobile(false); 374 burger.focus(); 375 } 376 }); 377 378 let resizeFrame = 0; 379 window.addEventListener( 380 'resize', 381 () => { 382 if (resizeFrame) { 383 return; 384 } 385 resizeFrame = window.requestAnimationFrame(() => { 386 resizeFrame = 0; 387 if (openKey !== null) { 388 place(openKey); 389 } 390 if ( 391 desktop.matches && 392 header.classList.contains('is-menu-open') 393 ) { 394 setMobile(false); 395 } 396 }); 397 }, 398 { passive: true } 399 ); 400 desktop.addEventListener('change', () => { 401 hide(); 402 }); 403 404 if (popup) { 405 popup.inert = true; 406 } 407})();
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.