/** * @module autoScrollerHandler * @description Автоматический горизонтальный скроллер для галереи элементов. * Поддерживает бесконечный цикл, паузу при наведении, центрирование элемента. * * Пример HTML: *
*
*
...
*
...
*
*
* * Использование: * import { initAutoScroller } from './autoScrollerHandler.js'; * initAutoScroller({ selector: '.js-join-scroller' }); */ const DEFAULT_STEP_MS = 3000; const SCROLL_BEHAVIOR = 'smooth'; /** * Центрирует элемент в видимой области контейнера. * @param {HTMLElement} container - Контейнер со скроллом * @param {HTMLElement} item - Элемент для центрирования */ function centerItemInView(container, item) { const itemRect = item.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const itemCenter = itemRect.left + itemRect.width / 2; const containerCenter = containerRect.left + containerRect.width / 2; const delta = itemCenter - containerCenter; container.scrollTo({ left: container.scrollLeft + delta, behavior: SCROLL_BEHAVIOR }); } /** * Вычисляет шаг скролла (ширина элемента + gap). * @param {HTMLElement} rail - Элемент с flex-контейнером * @returns {number} Размер шага в пикселях */ function computeScrollStep(rail) { const firstChild = rail.firstElementChild; if (!firstChild) return 0; const rect = firstChild.getBoundingClientRect(); const gap = parseFloat(getComputedStyle(rail).gap || '0') || 0; return Math.round(rect.width + gap); } /** * Инициализирует автоскроллер для одного контейнера. * @param {HTMLElement} wrapper - Обёртка со скроллом * @param {Object} options - Опции * @param {number} [options.stepMs=3000] - Интервал между прокрутками в миллисекундах * @returns {Object} API с методами start, stop, destroy */ function initSingleScroller(wrapper, options = {}) { const { stepMs = DEFAULT_STEP_MS } = options; const rail = wrapper.querySelector('.join-rail'); if (!rail) { console.warn('initAutoScroller: .join-rail не найден'); return { start: () => {}, stop: () => {}, destroy: () => {} }; } // Проверяем, нужен ли скролл (контент переполняет контейнер) const hasOverflow = rail.scrollWidth > wrapper.clientWidth + 4; if (!hasOverflow) { return { start: () => {}, stop: () => {}, destroy: () => {} }; } // Дублируем элементы для бесшовного цикла const originalChildren = Array.from(rail.children); const clones = originalChildren.map(node => node.cloneNode(true)); rail.append(...clones); // Состояние let scrollStep = computeScrollStep(rail); const originalCount = originalChildren.length; let timer = null; let paused = false; let resizeFrame = null; /** * Один шаг скролла. */ function tick() { if (paused) return; const maxScroll = scrollStep * originalCount; // Если достигли конца первой копии - прыгаем в начало if (wrapper.scrollLeft >= maxScroll - 2) { wrapper.scrollLeft = 0; } wrapper.scrollBy({ left: scrollStep, behavior: SCROLL_BEHAVIOR }); } /** * Запускает автоматическую прокрутку. */ function start() { if (!timer) { timer = setInterval(tick, stepMs); } } /** * Останавливает автоматическую прокрутку. */ function stop() { if (timer) { clearInterval(timer); timer = null; } } /** * Обработчик наведения на элемент. */ function handleItemMouseEnter(event) { paused = true; centerItemInView(wrapper, event.currentTarget); } /** * Обработчик ухода курсора с элемента. */ function handleItemMouseLeave() { paused = false; } /** * Обработчик получения фокуса элементом. */ function handleItemFocusIn(event) { paused = true; centerItemInView(wrapper, event.currentTarget); } /** * Обработчик потери фокуса элементом. */ function handleItemFocusOut() { paused = false; } /** * Обработчик изменения размера окна. */ function handleResize() { cancelAnimationFrame(resizeFrame); resizeFrame = requestAnimationFrame(() => { scrollStep = computeScrollStep(rail); }); } /** * Обработчик изменения видимости вкладки. */ function handleVisibilityChange() { if (document.hidden) { stop(); } else { start(); } } // Устанавливаем обработчики на все элементы const items = rail.querySelectorAll('.join-item'); items.forEach(item => { item.addEventListener('mouseenter', handleItemMouseEnter); item.addEventListener('mouseleave', handleItemMouseLeave); item.addEventListener('focusin', handleItemFocusIn); item.addEventListener('focusout', handleItemFocusOut); }); // Глобальные обработчики window.addEventListener('resize', handleResize); document.addEventListener('visibilitychange', handleVisibilityChange); // Проверяем prefers-reduced-motion const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!prefersReducedMotion) { start(); } /** * Очистка всех обработчиков и таймеров. */ function destroy() { stop(); items.forEach(item => { item.removeEventListener('mouseenter', handleItemMouseEnter); item.removeEventListener('mouseleave', handleItemMouseLeave); item.removeEventListener('focusin', handleItemFocusIn); item.removeEventListener('focusout', handleItemFocusOut); }); window.removeEventListener('resize', handleResize); document.removeEventListener('visibilitychange', handleVisibilityChange); cancelAnimationFrame(resizeFrame); // Удаляем клонированные элементы clones.forEach(clone => clone.remove()); } return { start, stop, destroy }; } /** * Инициализирует автоскроллеры для всех элементов по селектору. * @param {Object} options - Опции * @param {string} [options.selector='.js-join-scroller'] - CSS-селектор контейнеров * @param {number} [options.stepMs=3000] - Интервал между прокрутками в миллисекундах * @returns {Object} API с методом destroy */ export function initAutoScroller(options = {}) { const { selector = '.js-join-scroller', stepMs = DEFAULT_STEP_MS } = options; const scrollers = []; document.querySelectorAll(selector).forEach(wrapper => { const scroller = initSingleScroller(wrapper, { stepMs }); scrollers.push(scroller); }); /** * Останавливает и очищает все скроллеры. */ function destroy() { scrollers.forEach(scroller => scroller.destroy()); scrollers.length = 0; } return { destroy }; } // Экспорт для совместимости с неModular кодом if (typeof window !== 'undefined') { window.initAutoScroller = initAutoScroller; }