'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 }, getDuration: function(duration) { if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return 0; return typeof duration === 'string' ? (this.durations[duration] || this.durations.normal) : (duration || this.durations.normal); }, /** * 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 = this.getDuration(duration); // 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 = this.getDuration(duration); // 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 () { this.bindSidebarToggle(); this.patchDynamicListFocus(); ui.menu.load().then(L.bind(this.render, this)); }, patchDynamicListFocus: function () { var prototype = ui.Dropdown && ui.Dropdown.prototype; var setFocus = prototype && prototype.setFocus; if (typeof setFocus !== 'function' || setFocus.argonDynamicListGuard) return; var guardedFocus = function (node, item) { // A DynamicList change handler can consume a newly created option // before Dropdown.createItems() tries to scroll and focus it. if (item && !item.parentNode && node && node.closest('.cbi-dynlist')) return; return setFocus.apply(this, arguments); }; guardedFocus.argonDynamicListGuard = true; prototype.setFocus = guardedFocus; }, bindSidebarToggle: function () { var sidebarToggle = document.querySelector('.showSide'); var darkMask = document.querySelector('.darkMask'); // Navigation is synchronous: keep its controls enabled and focused. var toggle = L.bind(this.handleSidebarToggle, this); if (sidebarToggle) sidebarToggle.addEventListener('click', toggle); if (darkMask) darkMask.addEventListener('click', toggle); document.addEventListener('keydown', function(ev) { if (ev.key === 'Escape' && sidebarToggle && sidebarToggle.classList.contains('active')) { sidebarToggle.click(); sidebarToggle.focus(); } }); }, /** * Main render function for the menu system * @param {Object} tree - Menu tree structure from LuCI */ render: function (tree) { var node = tree; var url = ''; this.renderModeMenu(node); // 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++) { // A cached menu can predate the current page's route. node = node.children ? node.children[L.env.dispatchpath[i]] : null; url = url + (url ? '/' : '') + L.env.dispatchpath[i]; } if (node) { this.renderTabMenu(node, url); } } }, /** * 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) { // The click may originate on the SVG icon inside the navigation link. var target = ev.currentTarget; 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 */ renderMenuIcon: function (name) { var catalog = document.querySelector('meta[name="argon-menu-icons"]'); var icons = catalog ? (catalog.getAttribute('content') || '').split(/\s+/) : []; var filename = /^[a-zA-Z0-9_-]+$/.test(name) && icons.indexOf(name + '.svg') !== -1 ? name + '.svg' : 'default.svg'; return E('span', { 'class': 'menu-icon', 'aria-hidden': 'true', 'data-icon': filename.slice(0, -4), 'style': '--menu-icon: url("' + L.media('icon', filename) + '")' }); }, 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 }, currentLevel === 1 ? [this.renderMenuIcon(child.name), _(child.title)] : [_(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; }, renderModeMenu: function (tree) { var menu = document.querySelector('#modemenu'); var children = ui.menu.getChildren(tree); for (var i = 0; i < children.length; i++) { var isActive = (L.env.requestpath.length ? children[i].name == L.env.requestpath[0] : i == 0); if (i > 0) menu.appendChild(E([], ['\u00a0|\u00a0'])); menu.appendChild(E('li', {}, [ E('a', { 'href': L.url(children[i].name), 'class': isActive ? 'active' : null }, [_(children[i].title)]) ])); if (isActive) this.renderMainMenu(children[i], children[i].name); } if (menu.children.length > 1) menu.style.display = ''; }, /** * 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) this.renderTabMenu(activeNode, url + '/' + activeNode.name, currentLevel); } 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('.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'); showSideButton.setAttribute('aria-expanded', 'false'); sidebar.classList.remove('active'); scrollbarArea.classList.remove('active'); darkMask.classList.remove('active'); } else { // Open sidebar showSideButton.classList.add('active'); showSideButton.setAttribute('aria-expanded', 'true'); sidebar.classList.add('active'); scrollbarArea.classList.add('active'); darkMask.classList.add('active'); } } });