@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
933 lines (798 loc) • 25.7 kB
JavaScript
/**
* 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);