/* ==========================================================================
动效与菜单通用模板(vanilla JS,零依赖)
- 来源:工业 B2B 企业站实践的 main.js,去掉所有站点专有字符串后整理。
- 渐进增强:HTML 默认完整可见、数字写最终值;本脚本只在条件满足时加隐藏态。
- 配套 CSS 见 design-tokens-template.css(.js .reveal.is-pending / reduced-motion)。
必须在
最前面放这一行内联脚本(早于 CSS 生效,避免闪烁):
本文件用 引入。
需要的 HTML 约定(类名可改,改了同步下面的 SELECTORS):
菜单按钮
导航
计数 1212
对话框
========================================================================== */
'use strict';
const SELECTORS = {
header: '.site-header',
menuToggle: '.menu-toggle',
nav: '#navigation',
inertWhenMenuOpen: 'main, footer, .mobile-bar',
// 只列「块」,脚本会自动去掉嵌套(父子只动一个)
reveal: 'main .section-heading, main .reveal-block, .gallery > figure, .fact-strip > *, .faq-list > details, .closing-inner > *',
// 这些容器里的子项按顺序错开 0/60/120/180ms
staggerParents: '.gallery, .fact-strip, .stagger',
counter: '[data-count]'
};
const MOBILE_MENU_QUERY = '(max-width: 1279.98px)';
const COUNT_DURATION = 1100; // ms
const STAGGER_STEP = 60; // ms
const STAGGER_LEVELS = 4;
const HEADER_SCROLL_Y = 24; // px
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
const isStatic = document.documentElement.classList.contains('is-static');
/* --------------------------------------------------------------------------
1. 移动菜单:aria-expanded、Escape 关闭并还焦点、Tab 循环、inert、滚动锁定
无 JS 时 CSS(html:not(.js) nav { display:flex })直接显示导航。
-------------------------------------------------------------------------- */
(() => {
const toggle = document.querySelector(SELECTORS.menuToggle);
const nav = document.querySelector(SELECTORS.nav);
if (!toggle || !nav) return;
const mobile = matchMedia(MOBILE_MENU_QUERY);
let savedScrollY = 0;
function setMenu(open, returnFocus = false) {
open = open && mobile.matches;
const wasOpen = toggle.getAttribute('aria-expanded') === 'true';
if (open && !wasOpen) {
savedScrollY = window.scrollY;
document.body.style.setProperty('--menu-scroll-y', `-${savedScrollY}px`); // CSS: body.nav-open{position:fixed;top:var(--menu-scroll-y);width:100%}
}
toggle.setAttribute('aria-expanded', String(open));
if (toggle.dataset.labelOpen && toggle.dataset.labelClose) {
toggle.setAttribute('aria-label', open ? toggle.dataset.labelClose : toggle.dataset.labelOpen);
}
nav.classList.toggle('open', open);
document.documentElement.classList.toggle('nav-open', open);
document.body.classList.toggle('nav-open', open);
document.querySelectorAll(SELECTORS.inertWhenMenuOpen).forEach(el => { el.inert = open; });
if (!open && wasOpen) {
document.body.style.removeProperty('--menu-scroll-y');
window.scrollTo(0, savedScrollY);
if (returnFocus) toggle.focus({ preventScroll: true });
}
}
toggle.addEventListener('click', () => setMenu(toggle.getAttribute('aria-expanded') !== 'true'));
document.addEventListener('keydown', e => {
if (toggle.getAttribute('aria-expanded') !== 'true') return;
if (e.key === 'Escape') { setMenu(false, true); return; }
if (e.key === 'Tab') {
const controls = [toggle, ...nav.querySelectorAll('a, button')];
const first = controls[0];
const last = controls[controls.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
nav.querySelectorAll('a').forEach(a => a.addEventListener('click', () => setMenu(false)));
mobile.addEventListener('change', e => { if (!e.matches) setMenu(false); });
})();
/* --------------------------------------------------------------------------
2. 原生