1/** 2 * @package Astroid Framework 3 * @author Astroid Framework Team https://astroidframe.work 4 * @copyright Copyright (C) 2026 AstroidFrame.work. 5 * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 or Later 6 */ 7class AstroidMegaMenuPro { 8 9 constructor(navbar, options = {}) { 10 11 this.navbar = navbar; 12 13 this.settings = Object.assign({ 14 megamenuSelector: navbar.dataset.megamenuClass || '.has-megamenu', 15 dropdownSelector: '.nav-item-caret', 16 contentSelector: navbar.dataset.megamenuContentClass || '.megamenu-container', 17 submenuSelector: navbar.dataset.megamenuSubmenuClass || '.nav-submenu', 18 trigger: navbar.dataset.astroidTrigger || 'hover', // 'hover' or 'click' 19 duration: (navbar.dataset.transitionSpeed/1000) || 0.6, 20 ease: navbar.dataset.easing || 'expo.out', 21 backdrop: navbar.dataset.megamenuBackdrop === 'true' || false, 22 spacing: navbar.dataset.megamenuSpacing === 'true' || false, 23 headerSelector: '.astroid-header', 24 rtl: document.body.classList.contains('rtl'), 25 effect: navbar.dataset.megamenuAnimation || 'slide-scale', // slide-scale | fade | zoom | slide | drop | flip | scaleY | none 26 stagger: navbar.dataset.megamenuStagger === 'true' || false, 27 }, options); 28 // Detect interaction capability instead of just touch devices 29 this.canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches; 30 this.init(); 31 } 32 33 init() { 34 this.items = this.navbar.querySelectorAll( 35 `${this.settings.megamenuSelector}` 36 ); 37 38 this.createBackdrop(); 39 this.injectARIA(); 40 this.bindEvents(); 41 this.observeMutations(); 42 this.handleResize(); 43 } 44 45 /* ============================= 46 BACKDROP 47 ============================= */ 48 49 createBackdrop() { 50 if (!this.settings.backdrop) return; 51 52 this.backdrop = document.createElement('div'); 53 this.backdrop.className = 'astroid-menu-backdrop'; 54 55 gsap.set(this.backdrop, { 56 position: 'fixed', 57 inset: 0, 58 backdropFilter: 'blur(6px)', 59 background: 'rgba(0,0,0,0.25)', 60 opacity: 0, 61 pointerEvents: 'none', 62 zIndex: 90 63 }); 64 document.body.appendChild(this.backdrop); 65 } 66 67 showBackdrop() { 68 if (!this.backdrop) return; 69 gsap.to(this.backdrop, { 70 opacity: 1, 71 duration: 0.3 72 }); 73 this.backdrop.style.pointerEvents = 'auto'; 74 } 75 76 hideBackdrop() { 77 if (!this.backdrop) return; 78 gsap.to(this.backdrop, { 79 opacity: 0, 80 duration: 0.3 81 }); 82 this.backdrop.style.pointerEvents = 'none'; 83 } 84 85 /* ============================= 86 EVENT BINDING 87 ============================= */ 88 89 bindEvents() { 90 this.items.forEach(item => { 91 const trigger = item.querySelector('.as-menu-item'); 92 if (!trigger) return; 93 const content = this.getContent(item); 94 if (!content) return; 95 96 if (this.settings.trigger === 'click') { 97 trigger.addEventListener('click', e => { 98 e.preventDefault(); 99 this.toggle(item); 100 }); 101 } else if (!this.canHover) { 102 const arrow = item.querySelector('.fa-chevron-down.nav-item-caret'); 103 if (!arrow) { 104 // Create caret element and append it if .nav-title exists 105 const navTitle = trigger.querySelector('.nav-title'); 106 if (navTitle) { 107 item.classList.remove('no-dropdown-icon'); 108 const i = document.createElement('i'); 109 i.classList.add('nav-item-caret', 'fas', 'fa-chevron-down'); 110 navTitle.appendChild(i); 111 i.addEventListener('click', e => { 112 e.preventDefault(); 113 this.toggle(item); 114 }); 115 } 116 } else { 117 arrow.addEventListener('click', e => { 118 e.preventDefault(); 119 this.toggle(item); 120 }); 121 } 122 } else { 123 // Hover-capable devices 124 item.addEventListener('mouseenter', () => this.open(item)); 125 item.addEventListener('mouseleave', () => this.close(item)); 126 } 127 this.handleSubmenus(item); 128 this.keyboardSupport(trigger, item); 129 }); 130 131 document.addEventListener('click', e => { 132 if (!this.navbar.contains(e.target)) this.closeAll(); 133 }); 134 } 135 136 /* ============================= 137 MAIN OPEN 138 ============================= */ 139 140 toggle(item) { 141 item.classList.contains('open') 142 ? this.close(item) 143 : this.open(item); 144 } 145 open(item) { 146 147 this.closeSiblings(item); 148 149 const content = this.getContent(item); 150 if (!content) return; 151 // If closing animation is running, kill it 152 gsap.killTweensOf(content); 153 154 const subs = item.querySelectorAll('.nav-item-submenu');
155 if (subs) subs.forEach(sub => this.closeSub(sub)); 156 157 item.classList.add('open'); 158 159 // Kill previous animation if exists 160 if (content._tl) content._tl.kill(); 161 162 // Always force visible before animating (fix fast hover issue) 163 content.style.display = 'block'; 164 content.style.pointerEvents = 'auto'; 165 content.style.visibility = 'hidden'; 166 this.positionContent(item, content); 167 168 gsap.set(content, { autoAlpha: 1 }); 169 170 const effect = this.settings.effect; 171 content._tl = gsap.timeline(); 172 173 if (effect === 'slide-scale') { 174 content._tl.fromTo(content, 175 { autoAlpha: 0, y: 25, scale: 0.97 }, 176 { autoAlpha: 1, y: 0, scale: 1, duration: this.settings.duration, ease: this.settings.ease } 177 ); 178 } 179 else if (effect === 'fade') { 180 content._tl.fromTo(content, 181 { autoAlpha: 0 }, 182 { autoAlpha: 1, duration: this.settings.duration, ease: this.settings.ease } 183 ); 184 } 185 else if (effect === 'zoom') { 186 content._tl.fromTo(content, 187 { autoAlpha: 0, scale: 0.95 }, 188 { autoAlpha: 1, scale: 1, duration: this.settings.duration, ease: this.settings.ease } 189 ); 190 } 191 else if (effect === 'slide') { 192 content._tl.fromTo(content, 193 { autoAlpha: 0, y: 20 }, 194 { autoAlpha: 1, y: 0, duration: this.settings.duration, ease: this.settings.ease } 195 ); 196 } 197 else if (effect === 'drop') { 198 content._tl.fromTo(content, 199 { autoAlpha: 0, y: -20 }, 200 { autoAlpha: 1, y: 0, duration: this.settings.duration * 0.9, ease: this.settings.ease } 201 ); 202 } 203 else if (effect === 'flip') { 204 content._tl.fromTo(content, 205 { autoAlpha: 0, rotateX: -15, transformPerspective: 800, transformOrigin: 'top center' }, 206 { autoAlpha: 1, rotateX: 0, duration: this.settings.duration, ease: this.settings.ease } 207 ); 208 } 209 else if (effect === 'scaleY') { 210 content._tl.fromTo(content, 211 { autoAlpha: 0, scaleY: 0.9, transformOrigin: 'top center' }, 212 { autoAlpha: 1, scaleY: 1, duration: this.settings.duration, ease: this.settings.ease } 213 ); 214 } 215 else if (effect === 'blur-in') { 216 content._tl.fromTo(content, 217 { autoAlpha: 0, filter: 'blur(10px)', y: 10 }, 218 { autoAlpha: 1, filter: 'blur(0px)', y: 0, duration: this.settings.duration, ease: this.settings.ease } 219 ); 220 } 221 else { // none (default) 222 gsap.set(content, { autoAlpha: 1, y: 0, scale: 1 }); 223 } 224 225 if (effect !== 'none' && this.settings.stagger) { 226 this.staggerItems(content, item); 227 } 228 229 this.showBackdrop(); 230 this.rotateArrow(item, true); 231 } 232 233 close(item) { 234 235 const content = this.getContent(item); 236 if (!content) return; 237 238 if (content._tl) content._tl.kill(); 239 240 // Kill any running tweens on content 241 gsap.killTweensOf(content); 242 243 item.classList.remove('open'); 244 245 const effect = this.settings.effect; 246 247 let vars = { 248 autoAlpha: 0, 249 duration: this.settings.duration * 0.6, 250 ease: 'power2.inOut', 251 overwrite: 'auto', 252 onComplete: () => { 253 if (!item.classList.contains('open')) { 254 content.style.display = 'none'; 255 content.style.pointerEvents = 'none'; 256 } 257 } 258 }; 259 260 if (effect === 'slide') vars.y = 15; 261 else if (effect === 'zoom') vars.scale = 0.96; 262 else if (effect === 'drop') vars.y = -15; 263 else if (effect === 'flip') vars.rotateX = -10; 264 else if (effect === 'scaleY') vars.scaleY = 0.9; 265 else if (effect === 'blur-in') vars.filter = 'blur(8px)'; 266 else if (effect === 'slide-scale') { 267 vars.y = 15; 268 vars.scale = 0.98; 269 } 270 else if (effect === 'fade') vars.scale = 1; 271 else { 272 vars.scale = 1; 273 } 274 275 gsap.to(content, vars); 276 277 this.rotateArrow(item, false); 278 279 this.hideBackdrop(); 280 } 281 282 closeAll() {
283 this.items.forEach(item => this.close(item)); 284 } 285 286 closeSiblings(current) { 287 this.items.forEach(item => { 288 if (item !== current) this.close(item); 289 }); 290 } 291 292 getContent(item) { 293 return item.querySelector(this.settings.contentSelector) || 294 item.querySelector(this.settings.submenuSelector); 295 } 296 297 /* ============================= 298 SUBMENUS 299 ============================= */ 300 301 handleSubmenus(parent) { 302 303 const subs = parent.querySelectorAll('.nav-item-submenu'); 304 const parentContent = this.getContent(parent); 305 306 subs.forEach(sub => { 307 const link = sub.querySelector('.as-menu-item'); 308 309 const submenu = sub.querySelector(this.settings.submenuSelector); 310 if (!submenu || !link) return; 311 312 if (this.settings.trigger === 'click') { 313 link.addEventListener('click', e => { 314 e.preventDefault(); 315 sub.classList.contains('open') 316 ? this.closeSub(sub) 317 : this.openSub(sub, parentContent); 318 }) 319 } else if (!this.canHover) { 320 const arrow = sub.querySelector('.nav-item-caret'); 321 arrow.addEventListener('click', e => { 322 e.preventDefault(); 323 sub.classList.contains('open') 324 ? this.closeSub(sub) 325 : this.openSub(sub, parentContent); 326 }); 327 } else { 328 sub.addEventListener('mouseenter', () => this.openSub(sub, parentContent)); 329 sub.addEventListener('mouseleave', () => this.closeSub(sub)); 330 } 331 332 333 link.addEventListener('keydown', e => { 334 if (e.key === 'Enter') { 335 e.preventDefault(); 336 sub.classList.contains('open') 337 ? this.closeSub(sub) 338 : this.openSub(sub, parentContent); 339 } 340 341 if (e.key === 'Escape') { 342 this.closeSub(sub); 343 link.focus(); 344 } 345 }); 346 }); 347 } 348 349 openSub(sub, parentContent) { 350 351 const submenu = sub.querySelector(':scope > '+this.settings.submenuSelector); 352 if (!submenu) return; 353 354 // reference the parent's stagger timeline directly 355 const waitMs = 150; // extra wait time in milliseconds 356 357 const proceed = () => { 358 if (submenu._tl) submenu._tl.kill(); 359 sub.classList.add('open'); 360 submenu.style.display = 'block'; 361 submenu.style.pointerEvents = 'auto'; 362 this.smartSubPosition(sub, submenu); 363 364 submenu._tl = gsap.timeline(); 365 366 submenu._tl.fromTo(submenu, 367 { autoAlpha: 0, x: this.settings.rtl ? -15 : 15 }, 368 { autoAlpha: 1, x: 0, duration: 0.35, ease: 'power2.out' } 369 ); 370 }; 371 372 if (parentContent._staggerTl && typeof parentContent._staggerTl.totalProgress === 'function' && parentContent._staggerTl.totalProgress() < 1) { 373 // If we're already waiting, don't attach another handler 374 if (parentContent._waitingForStagger) return; 375 parentContent._waitingForStagger = true; 376 377 const prevOnComplete = typeof parentContent._staggerTl.eventCallback === 'function' ? parentContent._staggerTl.eventCallback('onComplete') : null; 378 379 parentContent._staggerTl.eventCallback('onComplete', function() { 380 // call previous onComplete if present 381 if (typeof prevOnComplete === 'function') { 382 try { prevOnComplete.call(this); } catch (e) {} 383 } 384 385 setTimeout(() => { 386 parentContent._waitingForStagger = false; 387 // ensure the submenu/parent still exists in DOM 388 if (!document.contains(sub)) return; 389 390 // Re-check that the stagger timeline is actually finished. 391 // It's possible another stagger was created or restarted; if it's not complete, bail. 392 if (parentContent._staggerTl && typeof parentContent._staggerTl.totalProgress === 'function' && parentContent._staggerTl.totalProgress() < 1) { 393 return; 394 } 395 396 // Use the nearest trigger to this sub for hover detection 397 const subTrigger = sub.querySelector(':scope > .as-menu-item') 398 || sub.querySelector('.as-menu-item') 399 || (sub.closest('.nav-item') && sub.closest('.nav-item').querySelector('.as-menu-item')); 400 401 const isHovered = sub.matches(':hover') || (subTrigger && subTrigger.matches(':hover')); 402 if (!isHovered) return; 403 404 proceed(); 405 }, waitMs); 406 }); 407 408 return; 409 } 410 411 proceed(); 412 } 413 414 closeSub(sub) { 415 416 const submenu = sub.querySelector(this.settings.submenuSelector); 417 if (!submenu) return; 418 419 if (submenu._tl) submenu._tl.kill(); 420 421 gsap.to(submenu, { 422 autoAlpha: 0, 423 x: this.settings.rtl ? -10 : 10, 424 duration: 0.25, 425 ease: 'power2.in', 426 onComplete: () => { 427 submenu.style.display = 'none'; 428 submenu.style.pointerEvents = 'none'; 429 sub.classList.remove('open'); 430 } 431 }); 432 } 433 434 /* ============================= 435 SMART POSITIONING 436 ============================= */ 437 438 positionContent(item, content) { 439 440 const positionType = item.dataset.position; 441 442 // Reset first 443 content.style.left = ''; 444 content.style.right = ''; 445 446 const header = this.navbar.closest(this.settings.headerSelector) || this.navbar; 447 const headerRect = header.getBoundingClientRect(); 448 const itemRect = item.getBoundingClientRect(); 449 const mark = item.querySelector('.submenu-dropdown-mask'); 450 451 // Set top based on available space below or above the item 452 mark.style.height = ( headerRect.bottom - itemRect.bottom + 10 ) + 'px'; 453 if (this.settings.spacing) { 454 content.style.top = 'calc(100% + '+( headerRect.bottom - itemRect.bottom )+'px)'; 455 } 456 457 // ===== FULL WIDTH (container width & centered) ===== 458 if (positionType === 'full') { 459 content.style.width = headerRect.width + 'px'; 460 content.style.left = (headerRect.left - itemRect.left) + 'px'; 461 content.style.right = 'auto'; 462 return; 463 } 464 465 // ===== EDGE (100vw & stick to viewport edges) ===== 466 if (positionType === 'edge') { 467 468 content.style.width = window.innerWidth + 'px'; 469 470 // Align to viewport left 471 content.style.left = (0 - itemRect.left) + 'px'; 472 content.style.right = 'auto'; 473 474 return; 475 } 476
477 // ===== DEFAULT SMART POSITION ===== 478 479 const rect = content.getBoundingClientRect(); 480 const overflowRight = rect.right > window.innerWidth; 481 const overflowLeft = rect.left < 0; 482 483 if (overflowRight) { 484 content.style.left = 'auto'; 485 content.style.right = '0'; 486 } 487 488 if (overflowLeft) { 489 content.style.left = '0'; 490 content.style.right = 'auto'; 491 } 492 } 493 494 smartSubPosition(parent, submenu) { 495 const parentRect = parent.getBoundingClientRect(); 496 const rect = submenu.getBoundingClientRect(); 497 const overflowRight = parentRect.right + rect.width > window.innerWidth; 498 499 if (overflowRight) { 500 submenu.style.left = 'auto'; 501 submenu.style.right = '100%'; 502 } else { 503 submenu.style.left = '100%'; 504 submenu.style.right = 'auto'; 505 } 506 } 507 508 /* ============================= 509 ANIMATION HELPERS 510 ============================= */ 511 512 staggerItems(container, parentItem) { 513 514 const el = container instanceof Element ? container : null; 515 if (!el) return; 516 517 let items; 518 if (parentItem.classList.contains('nav-item-megamenu')) { 519 items = el.querySelectorAll('li.nav-item-submenu.nav-item-level-2, li.nav-item-submenu.nav-item-level-3'); 520 } else { 521 items = el.querySelectorAll('li.nav-item-submenu.nav-item-level-2'); 522 } 523 if (!items.length) return; 524 525 // Kill previous stagger timeline if exists 526 if (el._staggerTl) { 527 el._staggerTl.kill(); 528 el._staggerTl = null; 529 } 530 531 // Clear inline styles to avoid broken states 532 gsap.set(items, { clearProps: 'all' }); 533 534 el._staggerTl = gsap.timeline({ 535 defaults: { 536 ease: 'power2.out', 537 overwrite: 'auto' 538 } 539 }); 540 541 el._staggerTl.from(items, { 542 opacity: 0, 543 y: 20, 544 stagger: 0.05, 545 duration: 0.4, 546 onComplete: () => { 547 // Clear inline styles after animation to prevent broken states 548 gsap.set(items, { clearProps: 'all' }); 549 } 550 }); 551 } 552 553 rotateArrow(item, open) { 554 555 const arrow = item.querySelector('.fa-chevron-down.nav-item-caret'); 556 if (!arrow) return; 557 558 gsap.to(arrow, { 559 rotate: open ? 180 : 0, 560 duration: 0.3, 561 ease: 'power2.out' 562 }); 563 } 564 565 /* ============================= 566 ACCESSIBILITY 567 ============================= */ 568 569 injectARIA() { 570 this.items.forEach(item => { 571 const trigger = item.querySelector(':scope > .as-menu-item'); 572 const content = this.getContent(item); 573 574 if (!trigger || !content) return; 575 576 trigger.setAttribute('aria-haspopup', 'true'); 577 trigger.setAttribute('aria-expanded', 'false'); 578 579 // Ensure the content has an id so we can set aria-controls 580 if (!content.id) { 581 content.id = 'megamenu-content-' + Math.random().toString(36).substr(2, 9); 582 } 583 trigger.setAttribute('aria-controls', content.id); 584 585 // Also inject ARIA for submenu items inside this item 586 const subs = item.querySelectorAll('.nav-item-submenu'); 587 subs.forEach(sub => { 588 const subTrigger = sub.querySelector(':scope > .as-menu-item'); 589 const subMenu = sub.querySelector(':scope > .nav-submenu, :scope > .nav-submenu-static'); 590 591 if (!subTrigger || !subMenu) return; 592 subTrigger.setAttribute('aria-haspopup', 'true'); 593 subTrigger.setAttribute('aria-expanded', 'false'); 594 595 if (!subMenu.id) { 596 subMenu.id = 'megamenu-sub-' + Math.random().toString(36).substr(2, 9); 597 } 598 subTrigger.setAttribute('aria-controls', subMenu.id); 599 }); 600 }); 601 } 602 603 keyboardSupport(trigger, item) { 604 605 trigger.addEventListener('keydown', e => { 606 607 if (e.key === 'Enter') { 608 e.preventDefault(); 609 this.toggle(item); 610 } 611 612 if (e.key === 'Escape') { 613 this.close(item); 614 trigger.focus(); 615 } 616 }); 617 } 618 619 620 /* ============================= 621 OBSERVERS 622 ============================= */ 623 624 observeMutations() { 625 626 const observer = new MutationObserver(() => { 627 this.items = this.navbar.querySelectorAll( 628 `${this.settings.megamenuSelector}` 629 ); 630 }); 631 632 observer.observe(this.navbar, { childList: true, subtree: true }); 633 } 634 635 handleResize() { 636 window.addEventListener('resize', () => { 637 this.closeAll(); 638 }); 639 } 640} 641 642document.addEventListener('DOMContentLoaded', () => { 643 document.querySelectorAll('[data-megamenu]').forEach(nav => { 644 new AstroidMegaMenuPro(nav, {}); 645 }); 646});
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.