/** * @class ItcSlider * @version 1.0.1 * @author https://github.com/itchief * @copyright Alexander Maltsev 2020 - 2023 * @license MIT (https://github.com/itchief/ui-components/blob/master/LICENSE) * @tutorial https://itchief.ru/javascript/slider */ class ItcSlider { static #EL_WRAPPER = 'wrapper'; static #EL_ITEMS = 'items'; static #EL_ITEM = 'item'; static #EL_ITEM_ACTIVE = 'item-active'; static #EL_INDICATOR = 'indicator'; static #EL_INDICATOR_ACTIVE = 'indicator-active'; static #BTN_PREV = 'btn-prev'; static #BTN_NEXT = 'btn-next'; static #BTN_HIDE = 'btn-hide'; static #TRANSITION_NONE = 'transition-none'; static #SWIPE_THRESHOLD = 20; static #instances = []; static checkSupportPassiveEvents() { let passiveSupported = false; try { const options = Object.defineProperty({}, 'passive', { get() { passiveSupported = true; }, }); window.addEventListener('testPassiveListener', null, options); window.removeEventListener('testPassiveListener', null, options); } catch (error) { passiveSupported = false; } return passiveSupported; } #config; #state; #resizeObserver; /** * @param {HTMLElement} el * @param {Object} config * @param {String} prefix */ constructor(el, config = {}, prefix = 'itc-slider-') { this.#state = { prefix, // префикс для классов el, // элемент который нужно активировать как ItcSlider elWrapper: el.querySelector(`.${prefix}${this.constructor.#EL_WRAPPER}`), // элемент с #CLASS_WRAPPER elItems: el.querySelector(`.${prefix}${this.constructor.#EL_ITEMS}`), // элемент, в котором находятся слайды elListItem: el.querySelectorAll(`.${prefix}${this.constructor.#EL_ITEM}`), // список элементов, являющиеся слайдами btnPrev: el.querySelector(`.${prefix}${this.constructor.#BTN_PREV}`), // кнопка, для перехода к предыдущему слайду btnNext: el.querySelector(`.${prefix}${this.constructor.#BTN_NEXT}`), // кнопка, для перехода к следующему слайду btnClassHide: prefix + this.constructor.#BTN_HIDE, // класс для скрытия кнопки exOrderMin: 0, exOrderMax: 0, exItemMin: null, exItemMax: null, exTranslateMin: 0, exTranslateMax: 0, direction: 'next', // направление смены слайдов intervalId: null, // id таймера isSwiping: false, swipeX: 0, swipeY: 0, }; this.#resizeObserver = null; this.#config = { loop: true, direction: 'next', autoplay: false, interval: 5000, refresh: true, swipe: true, ...config }; this.#init(); this.#attachEvents(); } /** * Статический метод, который возвращает экземпляр ItcSlider, связанный с DOM-элементом * @param {HTMLElement} elSlider * @returns {?ItcSlider} */ static getInstance(elSlider) { const found = this.#instances.find((el) => el.target === elSlider); if (found) { return found.instance; } return null; } /** * @param {String|HTMLElement} target * @param {Object} config * @param {String} prefix */ static getOrCreateInstance(target, config = {}, prefix = 'itc-slider-') { const elSlider = typeof target === 'string' ? document.querySelector(target) : target; const result = this.getInstance(elSlider); if (result) { return result; } const slider = new this(elSlider, config, prefix); this.#instances.push({ target: elSlider, instance: slider }); return slider; } // статический метод для активирования элементов как ItcSlider на основе data-атрибутов static createInstances() { document.querySelectorAll('[data-slider="itc-slider"]').forEach((el) => { const { dataset } = el; const params = {}; Object.keys(dataset).forEach((key) => { if (key === 'slider') { return; } let value = dataset[key]; value = Number.isNaN(Number(value)) ? value : Number(value); value = value === 'true' ? true : value; value = value === 'false' ? false : value; params[key] = value; }); this.getOrCreateInstance(el, params); }); } slideNext() { this.#state.direction = 'next'; this.#move(); } slidePrev() { this.#state.direction = 'prev'; this.#move(); } slideTo(index) { this.#moveTo(index); } reset() { this.#reset(); } get autoplay() { return { // Start autoplay start: () => { this.#config.autoplay = true; this.#autoplay(); }, // Stop autoplay stop: () => { this.#autoplay('stop'); this.#config.autoplay = false; } }; } dispose() { this.#detachEvents(); const transitionNoneClass = this.#state.prefix + this.constructor.#TRANSITION_NONE; const activeClass = this.#state.prefix + this.constructor.#EL_ITEM_ACTIVE; this.#autoplay('stop'); this.#state.elItems.classList.add(transitionNoneClass); this.#state.elItems.style.transform = ''; this.#state.elListItem.forEach((el) => { el.style.transform = ''; el.classList.remove(activeClass); }); const selIndicators = `${this.#state.prefix}${this.constructor.#EL_INDICATOR_ACTIVE}`; document.querySelectorAll(`.${selIndicators}`).forEach((el) => { el.classList.remove(selIndicators); }); this.#state.elItems.offsetHeight; this.#state.elItems.classList.remove(transitionNoneClass); const index = this.constructor.#instances.findIndex((el) => el.target === this.#state.el); this.constructor.#instances.splice(index, 1); } #onClick(e) { if (this.#state.isMoving) { e.preventDefault(); } if (!(e.target.closest('.itc-slider-btn') || e.target.closest('.itc-slider-indicators'))) { return; } const classBtnPrev = this.#state.prefix + this.constructor.#BTN_PREV; const classBtnNext = this.#state.prefix + this.constructor.#BTN_NEXT; this.#autoplay('stop'); if (e.target.closest(`.${classBtnPrev}`) || e.target.closest(`.${classBtnNext}`)) { this.#state.direction = e.target.closest(`.${classBtnPrev}`) ? 'prev' : 'next'; this.#move(); } else if (e.target.dataset.slideTo) { const index = parseInt(e.target.dataset.slideTo, 10); this.#moveTo(index); } this.#config.loop ? this.#autoplay() : null; } #onMouseEnter() { this.#autoplay('stop'); } #onMouseLeave() { this.#autoplay(); } #onTransitionStart() { if (this.#config.loop) { if (this.#state.isBalancing) { return; } this.#state.isBalancing = true; window.requestAnimationFrame(() => { this.#balanceItems(false); }); } } #onTransitionEnd() { if (this.#config.loop) { this.#state.isBalancing = false; } } #onDragStart(e) { e.preventDefault(); } #onVisibilityChange() { if (document.visibilityState === 'hidden') { this.#autoplay('stop'); } else if (document.visibilityState === 'visible' && this.#config.loop) { this.#autoplay(); } } #touchStart(e) { this.#state.isMoving = false; this.#autoplay('stop'); const event = e.type.search('touch') === 0 ? e.touches[0] : e; this.#state.swipeX = event.clientX; this.#state.swipeY = event.clientY; this.#state.isSwiping = true; this.#state.isTouchMoving = false; } #touchEnd(e) { if (!this.#state.isSwiping) { return; } const event = e.type.search('touch') === 0 ? e.changedTouches[0] : e; const wrapperRect = this.#state.elWrapper.getBoundingClientRect(); let clientX = event.clientX < wrapperRect.left ? wrapperRect.left : event.clientX; clientX = clientX > wrapperRect.right ? wrapperRect.right : clientX; let diffPosX = this.#state.swipeX - clientX; if (diffPosX === 0) { this.#state.isSwiping = false; return; } if (!this.#config.loop) { const isNotMoveFirst = this.#state.activeItems[0] === 1 && diffPosX <= 0; const isNotMoveLast = this.#state.activeItems[this.#state.activeItems.length - 1] && diffPosX >= 0; if (isNotMoveFirst || isNotMoveLast) { diffPosX = 0; } } const value = (diffPosX / this.#state.width) * 100; const transitionNoneClass = this.#state.prefix + this.constructor.#TRANSITION_NONE; this.#state.elItems.classList.remove(transitionNoneClass); if (value > this.constructor.#SWIPE_THRESHOLD) { this.#state.direction = 'next'; let count = 0; while (count <= Math.floor(Math.abs(value) - this.constructor.#SWIPE_THRESHOLD) / 100) { this.#move(); count += 1; } } else if (value < -this.constructor.#SWIPE_THRESHOLD) { this.#state.direction = 'prev'; let count = 0; while (count <= Math.floor(Math.abs(value) - this.constructor.#SWIPE_THRESHOLD) / 100) { this.#move(); count += 1; } } else { this.#state.direction = 'none'; this.#move(); } this.#state.isSwiping = false; if (this.#config.loop) { this.#autoplay(); } this.#state.isBalancing = false; } #touchMove(e) { if (!this.#state.isSwiping) { return; } this.#state.isMoving = true; const event = e.type.search('touch') === 0 ? e.changedTouches[0] : e; let diffPosX = this.#state.swipeX - event.clientX; const diffPosY = this.#state.swipeY - event.clientY; const prevPosX = this.#state.prevPosX ? this.#state.prevPosX : event.clientX; const direction = prevPosX > event.clientX ? 'next' : 'prev'; this.#state.prevPosX = event.clientX; if (!this.#state.isTouchMoving) { if (Math.abs(diffPosY) > Math.abs(diffPosX) || Math.abs(diffPosX) === 0) { this.#state.isSwiping = false; return; } this.#state.isTouchMoving = true; } e.preventDefault(); if (!this.#config.loop) { const isNotMoveFirst = this.#state.activeItems[0] === 1 && diffPosX <= 0; const isNotMoveLast = this.#state.activeItems[this.#state.activeItems.length - 1] && diffPosX >= 0; if (isNotMoveFirst || isNotMoveLast) { diffPosX /= 4; } } const transitionNoneClass = this.#state.prefix + this.constructor.#TRANSITION_NONE; this.#state.elItems.classList.add(transitionNoneClass); const translate = this.#state.translate - diffPosX; this.#state.elItems.style.transform = `translate3D(${translate}px, 0px, 0.1px)`; if (this.#config.loop) { this.#state.direction = diffPosX > 0 ? 'next' : 'prev'; this.#state.direction = direction; window.requestAnimationFrame(() => { this.#balanceItems(true); }); } } #attachEvents() { this.#state.events = { click: [this.#state.el, this.#onClick.bind(this), true], mouseenter: [this.#state.el, this.#onMouseEnter.bind(this), true], mouseleave: [this.#state.el, this.#onMouseLeave.bind(this), true], transitionstart: [this.#state.elItems, this.#onTransitionStart.bind(this), this.#config.loop], transitionend: [this.#state.elItems, this.#onTransitionEnd.bind(this), this.#config.loop], touchstart: [this.#state.el, this.#touchStart.bind(this), this.#config.swipe], mousedown: [this.#state.el, this.#touchStart.bind(this), this.#config.swipe], touchend: [document, this.#touchEnd.bind(this), this.#config.swipe], mouseup: [document, this.#touchEnd.bind(this), this.#config.swipe], touchmove: [this.#state.el, this.#touchMove.bind(this), this.#config.swipe], mousemove: [this.#state.el, this.#touchMove.bind(this), this.#config.swipe], dragstart: [this.#state.el, this.#onDragStart.bind(this), true], visibilitychange: [document, this.#onVisibilityChange.bind(this), true] }; Object.keys(this.#state.events).forEach((type) => { if (this.#state.events[type][2]) { const el = this.#state.events[type][0]; const fn = this.#state.events[type][1]; if (type === 'touchstart' || type === 'touchmove') { const options = this.constructor.checkSupportPassiveEvents() ? { passive: false } : false; el.addEventListener(type, fn, options); } else { el.addEventListener(type, fn); } } }); this.#resizeObserver = new ResizeObserver((entries) => { window.requestAnimationFrame(this.#reset.bind(this)); }); this.#resizeObserver.observe(this.#state.elWrapper); } #detachEvents() { Object.keys(this.#state.events).forEach((type) => { if (this.#state.events[type][2]) { const el = this.#state.events[type][0]; const fn = this.#state.events[type][1]; el.removeEventListener(type, fn); this.#resizeObserver.disconnect(); } }); } #autoplay(action) { if (!this.#config.autoplay) { return; } if (action === 'stop') { clearInterval(this.#state.intervalId); this.#state.intervalId = null; return; } if (this.#state.intervalId === null) { this.#state.intervalId = setInterval(() => { this.#state.direction = this.#config.direction === 'prev' ? 'prev' : 'next'; this.#move(); }, this.#config.interval); } } #balanceItems(once = false) { if (!this.#state.isBalancing && !once) { return; } const wrapperRect = this.#state.elWrapper.getBoundingClientRect(); const targetWidth = wrapperRect.width / this.#state.countActiveItems / 2; const countItems = this.#state.elListItem.length; if (this.#state.direction === 'next') { const exItemRectRight = this.#state.exItemMin.getBoundingClientRect().right; if (exItemRectRight < wrapperRect.left - targetWidth) { const elFound = this.#state.els.find((item) => item.el === this.#state.exItemMin); elFound.order = this.#state.exOrderMin + countItems; const translate = this.#state.exTranslateMin + countItems * this.#state.width; elFound.translate = translate; this.#state.exItemMin.style.transform = `translate3D(${translate}px, 0px, 0.1px)`; this.#updateExProperties(); } } else { const exItemRectLeft = this.#state.exItemMax.getBoundingClientRect().left; if (exItemRectLeft > wrapperRect.right + targetWidth) { const elFound = this.#state.els.find((item) => item.el === this.#state.exItemMax); elFound.order = this.#state.exOrderMax - countItems; const translate = this.#state.exTranslateMax - countItems * this.#state.width; elFound.translate = translate; this.#state.exItemMax.style.transform = `translate3D(${translate}px, 0px, 0.1px)`; this.#updateExProperties(); } } if (!once) { window.requestAnimationFrame(() => { this.#balanceItems(false); }); } } #updateClasses() { const activeClass = this.#state.prefix + this.constructor.#EL_ITEM_ACTIVE; this.#state.activeItems.forEach((item, index) => { if (item) { this.#state.elListItem[index].classList.add(activeClass); } else { this.#state.elListItem[index].classList.remove(activeClass); } const elListIndicators = this.#state.el.querySelectorAll(`.${this.#state.prefix}${this.constructor.#EL_INDICATOR}`); if (elListIndicators.length && item) { elListIndicators[index].classList.add(`${this.#state.prefix}${this.constructor.#EL_INDICATOR_ACTIVE}`); } else if (elListIndicators.length && !item) { elListIndicators[index].classList.remove(`${this.#state.prefix}${this.constructor.#EL_INDICATOR_ACTIVE}`); } }); } #move() { if (this.#state.direction === 'none') { const transform = this.#state.translate; this.#state.elItems.style.transform = `translate3D(${transform}px, 0px, 0.1px)`; return; } const widthItem = this.#state.direction === 'next' ? -this.#state.width : this.#state.width; const transform = this.#state.translate + widthItem; if (!this.#config.loop) { const limit = this.#state.width * (this.#state.elListItem.length - this.#state.countActiveItems); if (transform < -limit || transform > 0) { return; } if (this.#state.btnPrev) { this.#state.btnPrev.classList.remove(this.#state.btnClassHide); this.#state.btnNext.classList.remove(this.#state.btnClassHide); } if (this.#state.btnPrev && transform === -limit) { this.#state.btnNext.classList.add(this.#state.btnClassHide); } else if (this.#state.btnPrev && transform === 0) { this.#state.btnPrev.classList.add(this.#state.btnClassHide); } } if (this.#state.direction === 'next') { this.#state.activeItems = [...this.#state.activeItems.slice(-1), ...this.#state.activeItems.slice(0, -1)]; } else { this.#state.activeItems = [...this.#state.activeItems.slice(1), ...this.#state.activeItems.slice(0, 1)]; } this.#updateClasses(); this.#state.translate = transform; this.#state.elItems.style.transform = `translate3D(${transform}px, 0px, 0.1px)`; } #moveTo(index) { const delta = this.#state.activeItems.reduce((acc, current, currentIndex) => { const diff = current ? index - currentIndex : acc; return Math.abs(diff) < Math.abs(acc) ? diff : acc; }, this.#state.activeItems.length); if (delta !== 0) { this.#state.direction = delta > 0 ? 'next' : 'prev'; for (let i = 0; i < Math.abs(delta); i++) { this.#move(); } } } // приватный метод для выполнения первичной инициализации #init() { // состояние элементов this.#state.els = []; // текущее значение translate this.#state.translate = 0; // позиции активных элементов this.#state.activeItems = []; // состояние элементов this.#state.isBalancing = false; // получаем gap между слайдами const gap = parseFloat(getComputedStyle(this.#state.elItems).gap) || 0; // ширина одного слайда this.#state.width = this.#state.elListItem[0].getBoundingClientRect().width + gap; // ширина #EL_WRAPPER const widthWrapper = this.#state.elWrapper.getBoundingClientRect().width; // количество активных элементов this.#state.countActiveItems = Math.round(widthWrapper / this.#state.width); this.#state.elListItem.forEach((el, index) => { el.style.transform = ''; this.#state.activeItems.push(index < this.#state.countActiveItems ? 1 : 0); this.#state.els.push({ el, index, order: index, translate: 0 }); }); if (this.#state.countActiveItems === this.#state.elListItem.length) { if (this.#state.btnPrev) { this.#state.btnPrev.classList.add(this.#state.btnClassHide); } if (this.#state.btnNext) { this.#state.btnNext.classList.add(this.#state.btnClassHide); } } else { if (this.#state.btnPrev) { this.#state.btnPrev.classList.remove(this.#state.btnClassHide); } if (this.#state.btnNext) { this.#state.btnNext.classList.remove(this.#state.btnClassHide); } } if (this.#config.loop) { const lastIndex = this.#state.elListItem.length - 1; const translate = -(lastIndex + 1) * this.#state.width; this.#state.elListItem[lastIndex].style.transform = `translate3D(${translate}px, 0px, 0.1px)`; this.#state.els[lastIndex].order = -1; this.#state.els[lastIndex].translate = translate; this.#updateExProperties(); } else if (this.#state.btnPrev) { this.#state.btnPrev.classList.add(this.#state.btnClassHide); } this.#updateClasses(); this.#autoplay(); } #reset() { const transitionNoneClass = this.#state.prefix + this.constructor.#TRANSITION_NONE; // получаем gap между слайдами const gap = parseFloat(getComputedStyle(this.#state.elItems).gap) || 0; // ширина одного слайда const widthItem = this.#state.elListItem[0].getBoundingClientRect().width + gap; const widthWrapper = this.#state.elWrapper.getBoundingClientRect().width; const countActiveEls = Math.round(widthWrapper / widthItem); if (widthItem === this.#state.width && countActiveEls === this.#state.countActiveItems) { return; } this.#autoplay('stop'); this.#state.elItems.classList.add(transitionNoneClass); this.#state.elItems.style.transform = 'translate3D(0px, 0px, 0.1px)'; this.#init(); window.requestAnimationFrame(() => { this.#state.elItems.classList.remove(transitionNoneClass); }); } #updateExProperties() { const els = this.#state.els.map((item) => item.el); const orders = this.#state.els.map((item) => item.order); this.#state.exOrderMin = Math.min(...orders); this.#state.exOrderMax = Math.max(...orders); const min = orders.indexOf(this.#state.exOrderMin); const max = orders.indexOf(this.#state.exOrderMax); this.#state.exItemMin = els[min]; this.#state.exItemMax = els[max]; this.#state.exTranslateMin = this.#state.els[min].translate; this.#state.exTranslateMax = this.#state.els[max].translate; } } ItcSlider.createInstances();