export class Nav {
    constructor() {
        this.init();
    }

    init() {
        const navToggle = document.querySelector('.nav-toggle');
        const headerCenter = document.querySelector('.header-center');
        const overlay = document.querySelector('.mobile-menu-overlay');
        const loginBtn = document.querySelector('#js-nav-login');

        // Click Event for Activation (Frontend Logic)
        document.addEventListener('click', (e) => {
            // 1. Handle Level 2 Links (Dropdown items)
            const subLink = e.target.closest('.nav-dropdown__item');
            if (subLink) {
                // Remove active class from all dropdown items
                document.querySelectorAll('.nav-dropdown__item').forEach(el => el.classList.remove('active'));
                subLink.classList.add('active');

                // Activate parent Level 1 item
                const parentItem = subLink.closest('.nav-menu__item');
                if (parentItem) {
                    // Remove active from all Level 1 items
                    document.querySelectorAll('.nav-menu__item').forEach(item => item.classList.remove('nav-menu__item--active'));
                    parentItem.classList.add('nav-menu__item--active');
                }
                return;
            }

            // 2. Handle Level 1 Links
            // Note: We check if it is an Anchor tag to avoid activating on "More" span click which is just a toggle
            const link = e.target.closest('.nav-menu__link');
            if (link && link.tagName === 'A') {
                const item = link.closest('.nav-menu__item');
                if (item) {
                    // Remove active from all Level 1 items
                    document.querySelectorAll('.nav-menu__item').forEach(el => el.classList.remove('nav-menu__item--active'));
                    item.classList.add('nav-menu__item--active');

                    // Clear any Level 2 active states as we moved to a Level 1 page
                    document.querySelectorAll('.nav-dropdown__item').forEach(el => el.classList.remove('active'));
                }
            }
        });

        // Mobile Menu Toggle
        if (navToggle) {
            if (!navToggle.dataset.navBound) {
                navToggle.addEventListener('click', (e) => {
                    e.stopPropagation();
                    const isOpen = headerCenter.classList.contains('is-open');
                    this.toggleMenu(!isOpen, headerCenter, navToggle, overlay);
                });
                navToggle.dataset.navBound = "true";
            }
        }        

        if (overlay && !overlay.dataset.navBound) {
            overlay.addEventListener('click', () => this.toggleMenu(false, headerCenter, navToggle, overlay));
            overlay.dataset.navBound = "true";
        }

        // Global keydown (Escape) - bind only once
        if (!document.body.dataset.navKeyBound) {
            document.addEventListener('keydown', (e) => {
                const center = document.querySelector('.header-center');
                if (e.key === 'Escape' && center?.classList.contains('is-open')) {
                    // Find current elements again as closure might be stale
                    const toggle = document.querySelector('.nav-toggle');
                    const ov = document.querySelector('.mobile-menu-overlay');
                    this.toggleMenu(false, center, toggle, ov);
                }
            });
            document.body.dataset.navKeyBound = "true";
        }

        // Mobile Submenu Toggle - Event Delegation
        // Bind to headerCenter instead of individual links to handle dynamic changes
        if (headerCenter && !headerCenter.dataset.navSubBound) {
            headerCenter.addEventListener('click', (e) => {
                // Check if we are in mobile mode
                if (window.innerWidth > 900) return;

                const link = e.target.closest('.nav-menu__link');
                if (!link) return;

                const parent = link.closest('.nav-menu__item--has-sub');
                if (parent && parent.contains(link)) {
                    // It is a submenu toggle
                    e.preventDefault();
                    e.stopPropagation(); // Stop bubbling to prevent immediate closing issues
                    parent.classList.toggle('nav-menu__item--expanded');
                }
            });
            headerCenter.dataset.navSubBound = "true";
        }

        // Login Handling
        if (loginBtn && !loginBtn.dataset.navBound) {
            loginBtn.addEventListener('click', () => {
                const currentUrl = window.location.href;
                window.location.href = `/connect/oidc?fromUrl=${encodeURIComponent(currentUrl)}`;
            });
            loginBtn.dataset.navBound = "true";
        }
    }

    closeMobileMenu() {
        const headerCenter = document.querySelector('.header-center');
        const navToggle = document.querySelector('.nav-toggle');
        const overlay = document.querySelector('.mobile-menu-overlay');
        this.toggleMenu(false, headerCenter, navToggle, overlay);
    }

