Source

client/ui/elements/carousel.js

/**
 *
 * The Carousel component derives from [Component](kiss.ui.Component.html).
 *
 * It displays a list of images with automatic sliding.
 *
 * @param {object} config
 * @param {string[]} config.images - List of image src
 * @param {string|number} [config.width] - Any valid CSS width value
 * @param {string|number} [config.height] - Any valid CSS height value
 * @param {number} [config.delay] - Auto slide delay in milliseconds (default = 3000)
 * @param {boolean} [config.showArrows] - Display left/right arrows on hover
 * @param {boolean} [config.zoomable] - Enable zoom on active image click
 * @param {string|number} [config.zoomWidth] - Zoomed image width (default = 100%)
 * @param {string|number} [config.zoomHeight] - Zoomed image height (default = 100%)
 * @param {string} [config.zoomShadow] - Box shadow applied to zoomed image
 * @returns this
 *
 * ## Generated markup
 * ```
 * <a-carousel class="a-carousel">
 *  <div class="carousel-track">
 *      <img class="carousel-image carousel-image-active">
 *  </div>
 *  <span class="carousel-arrow carousel-arrow-left fas fa-chevron-left"></span>
 *  <span class="carousel-arrow carousel-arrow-right fas fa-chevron-right"></span>
 * </a-carousel>
 * ```
 */
