'use strict'; 'require baseclass'; 'require ui'; /** * Native JavaScript slide animation utilities * Replaces jQuery slideUp/slideDown functionality with better performance */ const SlideAnimations = { /** * Animation durations in milliseconds */ durations: { fast: 200, normal: 400, slow: 600 }, /** * Map to track running animations and their cleanup functions */ runningAnimations: new WeakMap(), /** * Slide element down (show) with animation * @param {Element} element - DOM element to animate * @param {string|number} duration - Animation duration ('fast', 'normal', 'slow' or milliseconds) * @param {function} callback - Optional callback function when animation completes */ slideDown: function(element, duration, callback) { if (!element) { console.warn('SlideAnimations.slideDown: No element provided'); return; } // Stop any existing animation on this element this.stop(element); // Convert duration string to milliseconds const animDuration = typeof duration === 'string' ? this.durations[duration] || this.durations.normal : (duration || this.durations.normal); // Store original styles const originalStyles = { display: element.style.display, overflow: element.style.overflow, height: element.style.height, transition: element.style.transition }; // Set initial state for animation element.style.display = 'block'; element.style.overflow = 'hidden'; element.style.height = '0px'; element.style.transition = `height ${animDuration}ms ease-out`; // Force reflow to ensure initial state is applied element.offsetHeight; // Get the target height const targetHeight = element.scrollHeight; // Animate to full height element.style.height = targetHeight + 'px'; // Set up cleanup function const cleanup = () => { element.style.height = originalStyles.height || ''; element.style.overflow = originalStyles.overflow || ''; element.style.transition = originalStyles.transition || ''; // Remove from running animations map this.runningAnimations.delete(element); if (callback && typeof callback === 'function') { try { callback.call(element); } catch (e) { console.error('SlideAnimations callback error:', e); } } }; // Store cleanup function for potential cancellation const timeoutId = setTimeout(cleanup, animDuration); this.runningAnimations.set(element, { timeoutId, cleanup }); }, /** * Slide element up (hide) with animation * @param {Element} element - DOM element to animate * @param {string|number} duration - Animation duration ('fast', 'normal', 'slow' or milliseconds) * @param {function} callback - Optional callback function when animation completes */ slideUp: function(element, duration, callback) { if (!element) { console.warn('SlideAnimations.slideUp: No element provided'); return; } // Stop any existing animation on this element this.stop(element); // Convert duration string to milliseconds const animDuration = typeof duration === 'string' ? this.durations[duration] || this.durations.normal : (duration || this.durations.normal); // Store original styles const originalStyles = { display: element.style.display, overflow: element.style.overflow, height: element.style.height, transition: element.style.transition }; // Get current height before hiding const currentHeight = element.scrollHeight; // Set initial state for animation element.style.overflow = 'hidden'; element.style.height = currentHeight + 'px'; element.style.transition = `height ${animDuration}ms ease-out`; // Force reflow to ensure initial state is applied element.offsetHeight; // Animate to zero height element.style.height = '0px'; // Set up cleanup function const cleanup = () => { element.style.display = 'none'; element.style.height = originalStyles.height || ''; element.style.overflow = originalStyles.overflow || ''; element.style.transition = originalStyles.transition || ''; // Remove from running animations map this.runningAnimations.delete(element); if (callback && typeof callback === 'function') { try { callback.call(element); } catch (e) { console.error('SlideAnimations callback error:', e); } } }; // Store cleanup function for potential cancellation const timeoutId = setTimeout(cleanup, animDuration); this.runningAnimations.set(element, { timeoutId, cleanup }); }, /** * Stop all running animations on an element * @param {Element} element - DOM element to stop animations on */ stop: function(element) { if (!element) return; const animationData = this.runningAnimations.get(element); if (animationData) { // Clear the timeout clearTimeout(animationData.timeoutId); // Run cleanup immediately animationData.cleanup(); } // Clear transition to immediately stop any CSS animation element.style.transition = ''; // Force reflow to apply changes immediately element.offsetHeight; }, /** * Check if element has running animation * @param {Element} element - DOM element to check * @returns {boolean} - True if element has running animation */ isAnimating: function(element) { return this.runningAnimations.has(element); } }; /** * Argon Theme Menu Module * Handles rendering and interaction of the main navigation menu and sidebar */ return baseclass.extend({ /** * Initialize the menu module * Load menu data and trigger rendering */ __init__: function () { ui.menu.load().then(L.bind(this.render, this)); }, /** * Main render function for the menu system * @param {Object} tree - Menu tree structure from LuCI */ render: function (tree) { var node = tree, url = '', children = ui.menu.getChildren(tree); // Find and render the active main menu item for (var i = 0; i < children.length; i++) { var isActive = (L.env.requestpath.length ? children[i].name == L.env.requestpath[0] : i == 0); if (isActive) { this.renderMainMenu(children[i], children[i].name); } } // Render tab menu if we're deep enough in the navigation hierarchy if (L.env.dispatchpath.length >= 3) { for (var i = 0; i < 3 && node; i++) { node = node.children[L.env.dispatchpath[i]]; url = url + (url ? '/' : '') + L.env.dispatchpath[i]; } if (node) { this.renderTabMenu(node, url); } } // Attach event listeners for sidebar toggle functionality var sidebarToggle = document.querySelector('a.showSide'); var darkMask = document.querySelector('.darkMask'); if (sidebarToggle) { sidebarToggle.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle')); } if (darkMask) { darkMask.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle')); } }, /** * Handle menu expand/collapse functionality * Manages the sliding animation and active states of menu items * @param {Event} ev - Click event from menu item */ handleMenuExpand: function (ev) { var target = ev.target; var slide = target.parentNode; var slideMenu = target.nextElementSibling; var shouldCollapse = false; // Close all currently active submenus var activeMenus = document.querySelectorAll('.main .main-left .nav > li > ul.active'); activeMenus.forEach(function (ul) { // Stop any running animations and slide up SlideAnimations.stop(ul); // Remove active classes immediately when starting slideUp animation ul.classList.remove('active'); ul.previousElementSibling.classList.remove('active'); SlideAnimations.slideUp(ul, 'fast'); // Check if we're clicking on an already open menu (should collapse it) if (!shouldCollapse && ul === slideMenu) { shouldCollapse = true; } }); // Exit if there's no submenu to show if (!slideMenu) { return; } // Open the submenu if it's not already open if (!shouldCollapse) { // Find the slide menu within the slide element var slideMenuElement = slide.querySelector(".slide-menu"); if (slideMenuElement) { // Add active classes immediately when starting slideDown animation slideMenu.classList.add('active'); target.classList.add('active'); SlideAnimations.slideDown(slideMenuElement, 'fast'); } target.blur(); // Remove focus from the clicked element } // Prevent default link behavior and event bubbling ev.preventDefault(); ev.stopPropagation(); }, /** * Render the main navigation menu * Creates hierarchical menu structure with active states and click handlers * @param {Object} tree - Menu tree node to render * @param {string} url - Base URL for menu items * @param {number} level - Current nesting level (0-based) * @returns {Element} - Generated menu element */ renderMainMenu: function (tree, url, level) { var currentLevel = (level || 0) + 1; var menuContainer = E('ul', { 'class': level ? 'slide-menu' : 'nav' }); var children = ui.menu.getChildren(tree); // Don't render empty menus or menus deeper than 2 levels if (children.length === 0 || currentLevel > 2) { return E([]); } // Generate menu items for each child for (var i = 0; i < children.length; i++) { var child = children[i]; var isActive = ( (L.env.dispatchpath[currentLevel] === child.name) && (L.env.dispatchpath[currentLevel - 1] === tree.name) ); // Recursively render submenu var submenu = this.renderMainMenu(child, url + '/' + child.name, currentLevel); var hasChildren = submenu.children.length > 0; // Determine CSS classes based on state var slideClass = hasChildren ? 'slide' : null; var menuClass = hasChildren ? 'menu' : 'food'; if (isActive) { menuContainer.classList.add('active'); slideClass += " active"; menuClass += " active"; } // Create menu item with link and submenu var menuItem = E('li', { 'class': slideClass }, [ E('a', { 'href': L.url(url, child.name), 'click': (currentLevel === 1) ? ui.createHandlerFn(this, 'handleMenuExpand') : null, 'class': menuClass, 'data-title': child.title.replace(/ /g, "_"), // More robust space replacement }, [_(child.title)]), submenu ]); menuContainer.appendChild(menuItem); } // Append to main menu container if this is the top level if (currentLevel === 1) { var mainMenuElement = document.querySelector('#mainmenu'); if (mainMenuElement) { mainMenuElement.appendChild(menuContainer); mainMenuElement.style.display = ''; } } return menuContainer; }, /** * Render tab navigation menu * Creates horizontal tab menu for deeper navigation levels * @param {Object} tree - Menu tree node to render * @param {string} url - Base URL for tab items * @param {number} level - Current nesting level (0-based) * @returns {Element} - Generated tab menu element */ renderTabMenu: function (tree, url, level) { var container = document.querySelector('#tabmenu'); var currentLevel = (level || 0) + 1; var tabContainer = E('ul', { 'class': 'tabs' }); var children = ui.menu.getChildren(tree); var activeNode = null; // Don't render empty tab menus if (children.length === 0) { return E([]); } // Generate tab items for each child for (var i = 0; i < children.length; i++) { var child = children[i]; var isActive = (L.env.dispatchpath[currentLevel + 2] === child.name); var activeClass = isActive ? ' active' : ''; var className = 'tabmenu-item-%s %s'.format(child.name, activeClass); var tabItem = E('li', { 'class': className }, [ E('a', { 'href': L.url(url, child.name) }, [_(child.title)]) ]); tabContainer.appendChild(tabItem); // Store reference to active node for recursive rendering if (isActive) { activeNode = child; } } // Append tab container to main tab menu element if (container) { container.appendChild(tabContainer); container.style.display = ''; // Recursively render nested tab menus if there's an active node if (activeNode) { var nestedTabs = this.renderTabMenu(activeNode, url + '/' + activeNode.name, currentLevel); if (nestedTabs.children.length > 0) { container.appendChild(nestedTabs); } } } return tabContainer; }, /** * Handle sidebar toggle functionality * Toggles the mobile/responsive sidebar menu visibility * @param {Event} ev - Click event from sidebar toggle button or dark mask */ handleSidebarToggle: function (ev) { var showSideButton = document.querySelector('a.showSide'); var sidebar = document.querySelector('#mainmenu'); var darkMask = document.querySelector('.darkMask'); var scrollbarArea = document.querySelector('.main-right'); // Check if any required elements are missing if (!showSideButton || !sidebar || !darkMask || !scrollbarArea) { console.warn('Sidebar toggle elements not found'); return; } // Toggle sidebar visibility and related states if (showSideButton.classList.contains('active')) { // Close sidebar showSideButton.classList.remove('active'); sidebar.classList.remove('active'); scrollbarArea.classList.remove('active'); darkMask.classList.remove('active'); } else { // Open sidebar showSideButton.classList.add('active'); sidebar.classList.add('active'); scrollbarArea.classList.add('active'); darkMask.classList.add('active'); } } });