    toggleMenu(show, menu, toggle, overlay) {
        if (!menu) return;
        const action = show ? 'add' : 'remove';
        menu.classList[action]('is-open');
        if (toggle) {
            toggle.classList[action]('nav-toggle--active');
            toggle.setAttribute('aria-expanded', show);
        }
        if (overlay) overlay.classList[action]('is-open');
        document.body.style.overflow = show ? 'hidden' : '';
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这是一个用于网站导航(包含桌面下拉菜单与移动端抽屉式菜单)交互逻辑的 JavaScript 类 Nav。总体目的是集中管理导航的点击事件、移动端菜单切换、子菜单展开、登录按钮跳转与键盘关闭等行为,并避免重复绑定事件。下面分部分说明每块代码的功能与要点。

总体结构

  • export class Nav:定义一个导航类,构造函数调用 this.init() 初始化绑定事件与行为。
  • 通过 data-* 属性(如 dataset.navBound、dataset.navSubBound、document.body.dataset.navKeyBound)避免重复绑定事件。

init() 关键点

  • 查询常用元素:
    • navToggle:移动端菜单的切换按钮(.nav-toggle)。
    • headerCenter:包含导航内容的容器(.header-center)。
    • overlay:移动端菜单打开时的遮罩(.mobile-menu-overlay)。
    • loginBtn:导航中的登录按钮(#js-nav-login)。
  1. 全局 document click(事件委托,用于管理激活状态)
  1. 移动菜单切换(navToggle)
  • 给 .nav-toggle 绑定 click(仅绑定一次,使用 dataset.navBound 标记);
  • click 时阻止冒泡(e.stopPropagation()),读取当前 headerCenter 是否包含 .is-open,然后调用 this.toggleMenu 切换;
  • 防止 document 的其他 click 逻辑与此冲突。
  1. 遮罩 overlay 点击关闭
  • overlay click 时关闭菜单(同样避免重复绑定)。
  1. 全局键盘监听(Escape)
  • 仅绑定一次(document.body.dataset.navKeyBound);
  • 按下 Escape 且菜单打开(.header-center 有 .is-open)时,重新查询 toggle 与 overlay,调用 toggleMenu(false, ... ) 关闭菜单。
  • 重新查询元素是为了避免闭包中元素失效(“closure might be stale” 注释)。
  1. 移动子菜单折叠/展开(事件委托到 headerCenter)
  • 只在移动视图下生效(if (window.innerWidth > 900) return;);
  • 点击 .nav-menu__link,如果该链接属于具有子菜单的一级项(.nav-menu__item--has-sub),则:
    • 阻止默认导航(e.preventDefault())和阻止冒泡(e.stopPropagation());
    • 切换父元素的类 nav-menu__item--expanded,用于在移动端展开/收起子菜单。
  • 绑定一次(headerCenter.dataset.navSubBound)。
  1. 登录按钮跳转
  • 点击 #js-nav-login 时重定向到 /connect/oidc?fromUrl=当前页面(使用 encodeURIComponent),并用 dataset 标记避免重复绑定。

closeMobileMenu()

  • 对外方法:再次查询元素并调用 toggleMenu(false, ...) 以关闭移动菜单(可供外部调用)。

toggleMenu(show, menu, toggle, overlay)

  • 通用的开/关菜单方法:
    • 根据 show 增删 menu 的 is-open 类;
    • 更新 toggle 的 nav-toggle--active 类并设置 aria-expanded 属性(便于无障碍访问);
    • overlay 的 is-open 类;
    • 控制 document.body.style.overflow:打开菜单时设为 hidden 以阻止背景滚动,关闭时恢复为空字符串。

预期的 DOM/CSS 约定(代码依赖)

  • .header-center:菜单容器,使用 .is-open 表示移动菜单打开。
  • .nav-toggle:移动菜单按钮,使用 nav-toggle--active 表示激活状态,同时设置 aria-expanded。
  • .mobile-menu-overlay:遮罩,使用 is-open 显示。
  • .nav-menu__item、.nav-menu__item--has-sub、.nav-menu__item--expanded:一级菜单项、含子菜单的标记以及展开样式。
  • .nav-dropdown__item:二级菜单项,使用 active 表示当前选中。
  • .nav-menu__link:一级链接(代码检查 tagName === 'A' 来区分点击是否为实际链接)。

其他注意点/设计考虑

  • 使用事件委托有利于动态 DOM(不必对每个子元素单独绑定)。
  • 使用 dataset 标记避免重复绑定,防止重复事件处理或内存泄漏(在单页应用中尤为重要)。
  • 通过更新 aria-expanded 和 body overflow,考虑了无障碍与体验一致性。
  • 在键盘事件里重新查询元素(避免闭包内引用 stale 元素),这是个细节性防护。

如果你希望,我可以:

  • 给出该类配合的示例 HTML 结构;
  • 指出潜在的改进(比如响应窗口大小变化时的行为、可访问性增强、节流 resize 处理等)。
评论加载中...