kiss.ui.Carousel = class Carousel extends kiss.ui.Component {
	constructor() {
		super()
	}

	/**
	 * Generates a Carousel from a JSON config
	 *
	 * @ignore
	 * @param {object} config - JSON config
	 * @returns {HTMLElement}
	 */
	init(config = {}) {
		super.init(config)

		this.images = Array.isArray(config.images) ? config.images.filter(Boolean) : []
		this.currentIndex = 0
		this.delay = (typeof config.delay == "number" && config.delay > 0) ? config.delay : 3000
		this.showArrows = config.showArrows === true
		this.zoomable = config.zoomable === true
		this.zoomWidth = this._computeCssSize(config.zoomWidth, "100%")
		this.zoomHeight = this._computeCssSize(config.zoomHeight, "100%")
		this.zoomShadow = config.zoomShadow || "var(--shadow-4)"
		this.isZoomed = false

		this.innerHTML = `
            ${(this.zoomable) ? '<div class="carousel-zoom-mask"></div>' : ""}
            <div class="carousel-track"></div>
            ${(this.showArrows) ? '<span class="carousel-arrow carousel-arrow-left fas fa-chevron-left"></span><span class="carousel-arrow carousel-arrow-right fas fa-chevron-right"></span>' : ""}
        `.removeExtraSpaces()

		this.zoomMask = this.querySelector(".carousel-zoom-mask")
		this.track = this.querySelector(".carousel-track")
		this.arrowLeft = this.querySelector(".carousel-arrow-left")
		this.arrowRight = this.querySelector(".carousel-arrow-right")

		this._renderImages()

		this._setProperties(config, [
			[
				["display", "position", "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight", "margin", "border", "borderStyle", "borderWidth", "borderColor", "borderRadius", "boxShadow", "overflow", "background", "backgroundColor"],
				[this.style]
			]
		])

		if (!config.display) this.style.display = "block"

		if (this.zoomable) {
			this.classList.add("carousel-zoomable")
			this.style.setProperty("--carousel-zoom-width", this.zoomWidth)
			this.style.setProperty("--carousel-zoom-height", this.zoomHeight)
			this.style.setProperty("--carousel-zoom-shadow", this.zoomShadow)

			this.track.onclick = (event) => {
				const clickedImage = event.target.closest(".carousel-image")
				if (!clickedImage || clickedImage != this.currentImage) return
				this.toggleZoom()
			}

			if (this.zoomMask) this.zoomMask.onclick = () => this.setZoom(false)
		}

		if (this.arrowLeft && this.arrowRight) {
			this.arrowLeft.onclick = (event) => {
				event.stopPropagation()
				this.previous()
			}
			this.arrowRight.onclick = (event) => {
				event.stopPropagation()
				this.next()
			}
		}

		this.start()
		return this
	}

	/**
	 * Start the automatic image rotation
	 *
	 * @returns this
	 */
	start() {
		this.stop()
		if (this.images.length <= 1) return this
		this.timer = setInterval(() => this.next(true), this.delay)
		return this
	}

	/**
	 * Stop the automatic image rotation
	 *
	 * @returns this
	 */
	stop() {
		if (this.timer) clearInterval(this.timer)
		this.timer = null
		return this
	}

	/**
	 * Go to the next image
	 *
	 * @param {boolean} [isAuto] - True when called by automatic rotation
	 * @returns this
	 */
	next(isAuto = false) {
		return this.goTo(this.currentIndex + 1, isAuto, "slideInRight")
	}

	/**
	 * Go to the previous image
	 *
	 * @returns this
	 */
	previous() {
		return this.goTo(this.currentIndex - 1, false, "slideInLeft")
	}

	/**
	 * Go to an image by index
	 *
	 * @param {number} index - Target index
	 * @param {boolean} [isAuto] - True when called by automatic rotation
	 * @param {string} [transitionAnimation]
	 * @returns this
	 */
	goTo(index, isAuto = false, transitionAnimation) {
		if (this.images.length == 0) return this
		if (isAuto && this.isZoomed) return this
		if (!isAuto && this.isZoomed) this.setZoom(false)

		const imageCount = this.images.length
		const nextIndex = (index % imageCount + imageCount) % imageCount
		const previousImage = this.currentImage
		const nextImage = this.imageNodes[nextIndex]

		if (previousImage) previousImage.classList.remove("carousel-image-active")
		if (nextImage) {
			nextImage.classList.add("carousel-image-active")
			if (transitionAnimation) nextImage.setAnimation(transitionAnimation)
			this.currentImage = nextImage
		}

		this.currentIndex = nextIndex

		if (!isAuto) this.start()

		return this
	}

	/**
	 * Update images list
	 *
	 * @param {string[]} images
	 * @returns this
	 */
	setImages(images = []) {
		if (this.isZoomed) this.setZoom(false)
		this.config.images = images
		this.images = Array.isArray(images) ? images.filter(Boolean) : []
		this.currentIndex = 0
		this._renderImages()
		this.start()
		return this
	}

	/**
	 * Update auto slide delay
	 *
	 * @param {number} delay
	 * @returns this
	 */
	setDelay(delay) {
		if (typeof delay != "number" || delay <= 0) return this
		this.config.delay = delay
		this.delay = delay
		this.start()
		return this
	}

	/**
	 * Set zoom state
	 *
	 * @param {boolean} [zoomState=true]
	 * @returns this
	 */
	setZoom(zoomState = true) {
		if (!this.zoomable) return this
		if (this.isZoomed == zoomState) return this

		this.isZoomed = zoomState

		if (zoomState) {
			this.classList.add("carousel-zoomed")
			this.stop()
		} else {
			this.classList.remove("carousel-zoomed")
			this.start()
		}

		return this
	}

	/**
	 * Toggle zoom state
	 *
	 * @returns this
	 */
	toggleZoom() {
		return this.setZoom(!this.isZoomed)
	}

	/**
	 * Set carousel width
	 *
	 * @param {*} width - A valid CSS width value
	 * @returns this
	 */
	setWidth(width) {
		this.config.width = width
		this.style.width = this._computeSize("width")
		return this
	}

	/**
	 * Set carousel height
	 *
	 * @param {*} height - A valid CSS height value
	 * @returns this
	 */
	setHeight(height) {
		this.config.height = height
		this.style.height = this._computeSize("height")
		return this
	}

	/**
	 * @private
	 * @ignore
	 */
	_afterDisconnected() {
		this.stop()
	}

	/**
	 * @private
	 * @ignore
	 */
	_renderImages() {
		this.track.innerHTML = this.images.map((src, index) => {
			return `<img class="carousel-image ${(index == this.currentIndex) ? "carousel-image-active" : ""}" src="${src}" loading="lazy">`
		}).join("")

		this.imageNodes = Array.from(this.querySelectorAll(".carousel-image"))
		this.currentImage = this.imageNodes[this.currentIndex] || null
	}

	/**
	 * @private
	 * @ignore
	 */
	_computeCssSize(value, defaultValue) {
		if (value == null) return defaultValue
		if (typeof value == "number") return value + "px"
		return value
	}
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-carousel", kiss.ui.Carousel)

/**
 * Shorthand to create a new Carousel. See [kiss.ui.Carousel](kiss.ui.Carousel.html)
 *
 * @param {object} config
 * @returns HTMLElement
 */
const createCarousel = (config) => document.createElement("a-carousel").init(config)