1// Header V2 - User menu dropdown functionality 2// ONLINE-41719: Keyboard accessibility (Tab, Escape, Arrow keys) 3 4(function() { 5 'use strict'; 6 7 const HEADER_GUTTER = 8; 8 9 const header = document.getElementById('header'); 10 11 if (!header) { 12 return; 13 } 14 15 function applyStickyHeaderOffset() { 16 const styles = globalThis.getComputedStyle(header); 17 const overlaysContent = styles.position === 'fixed' || styles.position === 'sticky'; 18 19 const offset = (overlaysContent && styles.display !== 'none' && styles.visibility !== 'hidden') 20 ? Math.ceil(header.getBoundingClientRect().height) + HEADER_GUTTER 21 : 0; 22 23 document.documentElement.style.setProperty('scroll-padding-top', offset + 'px'); 24 } 25 26 applyStickyHeaderOffset(); 27 28 if (globalThis.ResizeObserver) { 29 new globalThis.ResizeObserver(applyStickyHeaderOffset).observe(header); 30 } else { 31 globalThis.addEventListener('resize', applyStickyHeaderOffset); 32 globalThis.addEventListener('orientationchange', applyStickyHeaderOffset); 33 } 34})(); 35 36(function() { 37 'use strict'; 38 39 const userMenuTrigger = document.getElementById('user-menu-trigger'); 40 const userMenuDropdown = document.getElementById('user-menu-dropdown'); 41 42 if (!userMenuTrigger || !userMenuDropdown) { 43 return; 44 } 45 46 // Prevent double initialization if script loads twice 47 if (userMenuTrigger.dataset.headerV2Initialized) { 48 return; 49 } 50 userMenuTrigger.dataset.headerV2Initialized = 'true'; 51 52 const VIEWPORT_GUTTER = 16; 53 const MIN_MENU_HEIGHT = 120; 54 const DEFER_TO_NEXT_TICK = 0; // Allows native focus move to complete before collapsing 55 56 // ONLINE-41717: the trigger's accessible name must keep the visible user name (WCAG 2.5.3 57 // Label in Name) and track the expanded state. The rendered label is the collapsed wording, 58 // so the name is derived from it rather than rebuilt from the display name. 59 const collapsedMenuLabel = userMenuTrigger.dataset.collapsedLabel || ''; 60 const expandedMenuLabel = collapsedMenuLabel.replace(' to open ', ' to close '); 61 62 function applyTriggerLabel(expanded) { 63 if (collapsedMenuLabel) { 64 userMenuTrigger.setAttribute('aria-label', expanded ? expandedMenuLabel : collapsedMenuLabel); 65 } 66 } 67 68 // ONLINE-41956: Dynamic max-height for small viewports. Static CSS calc() doesn't account 69 // for expanded mobile panels, so trailing items paint off-screen. This measures actual space. 70 function applyAvailableHeight() { 71 if (!isOpen()) { 72 return; 73 } 74 75 // Measure from the untouched position so repeated calls do not compound. 76 userMenuDropdown.style.maxHeight = ''; 77 userMenuDropdown.style.marginTop = ''; 78 79 if (globalThis.getComputedStyle(userMenuDropdown).position === 'static') { 80 return; 81 } 82 83 const dropdownTop = userMenuDropdown.getBoundingClientRect().top; 84 let available = globalThis.innerHeight - dropdownTop - VIEWPORT_GUTTER; 85 86 // Extremely short viewports (landscape phone, on-screen keyboard open) leave less room 87 // below the trigger than MIN_MENU_HEIGHT. Pull the menu up over the trigger so it still 88 // fits, capped so its top never leaves the viewport. margin-top is used instead of 89 // transform because the `dropdown-appear` animation owns the transform property. 90 if (available < MIN_MENU_HEIGHT) { 91 const shift = Math.min(MIN_MENU_HEIGHT - available, Math.max(dropdownTop - VIEWPORT_GUTTER, 0)); 92 93 if (shift > 0) { 94 const baseMarginTop = Number.parseFloat(globalThis.getComputedStyle(userMenuDropdown).marginTop) || 0; 95 96 userMenuDropdown.style.marginTop = (baseMarginTop - shift) + 'px'; 97 available += shift; 98 } 99 } 100 101 userMenuDropdown.style.maxHeight = Math.max(available, MIN_MENU_HEIGHT) + 'px'; 102 } 103 104 function isOpen() { 105 return userMenuTrigger.getAttribute('aria-expanded') === 'true'; 106 } 107 108 // ONLINE-41719: re-query items on each keypress (Vetted and SavedJobs are conditionally rendered). 109 function getItems() { 110 return Array.prototype.slice.call(userMenuDropdown.querySelectorAll('.dropdown-item')) 111 .filter(isFocusable); 112 } 113 114 function isFocusable(item) { 115 return globalThis.getComputedStyle(item).display !== 'none'; 116 } 117 118 function focusItem(items, index) { 119 if (items.length) { 120 items[(index + items.length) % items.length].focus(); 121 } 122 } 123 124 function preventAndStop(e) { 125 e.preventDefault(); 126 e.stopPropagation(); 127 } 128 129 function handleArrowDown(items, currentIndex) { 130 if (!isOpen()) { 131 openDropdown(); 132 } 133 focusItem(items, currentIndex + 1); 134 } 135 136 function handleArrowUp(items, currentIndex) { 137 if (!isOpen()) { 138 openDropdown(); 139 focusItem(items, -1); 140 } else { 141 focusItem(items, currentIndex - 1); 142 } 143 } 144 145 function handleHome(items) { 146 if (isOpen()) { 147 focusItem(items, 0); 148 } 149 } 150 151 function handleEnd(items) { 152 if (isOpen()) { 153 focusItem(items, -1); 154 } 155 } 156 157 function handleTab(items, currentIndex, shiftKey) { 158 if (isOpen() && (shiftKey ? currentIndex === 0 : currentIndex === items.length - 1)) { 159 setTimeout(closeDropdown, DEFER_TO_NEXT_TICK); 160 } 161 } 162 163 function openDropdown() { 164 userMenuTrigger.setAttribute('aria-expanded', 'true'); 165 applyTriggerLabel(true); 166 userMenuDropdown.setAttribute('aria-hidden', 'false'); 167 userMenuDropdown.style.display = 'block'; 168 // iOS Safari: reading offsetHeight forces a synchronous reflow so the browser 169 // promotes the dropdown to its own compositing layer before the next paint.
170 // Without this, the dropdown can be invisible on physical iPhones when the 171 // sticky header's GPU layer doesn't yet include the absolutely-positioned overflow. 172 userMenuDropdown.offsetHeight; // eslint-disable-line no-unused-expressions 173 applyAvailableHeight(); 174 } 175 176 function closeDropdown() { 177 userMenuTrigger.setAttribute('aria-expanded', 'false'); 178 applyTriggerLabel(false); 179 userMenuDropdown.setAttribute('aria-hidden', 'true'); 180 userMenuDropdown.style.display = 'none'; 181 userMenuDropdown.style.maxHeight = ''; 182 userMenuDropdown.style.marginTop = ''; 183 } 184 185 // iOS Safari: touchstart can fire a document-level click before the trigger's own click. 186 // Track when the trigger was last touched so the outside-dismiss handler can ignore 187 // events that arrive within the same gesture (16 ms covers a single event-loop tick). 188 var lastTriggerInteractionTime = 0; 189 var touchHandled = false; 190 191 userMenuTrigger.addEventListener('touchstart', function() { 192 lastTriggerInteractionTime = Date.now(); 193 touchHandled = false; 194 }, { passive: true }); 195 196 // ONLINE-42934: iPhone menu not opening â touchend event handler added to ensure 197 // touch gestures properly toggle the dropdown on iOS devices. The click event 198 // alone was insufficient on some iOS versions (Safari/Chrome on iPhone 15). 199 userMenuTrigger.addEventListener('touchend', function(e) { 200 if (touchHandled) { 201 return; 202 } 203 touchHandled = true; 204 e.preventDefault(); 205 e.stopPropagation(); 206 lastTriggerInteractionTime = Date.now(); 207 208 if (isOpen()) { 209 closeDropdown(); 210 } else { 211 openDropdown(); 212 } 213 }); 214 215 userMenuTrigger.addEventListener('click', function(e) { 216 // Prevent click from firing after touchend has already handled it 217 if (touchHandled) { 218 touchHandled = false; 219 return; 220 } 221 222 e.stopPropagation(); 223 lastTriggerInteractionTime = Date.now(); 224 225 if (isOpen()) { 226 closeDropdown(); 227 } else { 228 openDropdown(); 229 } 230 }); 231 232 // Keyboard navigation: bring the focused item into view. `block: 'nearest'` scrolls the 233 // menu (or the page, when the menu is not the scrolling box) by the minimum amount and 234 // does nothing when the item is already visible, so it never fights the sticky header. 235 userMenuDropdown.addEventListener('focusin', function(e) { 236 const item = e.target.closest ? e.target.closest('.dropdown-item') : null; 237 238 if (item) { 239 item.scrollIntoView({ block: 'nearest' }); 240 } 241 }); 242 243 // ONLINE-41719 #2: Escape on trigger or any item closes menu and returns focus. 244 // Listener on container (not dropdown) because trigger sits outside #user-menu-dropdown. 245 const userMenuContainer = userMenuTrigger.parentNode; 246 247 userMenuContainer.addEventListener('keydown', function(e) { 248 if (!isOpen()) { 249 return; 250 } 251 252 if (e.key === 'Escape' || e.key === 'Esc') { 253 preventAndStop(e); 254 userMenuTrigger.focus(); 255 closeDropdown(); 256 } 257 }); 258 259 // ONLINE-41719 #3: arrow-key navigation. Registered with capture=true so it runs 260 // BEFORE bootstrap-dropdown.js, which calls stopPropagation() on arrow keys but 261 // then fails to find items (it expects `[role=menu] li a`, we have direct <a> children). 262 userMenuContainer.addEventListener('keydown', function(e) { 263 const items = getItems(); 264 if (!items.length) { 265 return; 266 } 267 268 const currentIndex = items.indexOf(document.activeElement); 269 270 switch (e.key) { 271 case 'ArrowDown': 272 case 'Down': 273 preventAndStop(e); 274 handleArrowDown(items, currentIndex); 275 break; 276 277 case 'ArrowUp': 278 case 'Up': 279 preventAndStop(e); 280 handleArrowUp(items, currentIndex); 281 break; 282 283 case 'Home': 284 if (isOpen()) { 285 preventAndStop(e); 286 handleHome(items); 287 } 288 break; 289 290 case 'End': 291 if (isOpen()) { 292 preventAndStop(e); 293 handleEnd(items); 294 } 295 break; 296 297 case 'Tab': 298 handleTab(items, currentIndex, e.shiftKey); 299 break; 300 } 301 }, true); 302 303 // The available space changes with rotation, browser zoom and on-screen keyboards. 304 globalThis.addEventListener('resize', applyAvailableHeight); 305 globalThis.addEventListener('orientationchange', applyAvailableHeight); 306
307 if (globalThis.visualViewport) { 308 globalThis.visualViewport.addEventListener('resize', applyAvailableHeight); 309 } 310 311 // ONLINE-42934: iOS dropdown link navigation fix. 312 // On iOS, touch events can interfere with link clicks. We need to explicitly 313 // handle touchend on dropdown items and trigger navigation programmatically. 314 var dropdownTouchStartTarget = null; 315 var dropdownTouchStartTime = 0; 316 317 userMenuDropdown.addEventListener('touchstart', function(e) { 318 // Track which item was touched 319 dropdownTouchStartTarget = e.target.closest ? e.target.closest('.dropdown-item') : null; 320 dropdownTouchStartTime = Date.now(); 321 console.log('ONLINE-42934 touchstart:', dropdownTouchStartTarget ? dropdownTouchStartTarget.getAttribute('href') : 'null'); 322 }, { passive: true }); 323 324 userMenuDropdown.addEventListener('touchend', function(e) { 325 var touchEndTarget = e.target.closest ? e.target.closest('.dropdown-item') : null; 326 var touchDuration = Date.now() - dropdownTouchStartTime; 327 328 console.log('ONLINE-42934 touchend:', { 329 touchEndTarget: touchEndTarget ? touchEndTarget.getAttribute('href') : 'null', 330 touchStartTarget: dropdownTouchStartTarget ? dropdownTouchStartTarget.getAttribute('href') : 'null', 331 sameTarget: touchEndTarget === dropdownTouchStartTarget, 332 duration: touchDuration 333 }); 334 335 // Only handle if touch started and ended on the same dropdown item 336 // and the duration was short (not a scroll or long press) 337 if (touchEndTarget && touchEndTarget === dropdownTouchStartTarget && touchDuration < 500) { 338 // Get the href from the link 339 var href = touchEndTarget.getAttribute('href'); 340 var target = touchEndTarget.getAttribute('target'); 341 342 console.log('ONLINE-42934 navigating to:', href, 'target:', target); 343 344 // ONLINE-42934: Handle touch directly and prevent the delayed synthetic click 345 // (which is suppressed on Profile/Account Settings pages anyway) 346 e.preventDefault(); 347 e.stopPropagation(); 348 349 // For javascript:void(0) / # links the action lives in onclick, not href. 350 // HTML onclick attributes are always exposed as element.onclick by the browser, 351 // so call it directly â no eval/new Function needed. 352 if (href === 'javascript:void(0)' || href === '#') { 353 console.log('ONLINE-42934 invoking onclick handler directly'); 354 if (typeof touchEndTarget.onclick === 'function') { 355 touchEndTarget.onclick.call(touchEndTarget, e); 356 } 357 return; 358 } 359 360 if (href) { 361 // Navigate programmatically 362 if (target === '_blank') { 363 // Open in new tab 364 console.log('ONLINE-42934 opening new tab'); 365 window.open(href, '_blank', 'noopener,noreferrer'); 366 // Close menu after opening new tab 367 setTimeout(function() { 368 if (isOpen()) { 369 closeDropdown(); 370 } 371 }, 100); 372 } else if (href.startsWith('javascript:')) { 373 // Handle javascript: links with actual code (not void(0)) 374 console.log('ONLINE-42934 executing javascript'); 375 try { 376 eval(href.substring(11)); // Execute the javascript code 377 } catch (ex) { 378 console.error('Error executing javascript link:', ex); 379 } 380 } else { 381 // Normal navigation - redirect the page 382 console.log('ONLINE-42934 redirecting to:', href); 383 window.location.href = href; 384 } 385 } else { 386 console.log('ONLINE-42934 no href found!'); 387 } 388 } else { 389 console.log('ONLINE-42934 conditions not met - NOT navigating'); 390 } 391 392 dropdownTouchStartTarget = null; 393 }); 394 395 // Fallback click handler for desktop/non-touch devices 396 userMenuDropdown.addEventListener('click', function(e) { 397 var clickedItem = e.target.closest ? e.target.closest('.dropdown-item') : null; 398 399 if (clickedItem) { 400 // For target="_blank" links, close menu after click 401 var isNewTabLink = clickedItem.getAttribute('target') === '_blank'; 402 if (isNewTabLink) { 403 setTimeout(function() { 404 if (isOpen()) { 405 closeDropdown(); 406 } 407 }, 100); 408 } 409 } 410 }); 411 412 // Outside-dismiss handler: close menu when clicking outside both trigger and dropdown 413 document.addEventListener('click', function(e) { 414 if (!isOpen()) return; 415 416 // Check if click is on a dropdown item - if so, let the click handler above deal with it 417 var clickedDropdownItem = e.target.closest ? e.target.closest('.dropdown-item') : null; 418 if (clickedDropdownItem && userMenuDropdown.contains(clickedDropdownItem)) { 419 // Click is on a dropdown link - don't interfere 420 return; 421 } 422 423 // ONLINE-42934: Don't close if recent touch on trigger (iOS
423Safari synthetic click delay) 424 var timeSinceTriggerInteraction = Date.now() - lastTriggerInteractionTime; 425 if (timeSinceTriggerInteraction < 300) { 426 return; 427 } 428 429 // Close only if click is outside both trigger and dropdown 430 if (!userMenuTrigger.contains(e.target) && !userMenuDropdown.contains(e.target)) { 431 closeDropdown(); 432 } 433 }); 434})();
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.