/
vgik
/
vgik.ru-web
Обзор
Документация
Войти
/
vgik
/
vgik.ru-web
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/scripts/components/_header.js
245 строк
10 KB
bsn24
Готовые страницы двух этапов верстки
03 мар 2026, 10:18
03 мар 2026, 10:18
5f2c567
Код
Авторство
О чём код?
import {slideDown, slideUp} from "@scripts/vendors/slide-toggle.js"; /** * Логика «липкого» хедера. * * Что делает: * - При прокрутке добавляет/убирает класс `.main-header--sticky` у шапки. * - Если хедер НЕ прозрачный (`.main-header--transparent` отсутствует), * добавляет `padding-top` к `<body>` на высоту хедера, чтобы избежать «скачка» контента. * - Порог срабатывания: * - если на странице есть секция `.js-first-section`, то после её высоты; * - иначе — после высоты самого хедера. * * Оптимизации производительности: * - Обработчик скролла пассивный (`{ passive: true }`). * - Чтение/запись в DOM делается внутри `requestAnimationFrame` (pattern rAF-ticking), * чтобы уменьшить количество перерасчётов и не делать лишние операции. * - Изменяем стиль `body` и класс хедера только при фактическом изменении состояния (sticky/non-sticky). */ export const stickyHeader = () => { // Кэшируем нужные элементы const firstSection = document.querySelector('.js-first-section'); const headerEl = document.querySelector('.main-header'); const body = document.body; // Если хедера нет — выходим тихо if (!headerEl) return; // Хелпер для проверки прозрачности хедера (в прозрачном режиме паддинг не добавляем) const isTransparent = () => headerEl.classList.contains('main-header--transparent'); // Текущее sticky‑состояние, чтобы не дергать DOM без необходимости let isSticky = false; // Высота первой секции может зависеть от ресайза. Держим в кэше и обновляем по resize. let firstSectionHeight = firstSection ? firstSection.clientHeight : null; const updateFirstSectionHeight = () => { if (firstSection) firstSectionHeight = firstSection.clientHeight; }; // rAF-троттлинг флага let ticking = false; const measureAndUpdate = () => { const scrollY = window.scrollY || window.pageYOffset; const headerHeight = headerEl.clientHeight; // Определяем, нужно ли «приклеить» хедер const shouldStick = firstSection ? (firstSectionHeight || 0) < scrollY : headerHeight < scrollY; // Меняем состояние только при необходимости if (shouldStick !== isSticky) { headerEl.classList.toggle('main-header--sticky', shouldStick); // Добавляем/сбрасываем верхний отступ у body только если хедер не прозрачный if (!isTransparent()) { body.style.paddingTop = shouldStick ? headerHeight + 'px' : '0'; } isSticky = shouldStick; } }; const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(() => { measureAndUpdate(); ticking = false; }); } }; // Пересчитываем порог на ресайзах и сразу обновляем состояние const onResize = () => { updateFirstSectionHeight(); onScroll(); }; // Инициализация: проставим корректное состояние без ожидания первого скролла onResize(); // Пассивные слушатели для лучшей прокрутки на мобильных window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onResize); } /** * Инициализация главного меню (десктоп/мобайл). * * Поведение: * - > 760px (десктоп): * - клик по `.main-nav__btn` активирует пункт и клонирует его `.main-nav__sub-menu` в `.main-nav__panel`. * - ≤ 760px (мобайл): * - аккордеон — клик по `.main-nav__btn` раскрывает свой `.main-nav__sub-menu` (slideDown/slideToggle), * при этом ранее открытый пункт закрывается (slideUp). * * Технически: * - Один делегированный обработчик на список `ul.main-nav__list`, логика ветвится по брейкпоинту. * - На переключении режимов чистим инлайн-стили у подменю и сбрасываем состояние. */ export const mainNavToggle = () => { const root = document.querySelector('.main-header__menu'); if (!root) return; const nav = root.querySelector('.main-nav__list'); const panel = root.querySelector('.main-nav__panel'); if (!nav || !panel) return; // Брейкпоинт: до и включая 760px — мобильный режим const mq = window.matchMedia('(max-width: 760px)'); // Кэш подменю const items = Array.from(nav.querySelectorAll('.main-nav__item')); const submenus = items.map((it) => it.querySelector('.main-nav__sub-menu')).filter(Boolean); // Вспомогательное: снять активность со всех пунктов const clearActive = () => { nav.querySelectorAll('.main-nav__item--active').forEach((el) => el.classList.remove('main-nav__item--active')); }; // Вспомогательное: очистить панель const clearPanel = () => { panel.replaceChildren(); }; // Вспомогательное: проставить активный пункт (десктоп) const setActiveDesktop = (item) => { clearActive(); item.classList.add('main-nav__item--active'); const submenu = item.querySelector('.main-nav__sub-menu'); if (submenu) { // Клонируем, чтобы не «вырывать» элемент из DOM колонки panel.replaceChildren(submenu.cloneNode(true)); } else { clearPanel(); } }; // Сброс инлайн‑стилей, которые могли остаться после slide-анимаций (для корректного свитча режимов) const resetInlineStyles = () => { submenus.forEach((el) => { if (!el) return; el.style.height = ''; el.style.paddingTop = ''; el.style.paddingBottom = ''; el.style.marginTop = ''; el.style.marginBottom = ''; el.style.overflow = ''; el.style.display = ''; }); }; // Инициализация десктопного состояния const initDesktop = () => { // Закрываем все подменю визуально (на десктопе они не используются напрямую) resetInlineStyles(); // Активируем пункт с классом по умолчанию либо первый const currentActive = nav.querySelector('.main-nav__item--active') || nav.querySelector('.main-nav__item'); if (currentActive) { setActiveDesktop(currentActive); } else { clearPanel(); } }; // Инициализация мобильного состояния (аккордеон) const initMobile = () => { // Сбрасываем активные классы — стартуем со свёрнутыми пунктами clearActive(); // Все подменю скрыть submenus.forEach((el) => { if (!el) return; el.style.display = 'none'; el.style.height = ''; el.style.paddingTop = ''; el.style.paddingBottom = ''; el.style.marginTop = ''; el.style.marginBottom = ''; el.style.overflow = ''; }); // Панель справа на мобиле не используется clearPanel(); }; // Текущее состояние режима let isMobile = mq.matches; // Первичная инициализация if (isMobile) initMobile(); else initDesktop(); // Делегирование клика по кнопкам меню nav.addEventListener('click', (e) => { const btn = e.target.closest('.main-nav__btn'); if (!btn) return; const item = btn.closest('.main-nav__item'); if (!item) return; if (isMobile) { // Аккордеон const currentSub = item.querySelector('.main-nav__sub-menu'); if (!currentSub) return; const wasActive = item.classList.contains('main-nav__item--active'); // Закрыть ранее открытый пункт (если он другой) const openedItem = nav.querySelector('.main-nav__item--active'); if (openedItem && openedItem !== item) { const openedSub = openedItem.querySelector('.main-nav__sub-menu'); if (openedSub) slideUp(openedSub, 300); openedItem.classList.remove('main-nav__item--active'); } // Тоггл для текущего if (wasActive) { // Был открыт — закрываем slideUp(currentSub, 300); item.classList.remove('main-nav__item--active'); } else { // Был закрыт — открываем slideDown(currentSub, 300); item.classList.add('main-nav__item--active'); } } else { // Десктоп: активируем и копируем подменю в панель setActiveDesktop(item); } }); // Переключение режимов на ресайзе/изменении медиа‑запроса mq.addEventListener('change', (ev) => { isMobile = ev.matches; if (isMobile) { initMobile(); } else { initDesktop(); } }); };