mobile-menu.js

I’ll build this as a complete, reusable inc/js/mobile-menu.js for RX Theme: accessible mobile menu, dropdowns, keyboard support, overlay, body scroll lock, resize handling, focus trap, escape close, outside click, and safe fallbacks.

Yes friend — create this file:

rx-theme/inc/js/mobile-menu.js

This is an advanced mobile menu JavaScript file for RX Theme with many possible useful functions included.

/*!
 * RX Theme Mobile Menu
 * File: inc/js/mobile-menu.js
 * Theme: RX Theme
 * Author: RxHarun
 *
 * Features:
 * - Mobile menu open/close
 * - Accessible ARIA support
 * - Keyboard support
 * - Escape key close
 * - Outside click close
 * - Overlay support
 * - Body scroll lock
 * - Focus trap
 * - Dropdown/submenu toggle
 * - Multi-level menu support
 * - Auto close on resize
 * - Auto close on link click
 * - Sticky header helper class
 * - Active menu item helper
 * - Safe fallback if elements missing
 */

(function () {
  'use strict';

  /**
   * RX Mobile Menu Configuration
   */
  const RXMobileMenu = {
    selectors: {
      body: 'body',
      header: '.site-header',
      menuToggle: '.rx-mobile-menu-toggle',
      menuClose: '.rx-mobile-menu-close',
      mobileMenu: '.rx-mobile-menu',
      menuOverlay: '.rx-mobile-menu-overlay',
      menuWrapper: '.rx-mobile-menu-wrapper',
      menuLink: '.rx-mobile-menu a',
      menuItemHasChildren: '.rx-mobile-menu .menu-item-has-children',
      submenu: '.sub-menu',
      dropdownToggle: '.rx-submenu-toggle',
      focusableElements:
        'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
    },

    classes: {
      menuOpen: 'rx-mobile-menu-open',
      menuActive: 'is-active',
      submenuOpen: 'submenu-open',
      bodyLocked: 'rx-body-locked',
      overlayActive: 'is-active',
      headerSticky: 'rx-header-sticky',
      hasJS: 'rx-js-enabled',
      focusVisible: 'rx-focus-visible',
    },

    settings: {
      breakpoint: 1024,
      closeOnLinkClick: true,
      closeOnOutsideClick: true,
      closeOnEscape: true,
      trapFocus: true,
      lockBodyScroll: true,
      allowMultipleSubmenus: false,
      stickyHeader: true,
      stickyOffset: 60,
      transitionDuration: 300,
    },

    state: {
      isOpen: false,
      lastFocusedElement: null,
      scrollPosition: 0,
      firstFocusableElement: null,
      lastFocusableElement: null,
    },

    elements: {},

    /**
     * Initialize mobile menu
     */
    init: function () {
      this.cacheElements();
      this.addJSClass();

      if (!this.elements.menuToggle || !this.elements.mobileMenu) {
        return;
      }

      this.setupAccessibility();
      this.createOverlayIfMissing();
      this.createCloseButtonIfMissing();
      this.createSubmenuToggles();
      this.bindEvents();
      this.setActiveMenuItem();
      this.handleStickyHeader();

      document.dispatchEvent(
        new CustomEvent('rxMobileMenuReady', {
          detail: {
            menu: this.elements.mobileMenu,
          },
        })
      );
    },

    /**
     * Cache DOM elements
     */
    cacheElements: function () {
      this.elements.body = document.querySelector(this.selectors.body);
      this.elements.header = document.querySelector(this.selectors.header);
      this.elements.menuToggle = document.querySelector(this.selectors.menuToggle);
      this.elements.mobileMenu = document.querySelector(this.selectors.mobileMenu);
      this.elements.menuClose = document.querySelector(this.selectors.menuClose);
      this.elements.menuOverlay = document.querySelector(this.selectors.menuOverlay);
      this.elements.menuWrapper = document.querySelector(this.selectors.menuWrapper);
    },

    /**
     * Add JS enabled class
     */
    addJSClass: function () {
      if (this.elements.body) {
        this.elements.body.classList.add(this.classes.hasJS);
      }
    },

    /**
     * Setup accessibility attributes
     */
    setupAccessibility: function () {
      const menuId = this.elements.mobileMenu.id || 'rx-mobile-menu';

      this.elements.mobileMenu.id = menuId;
      this.elements.mobileMenu.setAttribute('aria-hidden', 'true');

      this.elements.menuToggle.setAttribute('aria-controls', menuId);
      this.elements.menuToggle.setAttribute('aria-expanded', 'false');
      this.elements.menuToggle.setAttribute('aria-label', 'Open mobile menu');
      this.elements.menuToggle.setAttribute('type', 'button');

      if (this.elements.menuClose) {
        this.elements.menuClose.setAttribute('aria-label', 'Close mobile menu');
        this.elements.menuClose.setAttribute('type', 'button');
      }
    },

    /**
     * Create overlay if not already available
     */
    createOverlayIfMissing: function () {
      if (!this.elements.menuOverlay) {
        const overlay = document.createElement('div');
        overlay.className = this.selectors.menuOverlay.replace('.', '');
        overlay.setAttribute('aria-hidden', 'true');

        document.body.appendChild(overlay);
        this.elements.menuOverlay = overlay;
      }
    },

    /**
     * Create close button if missing
     */
    createCloseButtonIfMissing: function () {
      if (!this.elements.menuClose && this.elements.mobileMenu) {
        const closeButton = document.createElement('button');
        closeButton.className = this.selectors.menuClose.replace('.', '');
        closeButton.setAttribute('type', 'button');
        closeButton.setAttribute('aria-label', 'Close mobile menu');
        closeButton.innerHTML = '<span aria-hidden="true">&times;</span>';

        this.elements.mobileMenu.insertBefore(
          closeButton,
          this.elements.mobileMenu.firstChild
        );

        this.elements.menuClose = closeButton;
      }
    },

    /**
     * Create submenu buttons for menu items with children
     */
    createSubmenuToggles: function () {
      const parentItems = document.querySelectorAll(
        this.selectors.menuItemHasChildren
      );

      parentItems.forEach((item, index) => {
        const link = item.querySelector('a');
        const submenu = item.querySelector(this.selectors.submenu);

        if (!submenu) {
          return;
        }

        const submenuId = submenu.id || 'rx-submenu-' + index;
        submenu.id = submenuId;
        submenu.setAttribute('aria-hidden', 'true');

        let toggle = item.querySelector(this.selectors.dropdownToggle);

        if (!toggle) {
          toggle = document.createElement('button');
          toggle.className = this.selectors.dropdownToggle.replace('.', '');
          toggle.setAttribute('type', 'button');
          toggle.setAttribute('aria-expanded', 'false');
          toggle.setAttribute('aria-controls', submenuId);
          toggle.setAttribute('aria-label', 'Open submenu');
          toggle.innerHTML =
            '<span class="rx-submenu-icon" aria-hidden="true"></span>';

          if (link) {
            link.insertAdjacentElement('afterend', toggle);
          } else {
            item.insertBefore(toggle, submenu);
          }
        }
      });
    },

    /**
     * Bind all events
     */
    bindEvents: function () {
      const self = this;

      this.elements.menuToggle.addEventListener('click', function (event) {
        event.preventDefault();
        self.toggleMenu();
      });

      if (this.elements.menuClose) {
        this.elements.menuClose.addEventListener('click', function (event) {
          event.preventDefault();
          self.closeMenu();
        });
      }

      if (this.elements.menuOverlay) {
        this.elements.menuOverlay.addEventListener('click', function () {
          if (self.settings.closeOnOutsideClick) {
            self.closeMenu();
          }
        });
      }

      document.addEventListener('click', function (event) {
        self.handleOutsideClick(event);
      });

      document.addEventListener('keydown', function (event) {
        self.handleKeyboard(event);
      });

      document.addEventListener('focusin', function (event) {
        self.handleFocusIn(event);
      });

      window.addEventListener(
        'resize',
        this.debounce(function () {
          self.handleResize();
        }, 150)
      );

      window.addEventListener(
        'scroll',
        this.throttle(function () {
          self.handleStickyHeader();
        }, 100)
      );

      const submenuToggles = document.querySelectorAll(
        this.selectors.dropdownToggle
      );

      submenuToggles.forEach((toggle) => {
        toggle.addEventListener('click', function (event) {
          event.preventDefault();
          event.stopPropagation();
          self.toggleSubmenu(toggle);
        });
      });

      const menuLinks = document.querySelectorAll(this.selectors.menuLink);

      menuLinks.forEach((link) => {
        link.addEventListener('click', function () {
          self.handleMenuLinkClick(link);
        });
      });
    },

    /**
     * Toggle menu
     */
    toggleMenu: function () {
      if (this.state.isOpen) {
        this.closeMenu();
      } else {
        this.openMenu();
      }
    },

    /**
     * Open menu
     */
    openMenu: function () {
      if (this.state.isOpen) {
        return;
      }

      this.state.lastFocusedElement = document.activeElement;
      this.state.isOpen = true;

      this.elements.body.classList.add(this.classes.menuOpen);

      this.elements.mobileMenu.classList.add(this.classes.menuActive);
      this.elements.mobileMenu.setAttribute('aria-hidden', 'false');

      this.elements.menuToggle.classList.add(this.classes.menuActive);
      this.elements.menuToggle.setAttribute('aria-expanded', 'true');
      this.elements.menuToggle.setAttribute('aria-label', 'Close mobile menu');

      if (this.elements.menuOverlay) {
        this.elements.menuOverlay.classList.add(this.classes.overlayActive);
      }

      if (this.settings.lockBodyScroll) {
        this.lockBodyScroll();
      }

      this.updateFocusableElements();

      const self = this;

      window.setTimeout(function () {
        self.focusFirstElement();
      }, 50);

      document.dispatchEvent(
        new CustomEvent('rxMobileMenuOpened', {
          detail: {
            menu: this.elements.mobileMenu,
          },
        })
      );
    },

    /**
     * Close menu
     */
    closeMenu: function () {
      if (!this.state.isOpen) {
        return;
      }

      this.state.isOpen = false;

      this.elements.body.classList.remove(this.classes.menuOpen);

      this.elements.mobileMenu.classList.remove(this.classes.menuActive);
      this.elements.mobileMenu.setAttribute('aria-hidden', 'true');

      this.elements.menuToggle.classList.remove(this.classes.menuActive);
      this.elements.menuToggle.setAttribute('aria-expanded', 'false');
      this.elements.menuToggle.setAttribute('aria-label', 'Open mobile menu');

      if (this.elements.menuOverlay) {
        this.elements.menuOverlay.classList.remove(this.classes.overlayActive);
      }

      if (this.settings.lockBodyScroll) {
        this.unlockBodyScroll();
      }

      this.closeAllSubmenus();

      if (
        this.state.lastFocusedElement &&
        typeof this.state.lastFocusedElement.focus === 'function'
      ) {
        this.state.lastFocusedElement.focus();
      }

      document.dispatchEvent(
        new CustomEvent('rxMobileMenuClosed', {
          detail: {
            menu: this.elements.mobileMenu,
          },
        })
      );
    },

    /**
     * Lock body scroll when menu is open
     */
    lockBodyScroll: function () {
      this.state.scrollPosition = window.pageYOffset || document.documentElement.scrollTop;

      this.elements.body.classList.add(this.classes.bodyLocked);
      this.elements.body.style.top = '-' + this.state.scrollPosition + 'px';
      this.elements.body.style.position = 'fixed';
      this.elements.body.style.width = '100%';
      this.elements.body.style.overflow = 'hidden';
    },

    /**
     * Unlock body scroll when menu is closed
     */
    unlockBodyScroll: function () {
      this.elements.body.classList.remove(this.classes.bodyLocked);
      this.elements.body.style.position = '';
      this.elements.body.style.top = '';
      this.elements.body.style.width = '';
      this.elements.body.style.overflow = '';

      window.scrollTo(0, this.state.scrollPosition);
    },

    /**
     * Toggle submenu
     */
    toggleSubmenu: function (toggle) {
      const parentItem = toggle.closest(this.selectors.menuItemHasChildren);
      const submenuId = toggle.getAttribute('aria-controls');
      const submenu = document.getElementById(submenuId);

      if (!parentItem || !submenu) {
        return;
      }

      const isOpen = parentItem.classList.contains(this.classes.submenuOpen);

      if (!this.settings.allowMultipleSubmenus) {
        this.closeSiblingSubmenus(parentItem);
      }

      if (isOpen) {
        this.closeSubmenu(parentItem, submenu, toggle);
      } else {
        this.openSubmenu(parentItem, submenu, toggle);
      }
    },

    /**
     * Open submenu
     */
    openSubmenu: function (parentItem, submenu, toggle) {
      parentItem.classList.add(this.classes.submenuOpen);
      submenu.setAttribute('aria-hidden', 'false');
      toggle.setAttribute('aria-expanded', 'true');
      toggle.setAttribute('aria-label', 'Close submenu');

      const submenuHeight = submenu.scrollHeight;
      submenu.style.maxHeight = submenuHeight + 'px';

      document.dispatchEvent(
        new CustomEvent('rxSubmenuOpened', {
          detail: {
            item: parentItem,
            submenu: submenu,
          },
        })
      );
    },

    /**
     * Close submenu
     */
    closeSubmenu: function (parentItem, submenu, toggle) {
      parentItem.classList.remove(this.classes.submenuOpen);
      submenu.setAttribute('aria-hidden', 'true');
      toggle.setAttribute('aria-expanded', 'false');
      toggle.setAttribute('aria-label', 'Open submenu');
      submenu.style.maxHeight = '';

      const childSubmenus = parentItem.querySelectorAll(
        '.' + this.classes.submenuOpen
      );

      childSubmenus.forEach((childItem) => {
        const childSubmenu = childItem.querySelector(this.selectors.submenu);
        const childToggle = childItem.querySelector(this.selectors.dropdownToggle);

        if (childSubmenu && childToggle) {
          this.closeSubmenu(childItem, childSubmenu, childToggle);
        }
      });

      document.dispatchEvent(
        new CustomEvent('rxSubmenuClosed', {
          detail: {
            item: parentItem,
            submenu: submenu,
          },
        })
      );
    },

    /**
     * Close sibling submenus
     */
    closeSiblingSubmenus: function (parentItem) {
      const siblings = Array.from(parentItem.parentElement.children).filter(
        (item) => item !== parentItem
      );

      siblings.forEach((sibling) => {
        if (sibling.classList.contains(this.classes.submenuOpen)) {
          const submenu = sibling.querySelector(this.selectors.submenu);
          const toggle = sibling.querySelector(this.selectors.dropdownToggle);

          if (submenu && toggle) {
            this.closeSubmenu(sibling, submenu, toggle);
          }
        }
      });
    },

    /**
     * Close all submenus
     */
    closeAllSubmenus: function () {
      const openedItems = document.querySelectorAll(
        this.selectors.menuItemHasChildren + '.' + this.classes.submenuOpen
      );

      openedItems.forEach((item) => {
        const submenu = item.querySelector(this.selectors.submenu);
        const toggle = item.querySelector(this.selectors.dropdownToggle);

        if (submenu && toggle) {
          this.closeSubmenu(item, submenu, toggle);
        }
      });
    },

    /**
     * Handle menu link click
     */
    handleMenuLinkClick: function (link) {
      const href = link.getAttribute('href');

      if (!this.settings.closeOnLinkClick) {
        return;
      }

      if (!href || href === '#') {
        return;
      }

      const parentItem = link.closest(this.selectors.menuItemHasChildren);
      const submenu = parentItem
        ? parentItem.querySelector(this.selectors.submenu)
        : null;

      if (submenu && link.nextElementSibling) {
        return;
      }

      this.closeMenu();
    },

    /**
     * Handle outside click
     */
    handleOutsideClick: function (event) {
      if (!this.state.isOpen || !this.settings.closeOnOutsideClick) {
        return;
      }

      const clickedInsideMenu = this.elements.mobileMenu.contains(event.target);
      const clickedToggle = this.elements.menuToggle.contains(event.target);

      if (!clickedInsideMenu && !clickedToggle) {
        this.closeMenu();
      }
    },

    /**
     * Handle keyboard events
     */
    handleKeyboard: function (event) {
      if (!this.state.isOpen) {
        return;
      }

      if (event.key === 'Escape' && this.settings.closeOnEscape) {
        this.closeMenu();
        return;
      }

      if (event.key === 'Tab' && this.settings.trapFocus) {
        this.trapFocus(event);
      }
    },

    /**
     * Update focusable elements
     */
    updateFocusableElements: function () {
      const focusableElements = this.elements.mobileMenu.querySelectorAll(
        this.selectors.focusableElements
      );

      const visibleFocusableElements = Array.from(focusableElements).filter(
        (element) => {
          return (
            element.offsetWidth > 0 ||
            element.offsetHeight > 0 ||
            element === document.activeElement
          );
        }
      );

      this.state.firstFocusableElement = visibleFocusableElements[0] || null;
      this.state.lastFocusableElement =
        visibleFocusableElements[visibleFocusableElements.length - 1] || null;
    },

    /**
     * Focus first element
     */
    focusFirstElement: function () {
      this.updateFocusableElements();

      if (this.state.firstFocusableElement) {
        this.state.firstFocusableElement.focus();
      } else {
        this.elements.mobileMenu.focus();
      }
    },

    /**
     * Trap focus inside mobile menu
     */
    trapFocus: function (event) {
      this.updateFocusableElements();

      const firstElement = this.state.firstFocusableElement;
      const lastElement = this.state.lastFocusableElement;

      if (!firstElement || !lastElement) {
        return;
      }

      if (event.shiftKey) {
        if (document.activeElement === firstElement) {
          event.preventDefault();
          lastElement.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          event.preventDefault();
          firstElement.focus();
        }
      }
    },

    /**
     * Handle focus inside menu
     */
    handleFocusIn: function (event) {
      if (!this.state.isOpen || !this.settings.trapFocus) {
        return;
      }

      if (
        this.elements.mobileMenu &&
        !this.elements.mobileMenu.contains(event.target) &&
        !this.elements.menuToggle.contains(event.target)
      ) {
        this.focusFirstElement();
      }
    },

    /**
     * Handle browser resize
     */
    handleResize: function () {
      if (window.innerWidth >= this.settings.breakpoint && this.state.isOpen) {
        this.closeMenu();
      }

      this.refreshOpenSubmenuHeights();
    },

    /**
     * Refresh submenu height after resize
     */
    refreshOpenSubmenuHeights: function () {
      const openedSubmenus = document.querySelectorAll(
        this.selectors.menuItemHasChildren +
          '.' +
          this.classes.submenuOpen +
          ' ' +
          this.selectors.submenu
      );

      openedSubmenus.forEach((submenu) => {
        submenu.style.maxHeight = submenu.scrollHeight + 'px';
      });
    },

    /**
     * Sticky header class
     */
    handleStickyHeader: function () {
      if (!this.settings.stickyHeader || !this.elements.header) {
        return;
      }

      const scrollTop =
        window.pageYOffset || document.documentElement.scrollTop || 0;

      if (scrollTop > this.settings.stickyOffset) {
        this.elements.header.classList.add(this.classes.headerSticky);
      } else {
        this.elements.header.classList.remove(this.classes.headerSticky);
      }
    },

    /**
     * Add current active helper to menu item
     */
    setActiveMenuItem: function () {
      const currentURL = window.location.href;
      const currentPath = window.location.pathname;
      const menuLinks = document.querySelectorAll(this.selectors.menuLink);

      menuLinks.forEach((link) => {
        const linkURL = link.href;
        const linkPath = link.pathname;

        if (linkURL === currentURL || linkPath === currentPath) {
          link.classList.add('rx-current-menu-link');

          const item = link.closest('li');
          if (item) {
            item.classList.add('rx-current-menu-item');
          }

          const parentItems = link.closest(this.selectors.menuItemHasChildren);

          if (parentItems) {
            parentItems.classList.add('rx-current-menu-parent');
          }
        }
      });
    },

    /**
     * Debounce helper
     */
    debounce: function (callback, delay) {
      let timer;

      return function () {
        const context = this;
        const args = arguments;

        clearTimeout(timer);

        timer = setTimeout(function () {
          callback.apply(context, args);
        }, delay);
      };
    },

    /**
     * Throttle helper
     */
    throttle: function (callback, limit) {
      let waiting = false;

      return function () {
        if (!waiting) {
          callback.apply(this, arguments);
          waiting = true;

          setTimeout(function () {
            waiting = false;
          }, limit);
        }
      };
    },
  };

  /**
   * Initialize when DOM is ready
   */
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', function () {
      RXMobileMenu.init();
    });
  } else {
    RXMobileMenu.init();
  }

  /**
   * Make available globally for advanced use
   */
  window.RXMobileMenu = RXMobileMenu;
})();

