/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. */ import { instanceSymbol } from "../../constants.mjs"; import { ATTRIBUTE_PREFIX, ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { CustomElement, getSlottedElements } from "../../dom/customelement.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { SliderStyleSheet } from "./stylesheet/slider.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { getWindow } from "../../dom/util.mjs"; import { isObject, isInteger } from "../../types/is.mjs"; export { Slider }; /** * @private * @type {symbol} */ const sliderElementSymbol = Symbol("sliderElement"); /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * @private * @type {symbol} */ const prevElementSymbol = Symbol("prevElement"); /** * @private * @type {symbol} */ const nextElementSymbol = Symbol("nextElement"); /** * @private * @type {symbol} */ const thumbnailElementSymbol = Symbol("thumbnailElement"); /** * @private * @type {symbol} */ const configSymbol = Symbol("config"); /** * A Slider * * @fragments /fragments/components/layout/slider/ * * @example /examples/components/layout/slider-simple * @example /examples/components/layout/slider-carousel * @example /examples/components/layout/slider-multiple * * @since 3.74.0 * @copyright schukai GmbH * @summary A beautiful Slider that can make your life easier and also looks good. * @fires monster-slider-resized * @fires monster-slider-moved */ class Slider extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/layout/slider@@instance"); } /** * * @return {Components.Layout.Slider */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); this[configSymbol] = { currentIndex: 0, isDragging: false, draggingPos: 0, startPos: 0, autoPlayInterval: null, eventHandler: { mouseOverPause: null, mouseout: null, touchstart : null, touchend: null } }; // set --monster-slides-width const slides = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="slider"]`, ); const slidesVisible = getVisibleSlidesFromContainerWidth.call(this); slides.style.setProperty( "--monster-slides-width", `${100 / slidesVisible}%`, ); initControlReferences.call(this); initEventHandler.call(this); initStructure.call(this); return this; } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {string} actions.click Callback when clicked * @property {Object} features Features * @property {boolean} features.carousel Carousel feature * @property {boolean} features.autoPlay Auto play feature * @property {boolean} features.thumbnails Thumbnails feature * @property {boolean} features.drag Drag feature * @property {Object} slides Slides configuration, an object with breakpoints and the number of slides to show * @property {Object} slides.0 Number of slides to show at 0px * @property {Object} slides.600 Number of slides to show at 600px @since 3.109.0 * @property {Object} slides.1200 Number of slides to show at 1200px @since 3.109.0 * @property {Object} slides.1800 Number of slides to show at 1800px @since 3.109.0 * @property {Object} carousel Carousel configuration * @property {number} carousel.transition Transition time between a full rotation of the carousel * @property {Object} autoPlay Auto play configuration * @property {number} autoPlay.delay Delay between slides * @property {number} autoPlay.startDelay Start delay * @property {string} autoPlay.direction Direction of the autoplay * @property {boolean} autoPlay.mouseOverPause Pause on mouse over * @property {boolean} autoPlay.touchPause Pause on touch * @property {Object} classes CSS classes * @property {boolean} disabled Disabled state */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, classes: {}, disabled: false, features: { carousel: true, autoPlay: true, thumbnails: true, drag: true, }, slides: { 0: 1, 600: 2, 1200: 3, 1800: 4, }, carousel: { transition: 250, }, autoPlay: { delay: 1500, startDelay: 1000, direction: "next", mouseOverPause: true, touchPause: true, }, }); } /** * @return {string} */ static getTag() { return "monster-slider"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [SliderStyleSheet]; } /** * moves the slider to the given index * * @param index * @return {void} */ moveTo(index) { return moveTo.call(this, index); } /** * shows the previous slide * * @return {void} */ previous() { return prev.call(this); } /** * shows the next slide * * @return {void} */ next() { return next.call(this); } /** * stops the auto play * * @return {void} */ stopAutoPlay() { if (this[configSymbol].autoPlayInterval) { clearInterval(this[configSymbol].autoPlayInterval); } } /** * starts the auto play * * @return {void} */ startAutoPlay() { initAutoPlay.call(this); } } /** * @private * @param name */ //function initNavigation(name) { //const element = this.shadowRoot.querySelector("." + name + ""); //const elementHeight = element.offsetHeight; //element.style.top = `calc(50% - ${elementHeight / 2}px)`; //} /** * @private */ function initStructure() { //initNavigation.call(this, "next"); //initNavigation.call(this, "prev"); if (this.getOption("features.thumbnails")) { initThumbnails.call(this); } initShadows.call(this); if (this.getOption("features.autoPlay")) { initAutoPlay.call(this); } } /** * @private */ function initThumbnails() { const self = this; const thumbnails = this.shadowRoot.querySelector( "[data-monster-role='thumbnails']", ); // remove all thumbnails while (thumbnails.firstChild) { thumbnails.removeChild(thumbnails.firstChild); } const { originSlides } = getSlidesAndTotal.call(this); originSlides.forEach((x, index) => { const thumbnail = document.createElement("div"); thumbnail.classList.add("thumbnail"); thumbnail.addEventListener("click", () => { this.moveTo(index); }); thumbnails.appendChild(thumbnail); }); this.addEventListener("monster-slider-moved", (e) => { const index = e.detail.index; const thumbnail = thumbnails.children[index]; if (!thumbnail) { return; } Array.from(thumbnails.children).forEach((thumb) => { thumb.classList.remove("current"); }); thumbnail.classList.add("current"); }); } /** * @private */ function initAutoPlay() { const self = this; const autoPlay = this.getOption("autoPlay"); const delay = autoPlay.delay; const startDelay = autoPlay.startDelay; const direction = autoPlay.direction; function start() { if (self[configSymbol].autoPlayInterval) { clearInterval(self[configSymbol].autoPlayInterval); } self[configSymbol].autoPlayInterval = setInterval(() => { const { totalOriginSlides } = getSlidesAndTotal.call(self); if (direction === "next") { if ( !self.getOption("features.carousel") && self[configSymbol].currentIndex >= totalOriginSlides - 1 ) { self[configSymbol].currentIndex = -1; } self.next(); } else { if ( !self.getOption("features.carousel") && self[configSymbol].currentIndex <= 0 ) { self[configSymbol].currentIndex = totalOriginSlides; } self.previous(); } }, delay); } setTimeout(() => { start(); }, startDelay); if (autoPlay.mouseOverPause) { if(this[configSymbol].eventHandler.mouseOverPause===null) { this[configSymbol].eventHandler.mouseOverPause = () => { clearInterval(this[configSymbol].autoPlayInterval); } this.addEventListener("mouseover",this[configSymbol].eventHandler.mouseOverPause); } if(this[configSymbol].eventHandler.mouseout===null) { this[configSymbol].eventHandler.mouseout = () => { if (this[configSymbol].isDragging) { return; } start(); } this.addEventListener("mouseout", this[configSymbol].eventHandler.mouseout); } } if (autoPlay.touchPause) { if(this[configSymbol].eventHandler.touchstart===null) { this[configSymbol].eventHandler.touchstart = () => { clearInterval(this[configSymbol].autoPlayInterval); } this.addEventListener("touchstart",this[configSymbol].eventHandler.touchstart); } if(this[configSymbol].eventHandler.touchend===null) { this[configSymbol].eventHandler.touchend = () => { if (this[configSymbol].isDragging) { return; } start(); } this.addEventListener("touchend", this[configSymbol].eventHandler.touchend); } } } function getVisibleSlidesFromContainerWidth() { const containerWidth = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="slider"]`, ).offsetWidth; const slides = this.getOption("slides"); let visibleSlides = 1; if (!isObject(slides)) { return visibleSlides; } for (const key in slides) { if (containerWidth >= key) { visibleSlides = slides[key]; } } const { originSlides } = getSlidesAndTotal.call(this); if (visibleSlides > originSlides.length) { visibleSlides = originSlides.length - 1; } return visibleSlides; } /** * @private */ function initShadows() { const { slides, totalSlides } = getSlidesAndTotal.call(this); const slidesVisible = getVisibleSlidesFromContainerWidth.call(this); if (totalSlides > slidesVisible) { let current = slides[0]; let last = slides[totalSlides - 1]; for (let i = 0; i < slidesVisible; i++) { const clone = current.cloneNode(true); clone.setAttribute("data-monster-clone-from", i); last.insertAdjacentElement("afterend", clone); current = current.nextElementSibling; last = clone; } current = slides[totalSlides - 1]; let first = slides[0]; for (let i = 0; i < slidesVisible; i++) { const clone = current.cloneNode(true); clone.setAttribute("data-monster-clone-from", totalSlides - i); first.insertAdjacentElement("beforebegin", clone); current = current.previousElementSibling; first = clone; } moveTo.call(this, 0); } } /** * @private * @return {{slides: unknown[], totalSlides: number}} */ function getSlidesAndTotal() { const originSlides = Array.from( getSlottedElements.call( this, ":scope:not([data-monster-clone-from])", null, ), ); const totalOriginSlides = originSlides.length; const slides = Array.from(getSlottedElements.call(this, ":scope", null)); const totalSlides = slides.length; return { originSlides, totalOriginSlides, slides, totalSlides }; } /** * @private * @return {number} */ function next() { const nextIndex = this[configSymbol].currentIndex + 1; queueMicrotask(() => { getWindow().requestAnimationFrame(() => { getWindow().requestAnimationFrame(() => { moveTo.call(this, nextIndex); }); }); }); return 0; } /** * @private * @return {number} */ function prev() { const prevIndex = this[configSymbol].currentIndex - 1; queueMicrotask(() => { getWindow().requestAnimationFrame(() => { getWindow().requestAnimationFrame(() => { moveTo.call(this, prevIndex); }); }); }); return 0; } /** * @private * @param slides * @param index */ function setMoveProperties(slides, index) { slides.forEach((slide) => { slide.classList.remove("current"); }); let offset = -(index * 100); const slidesVisible = getVisibleSlidesFromContainerWidth.call(this); offset = offset / slidesVisible; if (offset !== 0) { offset += "%"; } this[sliderElementSymbol].style.transform = `translateX(calc(${offset} + ${this[configSymbol].draggingPos}px))`; if (slides[index]) { slides[index].classList.add("current"); } this[configSymbol].lastOffset = offset; } /** * @private * @param {number} index * @param {boolean} animation * @fires monster-slider-moved */ function moveTo(index, animation) { const { slides, totalSlides, originSlides, totalOriginSlides } = getSlidesAndTotal.call(this); if (animation === false) { this[sliderElementSymbol].classList.remove("animate"); } else { this[sliderElementSymbol].classList.add("animate"); } if (this.getOption("features.carousel") === true) { if (index < 0) { index = -1; } if (index > totalOriginSlides) { index = totalOriginSlides; } } else { if (index < 0) { index = 0; } if (index >= totalOriginSlides) { index = totalOriginSlides - 1; } } if (!isInteger(index)) { return; } const visibleSlides = getVisibleSlidesFromContainerWidth.call(this); if (totalOriginSlides <= visibleSlides) { this[prevElementSymbol].classList.add("hidden"); this[nextElementSymbol].classList.add("hidden"); this[thumbnailElementSymbol].classList.add("hidden"); return; } this[prevElementSymbol].classList.remove("hidden"); this[nextElementSymbol].classList.remove("hidden"); this[thumbnailElementSymbol].classList.remove("hidden"); let slidesIndex = index + visibleSlides; this[configSymbol].currentIndex = index; if (slidesIndex < 0) { slidesIndex = totalSlides - 1 - visibleSlides; this[configSymbol].currentIndex = totalOriginSlides - 1; } else if (index > totalOriginSlides) { slidesIndex = 0; this[configSymbol].currentIndex = 0; } setMoveProperties.call(this, slides, slidesIndex); if (index === totalOriginSlides) { setTimeout(() => { getWindow().requestAnimationFrame(() => { moveTo.call(this, 0, false); }); }, this.getOption("carousel.transition")); } else if (index === -1) { setTimeout(() => { getWindow().requestAnimationFrame(() => { moveTo.call(this, totalOriginSlides - 1, false); }); }, this.getOption("carousel.transition")); } fireCustomEvent(this, "monster-slider-moved", { index: index, }); } /** * @private * @return {initEventHandler} * @fires monster-slider-resized */ function initEventHandler() { const self = this; const nextElements = this[nextElementSymbol]; if (nextElements) { nextElements.addEventListener("click", () => { self.next(); }); } const prevElements = this[prevElementSymbol]; if (prevElements) { prevElements.addEventListener("click", () => { self.previous(); }); } if (this.getOption("features.drag")) { this[sliderElementSymbol].addEventListener("mousedown", (e) => startDragging.call(this, e, "mouse"), ); this[sliderElementSymbol].addEventListener("touchstart", (e) => startDragging.call(this, e, "touch"), ); } const initialSize = { width: this[sliderElementSymbol]?.offsetWidth || 0, height: this[sliderElementSymbol]?.offsetHeight || 0 }; const resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { const {width, height} = entry.contentRect; if (width !== initialSize.width || height !== initialSize.height) { self.stopAutoPlay(); if (this.getOption("features.thumbnails")) { initThumbnails.call(this); } const slidesVisible = getVisibleSlidesFromContainerWidth.call(this); this[sliderElementSymbol].style.setProperty( "--monster-slides-width", `${100 / slidesVisible}%`, ); moveTo.call(self,0,false) self.startAutoPlay(); fireCustomEvent(self, "monster-slider-resized", { width: width, height: height }); } } }); resizeObserver.observe(this[sliderElementSymbol]); return this; } /** * @private * @param e * @param type */ function startDragging(e, type) { const { slides } = getSlidesAndTotal.call(this); const widthOfSlider = slides[this[configSymbol].currentIndex]?.offsetWidth; this[configSymbol].isDragging = true; this[configSymbol].startPos = getPositionX(e, type); this[sliderElementSymbol].classList.add("grabbing"); this[sliderElementSymbol].style.transitionProperty = "none"; const callbackMousemove = (x) => { dragging.call(this, x, type); }; const callbackMouseUp = () => { const endEvent = type === "mouse" ? "mouseup" : "touchend"; const moveEvent = type === "mouse" ? "mousemove" : "touchmove"; document.body.removeEventListener(endEvent, callbackMouseUp); document.body.removeEventListener(moveEvent, callbackMousemove); this[configSymbol].isDragging = false; this[configSymbol].startPos = 0; this[sliderElementSymbol].classList.remove("grabbing"); this[sliderElementSymbol].style.transitionProperty = ""; const lastPos = this[configSymbol].draggingPos; this[configSymbol].draggingPos = 0; let newIndex = this[configSymbol].currentIndex; const shift = lastPos / widthOfSlider; const shiftIndex = Math.round(shift); newIndex += shiftIndex * -1; this.moveTo(newIndex); }; document.body.addEventListener("mouseup", callbackMouseUp); document.body.addEventListener("mousemove", callbackMousemove); } /** * @private * @param e * @param type * @return {*|number|number} */ function getPositionX(e, type) { return type === "mouse" ? e.pageX : e.touches[0].clientX; } /** * @private * @param e * @param type */ function dragging(e, type) { if (!this[configSymbol].isDragging) return; this[configSymbol].draggingPos = getPositionX(e, type) - this[configSymbol].startPos; this[sliderElementSymbol].style.transform = `translateX(calc(${this[configSymbol].lastOffset} + ${this[configSymbol].draggingPos}px))`; } /** * @private * @return {void} */ function initControlReferences() { this[controlElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="control"]`, ); this[sliderElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="slider"]`, ); this[prevElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="prev"]`, ); this[nextElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="next"]`, ); this[thumbnailElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="thumbnails"]`, ); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <div class="prev" data-monster-role="prev" part="prev" part="prev"> <slot name="prev"></slot> </div> <div data-monster-role="slider" part="slides"> <slot></slot> </div> <div data-monster-role="thumbnails" part="thumbnails"></div> <div class="next" data-monster-role="next" part="next" part="next"> <slot name="next"></slot> </div> </div>`; } registerCustomElement(Slider);