/* ========================================================================== 动效与菜单通用模板(vanilla JS,零依赖) - 来源:工业 B2B 企业站实践的 main.js,去掉所有站点专有字符串后整理。 - 渐进增强:HTML 默认完整可见、数字写最终值;本脚本只在条件满足时加隐藏态。 - 配套 CSS 见 design-tokens-template.css(.js .reveal.is-pending / reduced-motion)。 必须在 最前面放这一行内联脚本(早于 CSS 生效,避免闪烁): 本文件用 引入。 需要的 HTML 约定(类名可改,改了同步下面的 SELECTORS): 菜单按钮 导航 计数 12 对话框 … ========================================================================== */ '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. 原生 (如微信号、联系方式):打开/关闭/点遮罩关闭/焦点归还 showModal() 自带焦点限制与 Escape 关闭;关闭后焦点回到触发按钮。 -------------------------------------------------------------------------- */ (() => { document.querySelectorAll('[data-dialog]').forEach(trigger => { const dialog = document.getElementById(trigger.dataset.dialog); if (!dialog || typeof dialog.showModal !== 'function') return; trigger.addEventListener('click', () => { dialog.showModal(); dialog.addEventListener('close', () => trigger.focus({ preventScroll: true }), { once: true }); }); dialog.querySelector('.dialog-close')?.addEventListener('click', () => dialog.close()); dialog.addEventListener('click', e => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); const outside = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom; if (outside) dialog.close(); }); }); })(); /* -------------------------------------------------------------------------- 3. 滚动显现 + 数字递增 + 页头滚动状态(全站唯一一个 IntersectionObserver) -------------------------------------------------------------------------- */ (() => { const header = document.querySelector(SELECTORS.header); const counters = [...document.querySelectorAll(SELECTORS.counter)]; const running = new Map(); let observer = null; let countFrame = 0; let scrollFrame = 0; const visual = el => el.querySelector('.count-visual'); function setFinal(el) { const v = visual(el); if (v) v.textContent = el.dataset.count; // 终值来自 HTML,不改语义文本 .sr-only el.dataset.counted = 'true'; running.delete(el); } function tick(now) { if (reducedMotion.matches) { finishAll(); return; } running.forEach((state, el) => { const p = Math.min((now - state.start) / COUNT_DURATION, 1); const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic const v = visual(el); if (v) v.textContent = String(Math.round(state.value * eased)); if (p === 1) setFinal(el); }); countFrame = running.size ? requestAnimationFrame(tick) : 0; } function countIn(el) { if (el.dataset.counted || running.has(el)) return; const value = Number(el.dataset.count); // 只给「真实计数事实」做动画:非整数、规格、序号等直接显示终值。 // 如需白名单,在 HTML 上加 data-count-animate,并在这里判断。 if (reducedMotion.matches || !Number.isInteger(value) || el.hasAttribute('data-count-static')) { setFinal(el); return; } const v = visual(el); if (v) v.textContent = '0'; running.set(el, { value, start: performance.now() }); if (!countFrame) countFrame = requestAnimationFrame(tick); } function reveal(el, immediate = false) { if (immediate) el.classList.add('reveal-immediate'); el.classList.remove('is-pending'); observer?.unobserve(el); } function finishAll() { observer?.disconnect(); document.querySelectorAll('.reveal.is-pending').forEach(el => reveal(el, true)); counters.forEach(setFinal); cancelAnimationFrame(countFrame); countFrame = 0; header?.classList.remove('is-scrolled'); } // 页头:passive 监听 + rAF 节流,只读 scrollY,不改高度 function updateHeader() { header?.classList.toggle('is-scrolled', !reducedMotion.matches && window.scrollY > HEADER_SCROLL_Y); scrollFrame = 0; } if (header) { window.addEventListener('scroll', () => { if (!scrollFrame && !reducedMotion.matches) scrollFrame = requestAnimationFrame(updateHeader); }, { passive: true }); updateHeader(); } // 运行中切换「减少动态」也要立刻恢复终态 reducedMotion.addEventListener('change', e => { if (e.matches) finishAll(); }); // 降级条件:减少动态 / 截图模式 / 无 IntersectionObserver → 什么都不隐藏 if (reducedMotion.matches || isStatic || typeof window.IntersectionObserver !== 'function') { counters.forEach(setFinal); return; } const candidates = [...document.querySelectorAll(SELECTORS.reveal)]; const set = new Set(candidates); // 父子只动一个:祖先已是候选的元素剔除,避免透明度/位移叠加 const blocks = candidates.filter(el => { for (let p = el.parentElement; p; p = p.parentElement) if (set.has(p)) return false; return true; }); observer = new IntersectionObserver(entries => { entries.forEach(entry => { // 已在视口内,或已被快速滚过(top < 1),都立即处理,避免卡在隐藏态 if (!entry.isIntersecting && entry.boundingClientRect.top >= 1) return; if (entry.target.matches(SELECTORS.counter)) countIn(entry.target); else reveal(entry.target); observer.unobserve(entry.target); // 只播一次 }); }, { threshold: 0, rootMargin: '0px 0px -24px 0px' }); blocks.forEach(el => { if (el.parentElement?.matches(SELECTORS.staggerParents)) { const index = [...el.parentElement.children].indexOf(el); el.style.setProperty('--reveal-delay', `${(index % STAGGER_LEVELS) * STAGGER_STEP}ms`); } el.classList.add('reveal', 'is-pending'); // 隐藏态只在绑定观察器后添加 observer.observe(el); }); counters.forEach(el => observer.observe(el)); // 键盘焦点不能落在不可见的块上 document.addEventListener('focusin', e => { const block = e.target.closest?.('.reveal.is-pending'); if (!block) return; reveal(block, true); block.querySelectorAll(SELECTORS.counter).forEach(setFinal); }); })(); /* -------------------------------------------------------------------------- 4. 可选:视频只在可见时播放;减少动态/页面隐藏时暂停(先显示 poster) 为保持「唯一观察器」,这里用 rAF 节流的 passive scroll 检查。