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)。
- 全局 document click(事件委托,用于管理激活状态)
- 处理二级下拉项(Level 2):当点击 .nav-dropdown__item(或其子节点)时:
- 清除所有 .nav-dropdown__item 的 active 类,再给当前点击项添加 active;
- 找到其父级 .nav-menu__item(一级菜单项),清除所有一级项的 nav-menu__item--active,再给当前父项加上该类;
- return 阻止后续一级链接处理。
- 处理一级链接(Level 1):当点击 .nav-menu__link 且它是 标签时:
- 将对应 .nav-menu__item 标记为 nav-menu__item--active(并先清除其他一级项的该类);
- 清除所有二级 .nav-dropdown__item 的 active 状态。
- 注意:使用 e.target.closest() 进行事件委托,便于动态 DOM 情况下也能工作。
- 移动菜单切换(navToggle)
- 给 .nav-toggle 绑定 click(仅绑定一次,使用 dataset.navBound 标记);
- click 时阻止冒泡(e.stopPropagation()),读取当前 headerCenter 是否包含 .is-open,然后调用 this.toggleMenu 切换;
- 防止 document 的其他 click 逻辑与此冲突。
- 遮罩 overlay 点击关闭
- overlay click 时关闭菜单(同样避免重复绑定)。
- 全局键盘监听(Escape)
- 仅绑定一次(document.body.dataset.navKeyBound);
- 按下 Escape 且菜单打开(.header-center 有 .is-open)时,重新查询 toggle 与 overlay,调用 toggleMenu(false, ... ) 关闭菜单。
- 重新查询元素是为了避免闭包中元素失效(“closure might be stale” 注释)。
- 移动子菜单折叠/展开(事件委托到 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)。
- 登录按钮跳转
- 点击 #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 处理等)。
评论加载中...