Use this HTML structure in your theme header:

<button class="rx-mobile-menu-toggle" type="button">
    <span class="rx-menu-icon"></span>
    <span class="screen-reader-text">Menu</span>
</button>

<nav class="rx-mobile-menu" role="navigation">
    <?php
    wp_nav_menu(array(
        'theme_location' => 'primary',
        'menu_class'     => 'rx-mobile-menu-list',
        'container'      => false,
    ));
    ?>
</nav>

Enqueue it in functions.php or inc/core/enqueue.php:

function rx_theme_enqueue_mobile_menu_script() {
    wp_enqueue_script(
        'rx-mobile-menu',
        get_template_directory_uri() . '/inc/js/mobile-menu.js',
        array(),
        wp_get_theme()->get('Version'),
        true
    );
}
add_action('wp_enqueue_scripts', 'rx_theme_enqueue_mobile_menu_script');

Helpful CSS starter:

.rx-mobile-menu {
    position: fixed;
    top: 0;
    right: -100%;
    width: 85%;
    max-width: 380px;
    height: 100vh;
    background: #fff;
    z-index: 99999;
    overflow-y: auto;
    transition: right 0.3s ease;
    padding: 24px;
}

.rx-mobile-menu.is-active {
    right: 0;
}

.rx-mobile-menu-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.55);
    z-index: 99998;
    opacity: 0;
    visibility: hidden;
    transition: 0.3s ease;
}

.rx-mobile-menu-overlay.is-active {
    opacity: 1;
    visibility: visible;
}

.rx-body-locked {
    overflow: hidden;
}

.rx-mobile-menu-close {
    display: block;
    margin-left: auto;
    font-size: 32px;
    background: transparent;
    border: 0;
    cursor: pointer;
}

.rx-mobile-menu .sub-menu {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.3s ease;
}

.rx-submenu-toggle {
    background: transparent;
    border: 0;
    cursor: pointer;
    margin-left: 8px;
}

.rx-submenu-icon::before {
    content: "+";
}

.submenu-open > a + .rx-submenu-toggle .rx-submenu-icon::before {
    content: "-";
}

This mobile-menu.js is safe for a professional WordPress theme and can grow with your RX Theme.

Leave a Reply

Your email address will not be published. Required fields are marked *