1// ============================================================================ 2// ALTERNATE NAVIGATION ACCESSIBILITY ENHANCEMENTS 3// ============================================================================ 4// This code duplicates the functionality above but adds accessibility features 5// specifically for the alternate navigation (.nav-bamboo.alternate) 6// ============================================================================ 7 8document.addEventListener("DOMContentLoaded", function() { 9 // Early exit if navigation is disabled or if alternate navigation doesn't exist 10 if (window.fsNavDisabled) return; 11 12 const alternateNav = document.querySelector('.nav-bamboo.alternate'); 13 if (!alternateNav) return; // Only run this code if the alternate navigation exists 14 15 // Add visually-hidden class if it doesn't exist 16 if (!document.querySelector('style#accessibility-styles')) { 17 const style = document.createElement('style'); 18 style.id = 'accessibility-styles'; 19 style.textContent = ` 20 .visually-hidden { 21 position: absolute; 22 width: 1px; 23 height: 1px; 24 margin: -1px; 25 padding: 0; 26 overflow: hidden; 27 clip: rect(0, 0, 0, 0); 28 border: 0; 29 } 30 31 /* Focus styles for keyboard navigation */ 32 .nav-bamboo.alternate a:focus, 33 .nav-bamboo.alternate button:focus { 34 outline: 2px solid #4A90E2; 35 outline-offset: 2px; 36 } 37 38 /* Remove focus styles for mouse users in browsers that support :focus-visible */ 39 .nav-bamboo.alternate a:focus:not(:focus-visible), 40 .nav-bamboo.alternate button:focus:not(:focus-visible) { 41 outline: none; 42 } 43 44 /* Apply focus styles for keyboard navigation where :focus-visible is supported */ 45 .nav-bamboo.alternate a:focus-visible, 46 .nav-bamboo.alternate button:focus-visible { 47 outline: 2px solid #4A90E2; 48 outline-offset: 2px; 49 } 50 51 `; 52 document.head.appendChild(style); 53 } 54 55 // State management variables 56 let isAlternateDropdownContainerShowing = false; 57 let isAlternateSearchContainerShowing = false; 58 59 // DOM elements 60 const alternateSearchContainer = document.getElementById("nav-bamboo-search-container"); 61 const alternateSearchTrigger = alternateNav.querySelector('#trigger-search'); 62 const alternateHamburgerToggle = alternateNav.querySelector('#nav-bamboo-toggle'); 63 64 // Create a namespace for alternate navigation functions 65 window.fsAltNav = { 66 dropdownContainer: document.getElementById("nav-bamboo-dropdown-container"), 67 currentDropdownId: null, 68 lastInteractionWasKeyboard: false, 69 70 setOffsetAndSize(dropdownEl, leftPx, centerPx) { 71 const dataWidth = parseInt(dropdownEl.getAttribute('data-width')) || 0; 72 const align = dropdownEl.getAttribute('data-align'); 73 const correct = parseInt(dropdownEl.getAttribute('data-correct')) || 0; 74 const newOffset = align === "left" ? leftPx + correct : align === "center" ? centerPx - (dataWidth / 2) : 0; 75 76 dropdownEl.style.width = `${dataWidth}px`; 77 dropdownEl.style.left = `${newOffset}px`; 78 }, 79 80 showDropdownContainer(clickedLink, dropdownId, leftPx, centerPx) { 81 resetAlternateTriggerStates(); 82 clickedLink.classList.add('open'); 83 84 // Update ARIA attributes for accessibility 85 clickedLink.setAttribute('aria-expanded', 'true'); 86 87 hideAlternateSearchContainer(); 88 fsAltNav.hideAllVisibleDropdowns(); 89 90 const dropdown = document.getElementById(dropdownId); 91 if (dropdown) { 92 dropdown.classList.add('visible'); 93 this.setOffsetAndSize(dropdown, leftPx, centerPx); 94 95 // Add mouseleave event listener to close the dropdown when mouse exits 96 dropdown.addEventListener('mouseleave', () => { 97 dropdown.classList.remove('visible'); 98 isAlternateDropdownContainerShowing = false; 99 100 // Update ARIA attributes 101 clickedLink.setAttribute('aria-expanded', 'false'); 102 }, { once: true }); // Ensures the listener only triggers once 103 } 104 105 if (!isAlternateDropdownContainerShowing) { 106 this.dropdownContainer.classList.add('visible'); 107 isAlternateDropdownContainerShowing = true; 108 } 109 110 // Only focus the first item if the dropdown was triggered by keyboard 111 if (this.lastInteractionWasKeyboard && dropdown) { 112 setTimeout(() => { 113 const firstItem = dropdown.querySelector('.dropdown-links a'); 114 if (firstItem) { 115 firstItem.focus(); 116 } else { 117 console.error('[FS] Navigation: no focusable item found in dropdown', dropdownId); 118 } 119 }, 100); 120 } 121 }, 122 123 hideAllVisibleDropdowns() { 124 document.querySelectorAll(".nav-bamboo-dropdown.visible").forEach(dropdown => { 125 dropdown.classList.remove('visible'); 126 127 // Find the associated trigger and update its ARIA attributes 128 const dropdownId = dropdown.id.replace('dropdown-', ''); 129 const trigger = alternateNav.querySelector(`[data-dropdown="${dropdownId}"]`); 130 if (trigger) { 131 trigger.setAttribute('aria-expanded', 'false'); 132 } 133 }); 134 }, 135 }; 136 137 // First, define the handleAlternateDropdown function 138 const handleAlternateDropdown = (event) => { 139 event.preventDefault(); 140 const clickedLink = event.target.closest('.trigger-dropdown'); 141 if (!clickedLink) return; 142 143 const dropdownId = clickedLink.getAttribute('data-dropdown'); 144 const eventLabel = clickedLink.getAttribute('data-event') || "No Label Specified"; 145 146 if (dropdownId === "signin" && window.outerWidth < 1222) { 147 location.href = "https://app.fastspring.com/"; 148 } else { 149 const rect = clickedLink.getBoundingClientRect(); 150 const centerPx = Math.round(rect.left + rect.width / 2); 151 const leftPx = Math.round(rect.left); 152 const dropdownSelector = `dropdown-${dropdownId}`; 153 154 fsAltNav.showDropdownContainer(clickedLink, dropdownSelector, leftPx, centerPx); 155 fsAltNav.currentDropdownId = dropdownId; 156 } 157 }; 158 159 // Function to reset trigger states by removing the 'open' class 160 const resetAlternateTriggerStates = () => {
161 alternateDropdownTriggers.forEach(trigger => { 162 trigger.classList.remove('open'); 163 trigger.setAttribute('aria-expanded', 'false'); 164 }); 165 }; 166 167 // Function to hide the dropdown container 168 const hideAlternateDropdownContainer = () => { 169 fsAltNav.dropdownContainer.classList.remove('visible'); 170 alternateHamburgerToggle.classList.remove('open'); 171 alternateHamburgerToggle.setAttribute('aria-expanded', 'false'); 172 isAlternateDropdownContainerShowing = false; 173 }; 174 175 // Function to hide the search container 176 const hideAlternateSearchContainer = () => { 177 if (alternateSearchContainer) { 178 alternateSearchContainer.classList.remove("visible"); 179 } 180 isAlternateSearchContainerShowing = false; 181 if (alternateSearchTrigger) { 182 alternateSearchTrigger.classList.remove('open'); 183 alternateSearchTrigger.setAttribute('aria-expanded', 'false'); 184 } 185 }; 186 187 // Function to close and reset dropdowns 188 const closeAndResetAlternateDropdowns = () => { 189 fsAltNav.lastInteractionWasKeyboard = false; 190 hideAlternateDropdownContainer(); 191 fsAltNav.hideAllVisibleDropdowns(); 192 resetAlternateTriggerStates(); 193 hideAlternateSearchContainer(); 194 }; 195 196 // Get all dropdown triggers in the alternate navigation 197 const alternateDropdownTriggers = alternateNav.querySelectorAll(".trigger-dropdown"); 198 199 // Function to add event listeners to dropdown triggers 200 const addAlternateDropdownEventListeners = (trigger) => { 201 // Handle click event 202 trigger.addEventListener("click", (event) => { 203 fsAltNav.lastInteractionWasKeyboard = false; 204 handleAlternateDropdown(event); 205 }); 206 207 // Handle mouseover event for wider screens 208 trigger.addEventListener("mouseover", (event) => { 209 if (window.outerWidth >= 1222) { 210 fsAltNav.lastInteractionWasKeyboard = false; 211 handleAlternateDropdown(event); 212 } 213 }); 214 215 // Handle keyboard events for accessibility 216 trigger.addEventListener("keydown", (event) => { 217 // Open dropdown on Enter or Space 218 if (event.key === 'Enter' || event.key === ' ') { 219 event.preventDefault(); 220 fsAltNav.lastInteractionWasKeyboard = true; 221 handleAlternateDropdown(event); 222 } 223 }); 224 }; 225 226 // Loop through dropdown triggers and set up event listeners 227 alternateDropdownTriggers.forEach(addAlternateDropdownEventListeners); 228 229 // Get all top-level links and add event listeners 230 const alternateTopLevelLinks = alternateNav.querySelectorAll(".top-level-link"); 231 232 // Add mouseover event to top-level links to reset dropdowns 233 alternateTopLevelLinks.forEach(link => { 234 link.addEventListener("mouseover", closeAndResetAlternateDropdowns); 235 }); 236 237 // Event listener to handle mouse movement outside of the header 238 document.addEventListener("mousemove", (event) => { 239 if (window.outerWidth >= 1222) { 240 const isMouseInside = alternateNav.contains(event.target); 241 if (!isMouseInside) { 242 closeAndResetAlternateDropdowns(); 243 } 244 } 245 }); 246 247 // Event listener to close dropdowns when mouse leaves the window 248 document.addEventListener("mouseleave", (event) => { 249 const isOutsideViewport = event.clientY <= 0 || event.clientX <= 0 || 250 event.clientX >= window.innerWidth || event.clientY >= window.innerHeight; 251 252 if (window.outerWidth >= 1222 && isOutsideViewport) { 253 closeAndResetAlternateDropdowns(); 254 } 255 }); 256 257 // Set up mobile toggle button 258 if (alternateHamburgerToggle) { 259 alternateHamburgerToggle.addEventListener("click", (event) => { 260 if (!isAlternateDropdownContainerShowing) { 261 fsAltNav.showDropdownContainer(event.target, ''); 262 event.target.classList.add('open'); 263 event.target.setAttribute('aria-expanded', 'true'); 264 } else { 265 hideAlternateDropdownContainer(); 266 event.target.classList.remove('open'); 267 event.target.setAttribute('aria-expanded', 'false'); 268 } 269 }); 270 } 271 272 // Set up the collapsing / expanding mobile nav sections 273 const alternateMobileDropdownTitles = alternateNav.querySelectorAll(".has-sub-items"); 274 275 // Function to toggle the dropdown visibility 276 const toggleAlternateDropdown = (event) => { 277 event.preventDefault(); 278 const clickedElement = event.target.querySelector('.has-sub-items') || 279 event.target.parentElement.querySelector('.has-sub-items'); 280 281 if (!clickedElement) return; // Safeguard in case closest fails 282 283 // Determine if we're opening (not currently expanded) 284 const wasExpanded = clickedElement.getAttribute('aria-expanded') === 'true'; 285 const isOpening = !wasExpanded; 286 287 // Toggle 'open' class on the clicked element 288 clickedElement.classList.toggle('open'); 289 290 // Toggle aria-expanded attribute 291 clickedElement.setAttribute('aria-expanded', isOpening); 292 293 // Toggle visibility of the target menus 294 const targetClass = clickedElement.getAttribute('data-dropdown'); 295 const targetMenus = document.querySelectorAll(`.${targetClass}`); 296
297 targetMenus.forEach(menu => { 298 const isVisible = menu.getAttribute('data-visible') === "true"; 299 menu.classList.toggle('visible', !isVisible); 300 menu.setAttribute('data-visible', !isVisible); 301 }); 302 303 // Focus first link in the expanded menu if opened via keyboard 304 if (isOpening && fsAltNav.lastInteractionWasKeyboard) { 305 setTimeout(() => { 306 const firstLink = targetMenus[0] && targetMenus[0].querySelector('a'); 307 if (firstLink) { 308 firstLink.focus(); 309 } 310 }, 100); 311 } 312 }; 313 314 // Add click event listeners to each mobile dropdown title 315 alternateMobileDropdownTitles.forEach(title => { 316 title.addEventListener("click", (event) => { 317 fsAltNav.lastInteractionWasKeyboard = false; 318 toggleAlternateDropdown(event); 319 }); 320 321 // Add keyboard event listener for accessibility 322 title.addEventListener("keydown", (event) => { 323 if (event.key === 'Enter' || event.key === ' ') { 324 event.preventDefault(); 325 fsAltNav.lastInteractionWasKeyboard = true; 326 toggleAlternateDropdown(event); 327 } 328 }); 329 }); 330 331 // Search trigger keyboard support 332 if (alternateSearchTrigger) { 333 alternateSearchTrigger.addEventListener('keydown', (event) => { 334 if (event.key === 'Enter' || event.key === ' ') { 335 event.preventDefault(); 336 alternateSearchTrigger.click(); 337 338 // Poll for the search input which renders asynchronously 339 let attempts = 0; 340 const maxAttempts = 20; 341 const pollInterval = 150; 342 const focusSearchInput = setInterval(() => { 343 attempts++; 344 const searchInput = document.querySelector('.ais-SearchBox-input'); 345 if (searchInput) { 346 clearInterval(focusSearchInput); 347 searchInput.focus(); 348 } else if (attempts >= maxAttempts) { 349 clearInterval(focusSearchInput); 350 console.error('[FS] Navigation: search input not found after polling', { 351 page: window.location.pathname, 352 attempts: maxAttempts 353 }); 354 } 355 }, pollInterval); 356 } 357 }); 358 359 // Escape key handler on search container to close and return focus 360 if (alternateSearchContainer) { 361 alternateSearchContainer.addEventListener('keydown', (event) => { 362 if (event.key === 'Escape') { 363 event.preventDefault(); 364 hideAlternateSearchContainer(); 365 alternateSearchTrigger.focus(); 366 } 367 }); 368 } 369 } 370 371 // Close all dropdowns when hovering over the sign-up button 372 const alternateSignUpButton = alternateNav.querySelector('#main-nav-sign-up a'); 373 if (alternateSignUpButton) { 374 alternateSignUpButton.addEventListener("mouseover", () => { 375 fsAltNav.hideAllVisibleDropdowns(); 376 }); 377 } 378 379 // Handle keyboard navigation within dropdowns 380 document.addEventListener('keydown', (event) => { 381 // Only process if we're in a visible dropdown 382 const activeDropdown = document.querySelector('.nav-bamboo-dropdown.visible'); 383 if (!activeDropdown) return; 384 385 // Only process if the dropdown is related to the alternate navigation 386 const dropdownId = activeDropdown.id.replace('dropdown-', ''); 387 const trigger = alternateNav.querySelector(`[data-dropdown="${dropdownId}"]`); 388 if (!trigger) return; 389 390 // Get all focusable link elements in the dropdown 391 const menuItems = activeDropdown.querySelectorAll('.dropdown-links a'); 392 if (!menuItems.length) return; 393 394 // Find the currently focused item 395 const focusedItem = document.activeElement; 396 const focusedIndex = Array.from(menuItems).indexOf(focusedItem); 397 398 switch (event.key) { 399 case 'ArrowDown': 400 event.preventDefault(); 401 // Focus the next item, or the first if at the end 402 const nextIndex = (focusedIndex + 1) % menuItems.length; 403 menuItems[nextIndex].focus(); 404 break; 405 406 case 'ArrowUp': 407 event.preventDefault(); 408 // Focus the previous item, or the last if at the beginning 409 const prevIndex = (focusedIndex - 1 + menuItems.length) % menuItems.length; 410 menuItems[prevIndex].focus(); 411 break; 412 413 case 'Escape': 414 event.preventDefault(); 415 // Close the dropdown fully and return focus to the trigger 416 closeAndResetAlternateDropdowns(); 417 trigger.focus(); 418 break; 419 420 case 'Tab': 421 // If tabbing out of the last item, close the dropdown 422 if (!event.shiftKey && focusedIndex === menuItems.length - 1) { 423 setTimeout(() => { 424 closeAndResetAlternateDropdowns(); 425 }, 0); 426 } 427 // If shift+tabbing out of the first item, close the dropdown 428 else if (event.shiftKey && focusedIndex === 0) { 429 setTimeout(() => { 430 closeAndResetAlternateDropdowns(); 431 }, 0); 432 } 433 break; 434 } 435 }); 436 437 // Add ARIA attributes to all dropdowns in the alternate navigation 438 document.querySelectorAll('.nav-bamboo-dropdown').forEach(dropdown => { 439 const dropdownId = dropdown.id.replace('dropdown-', ''); 440 const trigger = alternateNav.querySelector(`[data-dropdown="${dropdownId}"]`); 441 442 // Only process dropdowns that are triggered from the alternate navigation 443 if (trigger) { 444 // Add attributes to the dropdown 445 dropdown.setAttribute('aria-labelledby', `trigger-${dropdownId}`); 446 447 // Add ID to the trigger if it doesn't have one 448 if (!trigger.id) { 449 trigger.id = `trigger-${dropdownId}`; 450 } 451 452 // Add attributes to the trigger 453 trigger.setAttribute('aria-expanded', 'false'); 454 trigger.setAttribute('aria-controls', dropdown.id); 455 456 // Add aria-hidden to SVG icons 457 trigger.querySelectorAll('svg').forEach(svg => { 458 svg.setAttribute('aria-hidden', 'true'); 459 }); 460 461 dropdown.querySelectorAll('svg').forEach(svg => { 462 svg.setAttribute('aria-hidden', 'true'); 463 }); 464 } 465 }); 466 });
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.