UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

933 lines (798 loc) 25.7 kB
/** * Copyright © Volker Schukai 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 Volker Schukai. */ 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} * @description Reference to the main slider <ul> container element. */ const sliderElementSymbol = Symbol("sliderElement"); /** * @private * @type {symbol} * @description Reference to the main control <div> wrapper. */ const controlElementSymbol = Symbol("controlElement"); /** * @private * @type {symbol} * @description Reference to the "previous" button element. */ const prevElementSymbol = Symbol("prevElement"); /** * @private * @type {symbol} * @description Reference to the "next" button element. */ const nextElementSymbol = Symbol("nextElement"); /** * @private * @type {symbol} * @description Reference to the thumbnails container element. */ const thumbnailElementSymbol = Symbol("thumbnailElement"); /** * @private * @type {symbol} * @description Stores internal state, configuration, and event handlers. */ const configSymbol = Symbol("config"); /** * A slider/carousel custom element. * * @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 Volker Schukai * @summary Provides a responsive, touch-enabled slider or carousel component with features like autoplay, thumbnails, and looping. * @fires monster-slider-resized - Fired when the slider's dimensions change. * @fires monster-slider-moved - Fired when the slider moves to a new slide. */ 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, resizeObserver: null, // Store the observer for later cleanup eventHandler: { mouseOverPause: null, mouseout: null, touchstart: null, touchend: null, }, }; // Set the CSS custom property for slide width based on visible slides. 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; } /** * Called when the element is removed from the DOM. * Cleans up intervals, observers, and event listeners to prevent memory leaks. */ disconnectedCallback() { // Check if super.disconnectedCallback exists and call it if (super.disconnectedCallback) { super.disconnectedCallback(); } this.stopAutoPlay(); // Clear interval // Disconnect the ResizeObserver if (this[configSymbol]?.resizeObserver) { this[configSymbol].resizeObserver.disconnect(); this[configSymbol].resizeObserver = null; } // Remove autoplay-related event listeners if (this[configSymbol]?.eventHandler) { const { mouseOverPause, mouseout, touchstart, touchend } = this[configSymbol].eventHandler; if (mouseOverPause) { this.removeEventListener("mouseover", mouseOverPause); this[configSymbol].eventHandler.mouseOverPause = null; } if (mouseout) { this.removeEventListener("mouseout", mouseout); this[configSymbol].eventHandler.mouseout = null; } if (touchstart) { this.removeEventListener("touchstart", touchstart); this[configSymbol].eventHandler.touchstart = null; } if (touchend) { this.removeEventListener("touchend", touchend); this[configSymbol].eventHandler.touchend = null; } } } /** * 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 {Object} features Features * @property {boolean} features.carousel Carousel feature (infinite looping) * @property {boolean} features.autoPlay Auto play feature * @property {boolean} features.thumbnails Thumbnails feature * @property {boolean} features.drag Drag feature (touch and mouse) * @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 Duration (ms) of the carousel 'jump' animation when looping. * @property {Object} autoPlay Auto play configuration * @property {number} autoPlay.delay Delay in ms between slide transitions * @property {number} autoPlay.startDelay Delay in ms before autoplay starts * @property {string} autoPlay.direction Direction of the autoplay ("next" or "prev") * @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 {number} index - The slide index to move to. * @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); this[configSymbol].autoPlayInterval = null; } } /** * Starts the auto play. * * @return {void} */ startAutoPlay() { initAutoPlay.call(this); } } /** * @private * @description Initializes the component structure, thumbnails, and autoplay. */ function initStructure() { if (this.getOption("features.thumbnails")) { initThumbnails.call(this); } // Clones slides if carousel mode is active initCarouselClones.call(this); if (this.getOption("features.autoPlay")) { initAutoPlay.call(this); } } /** * @private * @description Generates the thumbnail navigation elements. */ function initThumbnails() { const self = this; const thumbnails = this.shadowRoot.querySelector( "[data-monster-role='thumbnails']", ); // Clear existing thumbnails before regenerating 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); }); // Listen for move events to update the active 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 * @description Initializes the autoplay functionality and its event handlers. */ function initAutoPlay() { const self = this; if (this.getOption("features.autoPlay") === false) { return; } const autoPlay = this.getOption("autoPlay"); if (!isObject(autoPlay)) { return; } const delay = autoPlay.delay; const startDelay = autoPlay.startDelay; const direction = autoPlay.direction; function start() { // Clear any existing interval before starting a new one if (self[configSymbol].autoPlayInterval) { clearInterval(self[configSymbol].autoPlayInterval); } self[configSymbol].autoPlayInterval = setInterval(() => { const { totalOriginSlides } = getSlidesAndTotal.call(self); if (direction === "next") { // Check if carousel looping is disabled and we're at the end if ( !self.getOption("features.carousel") && self[configSymbol].currentIndex >= totalOriginSlides - 1 ) { self[configSymbol].currentIndex = -1; } self.next(); } else { // Check if carousel looping is disabled and we're at the beginning if ( !self.getOption("features.carousel") && self[configSymbol].currentIndex <= 0 ) { self[configSymbol].currentIndex = totalOriginSlides; } self.previous(); } }, delay); } setTimeout(() => { start(); }, startDelay); // Add listeners for pause-on-hover 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, ); } } // Add listeners for pause-on-touch 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, ); } } } /** * @private * @description Calculates the number of slides that should be visible based on breakpoints. * @return {number} */ function getVisibleSlidesFromContainerWidth() { const containerWidth = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="slider"]`, ).offsetWidth; const slides = this.getOption("slides"); let visibleSlides = 1; if (!isObject(slides)) { return visibleSlides; } // Find the largest breakpoint that is smaller than the current container width for (const key in slides) { if (containerWidth >= key) { visibleSlides = slides[key]; } } const { originSlides } = getSlidesAndTotal.call(this); // Ensure we don't try to show more slides than are available if (visibleSlides > originSlides.length) { visibleSlides = originSlides.length; // Fixed: was originSlides.length - 1 } return visibleSlides; } /** * @private * @description Clones slides to create the "infinite" loop effect for the carousel. */ function initCarouselClones() { const { slides, totalSlides } = getSlidesAndTotal.call(this); const slidesVisible = getVisibleSlidesFromContainerWidth.call(this); // Only clone if there are more slides than are visible if (totalSlides > slidesVisible) { // Clone slides from the beginning and append them to the end 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; } // Clone slides from the end and prepend them to the beginning current = slides[totalSlides - 1]; let first = slides[0]; for (let i = 0; i < slidesVisible; i++) { const clone = current.cloneNode(true); // Fixed: Index was totalSlides - i, should be totalSlides - 1 - i clone.setAttribute("data-monster-clone-from", totalSlides - 1 - i); first.insertAdjacentElement("beforebegin", clone); current = current.previousElementSibling; first = clone; } moveTo.call(this, 0); } } /** * @private * @description Gets all slides, original slides, and their counts. * @return {{slides: HTMLElement[], totalSlides: number, originSlides: HTMLElement[], totalOriginSlides: number}} */ function getSlidesAndTotal() { // Get only original slides (excluding clones) const originSlides = Array.from( getSlottedElements.call( this, ":scope:not([data-monster-clone-from])", null, ), ); const totalOriginSlides = originSlides.length; // Get all slides (including clones) const slides = Array.from(getSlottedElements.call(this, ":scope", null)); const totalSlides = slides.length; return { originSlides, totalOriginSlides, slides, totalSlides }; } /** * @private * @description Moves to the next slide. * @return {number} */ function next() { const nextIndex = this[configSymbol].currentIndex + 1; // Use requestAnimationFrame to ensure the move happens in the next frame, // allowing CSS transitions to apply correctly. getWindow().requestAnimationFrame(() => { moveTo.call(this, nextIndex); }); return 0; } /** * @private * @description Moves to the previous slide. * @return {number} */ function prev() { const prevIndex = this[configSymbol].currentIndex - 1; // Use requestAnimationFrame for smooth transitions getWindow().requestAnimationFrame(() => { moveTo.call(this, prevIndex); }); return 0; } /** * @private * @description Sets the CSS transform and 'current' class for the slide container. * @param {HTMLElement[]} slides - Array of all slide elements. * @param {number} index - The target slide index. */ function setMoveProperties(slides, index) { // Remove 'current' class from all slides 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 * @description The core logic for moving the slider to a specific index. * @param {number} index - The target index (relative to original slides). * @param {boolean} [animation=true] - Whether to use CSS transitions for this move. * @fires monster-slider-moved */ function moveTo(index, animation) { const { slides, totalSlides, originSlides, totalOriginSlides } = getSlidesAndTotal.call(this); // Remove/add 'animate' class to enable/disable CSS transitions if (animation === false) { this[sliderElementSymbol].classList.remove("animate"); } else { this[sliderElementSymbol].classList.add("animate"); } // Handle carousel looping logic if (this.getOption("features.carousel") === true) { if (index < 0) { index = -1; // Will trigger the "jump" to the end } if (index > totalOriginSlides) { index = totalOriginSlides; // Will trigger the "jump" to the start } } else { // Handle non-carousel boundary logic if (index < 0) { index = 0; } if (index >= totalOriginSlides) { index = totalOriginSlides - 1; } } if (!isInteger(index)) { return; } const visibleSlides = getVisibleSlidesFromContainerWidth.call(this); // Hide controls if all original slides are visible 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"); // Calculate the actual index in the 'slides' array (which includes clones) let slidesIndex = index + visibleSlides; this[configSymbol].currentIndex = index; if (slidesIndex < 0) { // We are at the "pre-cloned" slides, set index to the end slidesIndex = totalSlides - 1 - visibleSlides; this[configSymbol].currentIndex = totalOriginSlides - 1; } else if (index > totalOriginSlides) { // We are at the "post-cloned" slides, set index to the start slidesIndex = 0; this[configSymbol].currentIndex = 0; } setMoveProperties.call(this, slides, slidesIndex); // Handle the "jump" back to the start/end for seamless looping if (index === totalOriginSlides) { setTimeout(() => { getWindow().requestAnimationFrame(() => { moveTo.call(this, 0, false); // Jump to first slide without animation }); }, this.getOption("carousel.transition")); } else if (index === -1) { setTimeout(() => { getWindow().requestAnimationFrame(() => { moveTo.call(this, totalOriginSlides - 1, false); // Jump to last slide without animation }); }, this.getOption("carousel.transition")); } fireCustomEvent(this, "monster-slider-moved", { index: this[configSymbol].currentIndex, // Fire with the "real" index }); } /** * @private * @description Initializes all event handlers for navigation, dragging, and resizing. * @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(); }); } // Initialize drag-to-move event listeners 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, }; // Observe slider size changes to update layout const resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { const { width, height } = entry.contentRect; if (width !== initialSize.width || height !== initialSize.height) { self.stopAutoPlay(); // Re-init thumbnails if layout changes if (this.getOption("features.thumbnails")) { initThumbnails.call(this); } // Recalculate visible slides and update CSS property const slidesVisible = getVisibleSlidesFromContainerWidth.call(this); this[sliderElementSymbol].style.setProperty( "--monster-slides-width", `${100 / slidesVisible}%`, ); // Move to start without animation moveTo.call(self, 0, false); self.startAutoPlay(); fireCustomEvent(self, "monster-slider-resized", { width: width, height: height, }); } } }); resizeObserver.observe(this[sliderElementSymbol]); this[configSymbol].resizeObserver = resizeObserver; // Store for cleanup return this; } /** * @private * @description Handles the "mousedown" or "touchstart" event to begin dragging. * @param {Event} e - The mousedown or touchstart event. * @param {string} type - The event type ("mouse" or "touch"). */ function startDragging(e, type) { const { slides } = getSlidesAndTotal.call(this); // Get the width of a single slide for calculating drag distance const widthOfSlider = slides[this[configSymbol].currentIndex]?.offsetWidth; // Set dragging state and initial position this[configSymbol].isDragging = true; this[configSymbol].startPos = getPositionX(e, type); this[sliderElementSymbol].classList.add("grabbing"); // Disable transitions during drag for smooth movement 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"; // Clean up global event listeners 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 = ""; // Re-enable transitions const lastPos = this[configSymbol].draggingPos; this[configSymbol].draggingPos = 0; // Calculate how many slides were "swiped" and move to the new index 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); document.body.addEventListener("touchend", callbackMouseUp); document.body.addEventListener("touchmove", callbackMousemove); } /** * @private * @description Get the X coordinate from a mouse or touch event. * @param {Event} e - The mouse or touch event. * @param {string} type - The event type ("mouse" or "touch"). * @return {number} The clientX position. */ function getPositionX(e, type) { return type === "mouse" ? e.pageX : e.touches[0].clientX; } /** * @private * @description Called on mousemove/touchmove to update the slider's transform. * @param {Event} e - The mousemove or touchmove event. * @param {string} type - The event type ("mouse" or "touch"). */ function dragging(e, type) { if (!this[configSymbol].isDragging) return; this[configSymbol].draggingPos = getPositionX(e, type) - this[configSymbol].startPos; // Update position based on drag delta this[sliderElementSymbol].style.transform = `translateX(calc(${this[configSymbol].lastOffset} + ${this[configSymbol].draggingPos}px))`; } /** * @private * @description Caches references to key elements in the shadow DOM. * @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 * @description Returns the HTML template string for the component's shadow DOM. * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <div class="prev hidden" data-monster-role="prev" part="prev"> <slot name="prev"></slot> </div> <div data-monster-role="slider" part="slides"> <slot></slot> </div> <div class="hidden" data-monster-role="thumbnails" part="thumbnails"></div> <div class="next hidden" data-monster-role="next" part="next"> <slot name="next"></slot> </div> </div>`; } registerCustomElement(Slider);