/** * Menu Animation Controller * Handles menu open/close state with accessibility features */ (() => { // Configuration const menuCONFIG = { selectors: { navButton: "[r-nav-btn]", menuWrap: "[r-nav-menu-wrap]", menuTitle: '[r-nav-menu-animate="title"]', menuNr: '[r-nav-menu-animate="nr"]', menuBackdrop: "[r-nav-menu-backdrop]", focusableElements: 'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])', }, classes: { open: "cc-open", }, animation: { text: { duration: 0.6, ease: "power2.inOut", stagger: 0.05, yOffset: "130%", delay: 0.3, }, menu: { closeSpeedFactor: 1.5, clipPath: { duration: 1, ease: "power4.inOut", closed: "polygon(0 0, 100% 0, 100% 0%, 0 0%)", open: "polygon(0 0, 100% 0, 100% 100%, 0 100%)", }, }, }, keys: { escape: "Escape", }, }; /** * Menu Controller Class */ class MenuController { constructor() { this.isOpen = false; this.focusableElements = []; this.lastFocusedElement = null; this.init(); } /** * Initialize menu controller */ init() { this.bindEvents(); this.updateFocusableElements(); } /** * Cache focusable elements within menu */ cacheFocusableElements() { const menuWrap = document.querySelector(menuCONFIG.selectors.menuWrap); if (menuWrap) { this.focusableElements = Array.from( menuWrap.querySelectorAll(menuCONFIG.selectors.focusableElements), ).filter( (element) => !element.disabled && element.offsetParent !== null, ); } } /** * Bind event listeners */ bindEvents() { const navButton = document.querySelector(menuCONFIG.selectors.navButton); if (navButton) { navButton.addEventListener("click", this.handleToggle.bind(this)); } document.addEventListener("keydown", this.handleKeydown.bind(this)); } /** * Handle toggle button click * @param {Event} event */ handleToggle(event) { event.preventDefault(); if (this.isOpen) { this.closeMenu(); } else { this.openMenu(); } } /** * Handle keydown events * @param {Event} event */ handleKeydown(event) { if (!this.isOpen) return; switch (event.key) { case menuCONFIG.keys.escape: event.preventDefault(); this.closeMenu(); break; case "Tab": this.handleTabNavigation(event); break; } } /** * Handle tab navigation within menu * @param {Event} event */ handleTabNavigation(event) { if (this.focusableElements.length === 0) return; const firstElement = this.focusableElements[0]; const lastElement = this.focusableElements[this.focusableElements.length - 1]; if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } else if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } /** * Animate menu opening */ animateOpen() { const menuTitle = document.querySelector(menuCONFIG.selectors.menuTitle); const menuNr = document.querySelector(menuCONFIG.selectors.menuNr); const menuBackdrop = document.querySelector( menuCONFIG.selectors.menuBackdrop, ); const menuWrap = document.querySelector(menuCONFIG.selectors.menuWrap); // Animate clip-path immediately (no delay) if (menuWrap) { gsap.to(menuWrap, { clipPath: menuCONFIG.animation.menu.clipPath.open, duration: menuCONFIG.animation.menu.clipPath.duration, ease: menuCONFIG.animation.menu.clipPath.ease, }); } // Animate backdrop immediately (no delay) if (menuBackdrop) { gsap.fromTo( menuBackdrop, { opacity: 0, }, { opacity: 1, duration: menuCONFIG.animation.text.duration, ease: menuCONFIG.animation.text.ease, }, ); } // Main timeline with delay for content elements const timeline = gsap.timeline({ delay: menuCONFIG.animation.text.delay, onComplete: () => { this.setFocusToFirstElement(); }, }); // Animate number first if (menuNr) { timeline.fromTo( menuNr, { y: menuCONFIG.animation.text.yOffset, }, { y: "0%", duration: menuCONFIG.animation.text.duration, ease: menuCONFIG.animation.text.ease, }, ); } // Animate title after stagger delay if (menuTitle) { timeline.fromTo( menuTitle, { y: menuCONFIG.animation.text.yOffset, }, { y: "0%", duration: menuCONFIG.animation.text.duration, ease: menuCONFIG.animation.text.ease, }, menuCONFIG.animation.text.stagger, ); } } /** * Animate menu closing */ animateClose() { const menuTitle = document.querySelector(menuCONFIG.selectors.menuTitle); const menuNr = document.querySelector(menuCONFIG.selectors.menuNr); const menuBackdrop = document.querySelector( menuCONFIG.selectors.menuBackdrop, ); const menuWrap = document.querySelector(menuCONFIG.selectors.menuWrap); // Content elements timeline const timeline = gsap.timeline({ onComplete: () => { if (menuWrap) { menuWrap.classList.remove(menuCONFIG.classes.open); } this.restoreFocus(); }, }); const closeDuration = menuCONFIG.animation.text.duration / menuCONFIG.animation.menu.closeSpeedFactor; const closeStagger = menuCONFIG.animation.text.stagger / menuCONFIG.animation.menu.closeSpeedFactor; // Animate title first (reverse order for closing) if (menuTitle) { timeline.to(menuTitle, { y: menuCONFIG.animation.text.yOffset, duration: closeDuration, ease: menuCONFIG.animation.text.ease, }); } // Animate number after stagger delay if (menuNr) { timeline.to( menuNr, { y: menuCONFIG.animation.text.yOffset, duration: closeDuration, ease: menuCONFIG.animation.text.ease, }, closeStagger, ); } // Animate clip-path separately (close immediately) if (menuWrap) { gsap.to(menuWrap, { clipPath: menuCONFIG.animation.menu.clipPath.closed, duration: menuCONFIG.animation.menu.clipPath.duration / menuCONFIG.animation.menu.closeSpeedFactor, ease: menuCONFIG.animation.menu.clipPath.ease, }); } // Animate backdrop separately (fade out immediately) if (menuBackdrop) { gsap.to(menuBackdrop, { opacity: 0, delay: 0.5, duration: closeDuration, ease: menuCONFIG.animation.text.ease, }); } } /** * Open menu with animation */ openMenu() { if (this.isOpen) return; this.isOpen = true; this.lastFocusedElement = document.activeElement; const menuWrap = document.querySelector(menuCONFIG.selectors.menuWrap); const navButton = document.querySelector(menuCONFIG.selectors.navButton); // Add class for styling if (menuWrap) { menuWrap.classList.add(menuCONFIG.classes.open); } // Update button aria state if (navButton) { navButton.setAttribute("aria-expanded", "true"); } // Animate menu appearance this.animateOpen(); } /** * Close menu with animation */ closeMenu() { if (!this.isOpen) return; this.isOpen = false; const navButton = document.querySelector(menuCONFIG.selectors.navButton); // Update button aria state if (navButton) { navButton.setAttribute("aria-expanded", "false"); } // Animate menu disappearance this.animateClose(); } /** * Set focus to first focusable element in menu */ setFocusToFirstElement() { if (this.focusableElements.length > 0) { this.focusableElements[0].focus(); } } /** * Restore focus to previously focused element */ restoreFocus() { if (this.lastFocusedElement) { this.lastFocusedElement.focus(); } } /** * Update focusable elements cache (call when menu content changes) */ updateFocusableElements() { this.cacheFocusableElements(); } /** * Get current menu state * @returns {boolean} */ getState() { return this.isOpen; } } // Export for module usage if needed if (typeof module !== "undefined" && module.exports) { module.exports = MenuController; } function initMenu() { window.menuController = new MenuController(); } // Initialize when DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initMenu); } else { initMenu(); } })();