UNPKG

@ribajs/bs5

Version:

Bootstrap 5 module for Riba.js

952 lines (827 loc) 26.7 kB
import { TemplatesComponent, TemplateFunction } from "@ribajs/core"; import { EventDispatcher } from "@ribajs/events"; import { hasChildNodesTrim, scrollTo } from "@ribajs/utils/src/dom.js"; import { throttle, debounce } from "@ribajs/utils/src/control"; import { Bs5Service } from "../../services/index.js"; import { SlideshowControlsPosition, SlideshowIndicatorsPosition, SlideshowSlidePosition, Bs5SlideshowComponentScope, JsxBs5SlideshowProps, } from "../../types/index.js"; import { Dragscroll, DragscrollOptions, Autoscroll, AutoscrollOptions, ScrollPosition, ScrollEventsService, getScrollPosition, } from "@ribajs/extras"; import templateSlides from "./bs5-slideshow-slides.component.html?raw"; import templateControls from "./bs5-slideshow-controls.component.html?raw"; import templateIndicators from "./bs5-slideshow-indicators.component.html?raw"; import templateImage from "./bs5-slideshow-image.component.html?raw"; const SLIDESHOW_INNER_SELECTOR = ".slideshow-row"; const SLIDES_SELECTOR = `${SLIDESHOW_INNER_SELECTOR} .slide`; export class Bs5SlideshowComponent extends TemplatesComponent { protected resizeObserver?: ResizeObserver; protected bs5: Bs5Service; protected get slideshowInner() { return this.querySelector<HTMLElement>(SLIDESHOW_INNER_SELECTOR); } protected get slideElements() { return this.querySelectorAll<HTMLElement>(SLIDES_SELECTOR); } protected get controlsElements() { return this.querySelectorAll( ".slideshow-control-prev, .slideshow-control-next", ); } protected get indicatorsElement() { return this.querySelector(".slideshow-indicators"); } static get observedAttributes(): (keyof JsxBs5SlideshowProps)[] { return [ "items", "slides-to-scroll", "controls", "controls-position", "drag", "autoplay", "autoplay-interval", "autoplay-velocity", "control-prev-icon-src", "control-next-icon-src", "indicator-inactive-icon-src", "indicator-active-icon-src", "angle", "pause-on-hover", "sticky", "indicators", "indicators-position", "pause", "infinite", ]; } protected defaultScope: Bs5SlideshowComponentScope = { // Options slidesToScroll: 1, controls: true, controlsPosition: "inside-middle", pauseOnHover: true, sticky: false, indicators: true, indicatorsPosition: "inside-bottom", pause: false, drag: true, touchScroll: true, autoplay: false, autoplayInterval: 0, autoplayVelocity: 0.8, controlPrevIconSrc: "", controlNextIconSrc: "", indicatorActiveIconSrc: "", indicatorInactiveIconSrc: "", angle: "horizontal", infinite: true, // Template methods next: this.next.bind(this), prev: this.prev.bind(this), goTo: this.goTo.bind(this), enableTouchScroll: this.enableTouchScroll.bind(this), disableTouchScroll: this.disableTouchScroll.bind(this), // Template properties items: undefined, // Classes controlsPositionClass: "", indicatorsPositionClass: "", intervalCount: 0, intervalProgress: 0, nextIndex: -1, prevIndex: -1, activeIndex: 0, }; public static tagName = "bs5-slideshow"; protected templateAttributes = [ { name: "class", required: false, }, { name: "handle", required: false, }, { name: "type", required: true, }, { name: "active", type: "boolean", required: false, }, { name: "index", type: "number", required: false, }, { name: "src", type: "string", required: false, }, ]; protected autobind = true; protected dragscrollService?: Dragscroll; protected continuousAutoplayService?: Autoscroll; protected scrollEventsService?: ScrollEventsService; protected templateControls = templateControls; protected templateIndicators = templateIndicators; protected autoplayIntervalIndex: number | null = null; protected continuousAutoplayIntervalIndex: number | null = null; protected resumeTimer: number | null = null; protected routerEvents = new EventDispatcher("main"); public scope: Bs5SlideshowComponentScope = { ...this.defaultScope, }; constructor() { super(); this.bs5 = Bs5Service.getSingleton(); // set event listeners to the this-bound version once, so we can easily pass them to DOM event handlers and remove them again later this.onViewChanges = this.onViewChanges.bind(this); this.onVisibilityChanged = this.onVisibilityChanged.bind(this); this.onScroll = this.onScroll.bind(this); this.onScrollend = this.onScrollend.bind(this); this.onMouseIn = this.onMouseIn.bind(this); this.onMouseOut = this.onMouseOut.bind(this); } /** * Go to next slide */ public next() { this.scrollToNextSlide(); } /** * Go to prev slide */ public prev() { this.scrollToPrevSlide(); } /** * Go to slide by index * @param index */ public goTo(index: number) { if ( index < 0 || !this.scope.items?.[index] || !this.slideElements[index] || !this.slideshowInner ) { this.throw(new Error(`Can't go to slide of index ${index}`)); console.error("items", this.scope.items); console.error("this.slideElements", this.slideElements); console.error("this.slideshowInner", this.slideshowInner); return; } this.setSlidePositions(); if (!this.slideElements[index]) { this.throw(new Error(`Slide element with index "${index}" not found!`)); } else { scrollTo( this.slideElements[index], 0, this.slideshowInner, this.scope.angle, ); this.setSlideActive(index); } } public getNextIndex(centeredIndex: number) { let nextIndex = centeredIndex + this.scope.slidesToScroll; if (nextIndex >= this.slideElements.length) { if (!this.scope.infinite) { return this.slideElements.length - 1; } nextIndex = nextIndex - this.slideElements.length; } return nextIndex; } public getPrevIndex(centeredIndex: number) { let prevIndex = centeredIndex - this.scope.slidesToScroll; if (prevIndex < 0) { if (!this.scope.infinite) { return 0; } prevIndex = this.slideElements.length - 1 + (prevIndex + 1); } return prevIndex; } public scrollToNearestSlide() { this.setSlidePositions(); const nearestIndex = this.getMostCenteredSlideIndex(); return this.goTo(nearestIndex); } protected scrollToNextSlide() { this.setSlidePositions(); const centeredIndex = this.getMostCenteredSlideIndex(); const nextIndex = this.getNextIndex(centeredIndex); return this.goTo(nextIndex); } protected scrollToPrevSlide() { this.setSlidePositions(); const centeredIndex = this.getMostCenteredSlideIndex(); const prevIndex = this.getPrevIndex(centeredIndex); return this.goTo(prevIndex); } protected initOptions() { this.setOptions(); } protected setOptions() { if (this.scope.autoplay) { this.enableAutoplay(); } else { this.disableAutoplay(); } if (this.scope.drag) { this.enableDesktopDragscroll(); } else { this.disableDesktopDragscroll(); } if (this.scope.touchScroll) { this.enableTouchScroll(); } else { this.disableTouchScroll(); } this.setControlsOptions(); this.setIndicatorsOptions(); } protected setControlsOptions() { const position = this.scope.controlsPosition?.split( "-", ) as SlideshowControlsPosition[]; if (this.scope.controls && position.length === 2) { this.scope.controlsPositionClass = `control-${position[0]} control-${position[1]}`; } else { this.scope.controlsPositionClass = ""; } } protected setIndicatorsOptions() { const positions = this.scope.indicatorsPosition?.split( "-", ) as SlideshowIndicatorsPosition[]; if (this.scope.indicators && positions.length === 2) { this.scope.indicatorsPositionClass = `indicators-${positions[0]} indicators-${positions[1]}`; } else { this.scope.indicatorsPositionClass = ""; } } protected _onViewChanges() { this.debug("onViewChanges"); if (!this.scope.items?.length || !this.slideElements?.length) { return; } try { this.setSlidePositions(); const index = this.setCenteredSlideActive(); if (this.scope.sticky) { this.goTo(index); } } catch (error: any) { this.throw(error); } } protected onViewChanges = debounce(this._onViewChanges.bind(this)); protected onVisibilityChanged(event: CustomEvent) { if (event.detail.visible) { this.dragscrollService?.checkDraggable(); this.continuousAutoplayService?.update(); } } protected _onScroll() { // } protected onScroll = debounce(this._onScroll.bind(this)); protected onScrollend() { if (!this.scope.items?.length) { return; } try { this.setSlidePositions(); this.setCenteredSlideActive(); if (this.scope.sticky) { this.scrollToNearestSlide(); } } catch (error: any) { this.throw(error); } } protected onMouseIn() { if (this.scope.pauseOnHover) { this.scope.pause = true; } } protected onMouseOut() { this.resume(); } protected _onMouseUp() { // } protected onMouseUp = throttle(this._onMouseUp.bind(this)); protected _resume() { this.setSlidePositions(); this.scope.pause = false; } /** Resume if this method was not called up for [delay] milliseconds */ protected resume = throttle(this._resume.bind(this), 500); protected connectedCallback() { // If slides not added by template or attribute if (!this.scope.items?.length && this.slideElements) { this.addItemsByChilds(); } super.connectedCallback(); this.init(Bs5SlideshowComponent.observedAttributes); this.addEventListeners(); } protected addEventListeners() { this.routerEvents.on("newPageReady", this.onViewChanges); // If sidebar itself resizes if (window.ResizeObserver) { this.resizeObserver = new window.ResizeObserver(this.onViewChanges); this.resizeObserver?.observe(this); } // If window resizes window.addEventListener("resize", this.onViewChanges, { passive: true }); // Custom event triggered by some parent components when this component changes his visibility, e.g. triggered in the bs5-tabs component this.addEventListener( "visibility-changed" as any, this.onVisibilityChanged, ); this.slideshowInner?.addEventListener("scroll", this.onScroll, { passive: true, }); this.slideshowInner?.addEventListener("scrollended", this.onScrollend, { passive: true, }); this.addEventListener("mouseenter", this.onMouseIn, { passive: true }); this.addEventListener("mouseover", this.onMouseIn, { passive: true }); this.addEventListener("focusin", this.onMouseIn, { passive: true }); this.addEventListener("touchstart", this.onMouseIn, { passive: true }); this.addEventListener("mouseleave", this.onMouseOut, { passive: true }); this.addEventListener("focusout", this.onMouseOut, { passive: true }); this.addEventListener("mouseup", this.onMouseUp, { passive: true }); this.addEventListener("touchend", this.onMouseUp, { passive: true }); this.addEventListener("scroll", this.onMouseUp, { passive: true }); this.addEventListener("scrollend", this.onMouseUp, { passive: true }); // See ScrollEventsService for this event this.addEventListener("scrollended", this.onMouseUp, { passive: true }); } protected removeEventListeners() { this.routerEvents.off("newPageReady", this.onViewChanges, this); window.removeEventListener("resize", this.onViewChanges); this.resizeObserver?.unobserve(this); this.bs5.events.off("breakpoint:changed", this.onViewChanges, this); this.removeEventListener( "visibility-changed" as any, this.onVisibilityChanged, ); this.slideshowInner?.removeEventListener("scroll", this.onScroll); this.slideshowInner?.removeEventListener("scrollended", this.onScrollend); this.removeEventListener("mouseenter", this.onMouseIn); this.removeEventListener("mouseover", this.onMouseIn); this.removeEventListener("focusin", this.onMouseIn); this.removeEventListener("touchstart", this.onMouseIn); this.removeEventListener("mouseleave", this.onMouseOut); this.removeEventListener("focusout", this.onMouseOut); this.removeEventListener("mouseup", this.onMouseUp); this.removeEventListener("touchend", this.onMouseUp); this.removeEventListener("scroll", this.onMouseUp); this.removeEventListener("scrollend", this.onMouseUp); // See ScrollEventsService for this event this.removeEventListener("scrollended", this.onMouseUp); } protected initAll() { this.initSlideshowInner(); this.initOptions(); this.addEventListeners(); // initial this.onViewChanges(); this.onScrollend(); } protected async beforeBind() { await super.beforeBind(); this.validateItems(); } protected async afterBind() { this.initAll(); await super.afterBind(); } protected initSlideshowInner() { if (!this.slideshowInner) { this.throw(new Error("Can't init slideshow inner!")); return; } this.scrollEventsService = new ScrollEventsService(this.slideshowInner); } protected enableDesktopDragscroll() { if (!this.dragscrollService) { if (!this.slideshowInner) { return; } const dragscrollOptions: DragscrollOptions = { detectGlobalMove: true }; this.dragscrollService = new Dragscroll( this.slideshowInner, dragscrollOptions, ); } } protected disableDesktopDragscroll() { if (this.dragscrollService) { this.dragscrollService.destroy(); this.dragscrollService = undefined; } } public enableTouchScroll() { this.classList.remove("touchscroll-disabled"); } public disableTouchScroll() { this.classList.add("touchscroll-disabled"); } protected enableContinuousAutoplay() { if (!this.continuousAutoplayService && this.slideshowInner) { const autoscrollOptions: AutoscrollOptions = { velocity: this.scope.autoplayVelocity, angle: this.scope.angle, pauseOnHover: this.scope.pauseOnHover, }; this.continuousAutoplayService = new Autoscroll( this.slideshowInner, autoscrollOptions, ); } // on continuous autoplay the scrollended event is never triggered, so call this method all `intervalsTimeMs` milliseconds as a WORKAROUND if (this.continuousAutoplayIntervalIndex === null) { // intervals are depending on the autoscrolling speed (autoplayVelocity) const intervalsTimeMs = this.scope.autoplayVelocity * 10000; // this.debug('intervalsTimeMs', intervalsTimeMs); this.continuousAutoplayIntervalIndex = window.setInterval( this.onScrollend.bind(this), intervalsTimeMs, ); } } protected disableContinuousAutoplay() { if (this.continuousAutoplayService) { this.continuousAutoplayService.pause(); this.continuousAutoplayService.destroy(); this.continuousAutoplayService = undefined; } if (this.continuousAutoplayIntervalIndex !== null) { window.clearInterval(this.continuousAutoplayIntervalIndex); this.continuousAutoplayIntervalIndex = null; } } protected resetIntervalAutoplay() { this.scope.intervalCount = 0; this.scope.intervalProgress = 0; } protected enableIntervalAutoplay() { const steps = 100; if (this.autoplayIntervalIndex === null) { this.autoplayIntervalIndex = window.setInterval(() => { if (!this.scope.pause) { this.scope.intervalCount += steps; this.scope.intervalProgress = (this.scope.intervalCount / this.scope.autoplayInterval) * 100; if (this.scope.intervalProgress >= 100) { this.next(); } } }, steps); } } protected disableIntervalAutoplay() { this.resetIntervalAutoplay(); console.debug("disableIntervalAutoplay", this.autoplayIntervalIndex); if (this.autoplayIntervalIndex !== null) { window.clearInterval(this.autoplayIntervalIndex); this.autoplayIntervalIndex = null; } } protected disableAutoplay() { this.disableIntervalAutoplay(); this.disableContinuousAutoplay(); } protected enableAutoplay() { this.disableAutoplay(); // continuous scrolling if (this.scope.autoplayInterval <= 0) { this.enableContinuousAutoplay(); } else { this.enableIntervalAutoplay(); } } protected transformTemplateAttributes(attributes: any, index: number) { attributes = super.transformTemplateAttributes(attributes, index); attributes.handle = attributes.handle || index.toString(); attributes.index = index; attributes.class = attributes.class || ""; attributes.class += " slide"; return attributes; } protected validateItems() { if (!this.scope.items) { this.throw(new Error("No items to validate!")); return; } for (let i = 0; i < this.scope.items.length; i++) { const item = this.scope.items[i]; item.index = item.index || i; item.active = item.active || false; item.title = item.title || ""; item.handle = item.handle || item.index.toString(); item.position = item.position || ({ centerX: 0, centerY: 0, } as SlideshowSlidePosition); item.class = item.class || ""; item.class += " slide"; item.content = item.content || templateImage; } } /** * Add slide by template element * @param tpl template element */ protected addItemByTemplate(tpl: HTMLTemplateElement, index: number) { const attributes = this.getTemplateAttributes(tpl, index); const content = tpl.innerHTML; if (attributes.type) { if (attributes.type === "slide") { if (!this.scope.items) { this.scope.items = []; } this.scope.items.push({ ...attributes, content }); } if (attributes.type === "controls") { this.templateControls = content; } if (attributes.type === "indicators") { this.templateIndicators = content; } } } /** * Add slides by child elements (not as template elements) * @param tpl template element */ protected addItemsByChilds() { if (!this.slideElements) { this.throw( new Error( "Can't not add items by child's because no slide child's are found!", ), ); } this.slideElements.forEach((slideElement, index) => { const handle = slideElement.getAttribute("handle") || slideElement.getAttribute("id") || index.toString(); slideElement.setAttribute("index", index.toString()); const attributes = { handle, active: false, content: slideElement.innerHTML, index, position: { ...slideElement.getBoundingClientRect(), centerY: 0, centerX: 0, }, }; if (!this.scope.items) { this.scope.items = []; } this.scope.items.push(attributes); }); } protected getScrollPosition(): ScrollPosition | null { if (!this.slideshowInner) { return null; } const scrollPosition = getScrollPosition(this.slideshowInner); return scrollPosition; } /** * get closest number * @see https://stackoverflow.com/a/35000557 * @param goal the number which this number should be closest to * @param curr current number in loop * @param prev previous number or closest value found so far */ protected getCurrentClosestNumber(goal: number, curr: number, prev: number) { return Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev; } protected getMostCenteredSlideIndex() { if (!this.scope.items?.length) { this.throw(new Error("No slide items found!")); return -1; } let nearZero = Math.abs( this.scope.angle === "vertical" ? this.scope.items[0].position.centerY : this.scope.items[0].position.centerX, ); let minIndex = 0; for (let i = 1; i < this.scope.items.length; i++) { const position = Math.abs( this.scope.angle === "vertical" ? this.scope.items[i].position.centerY : this.scope.items[i].position.centerX, ); nearZero = this.getCurrentClosestNumber(0, position, nearZero); if (nearZero === position) { minIndex = i; } } return minIndex; } protected setAllSlidesInactive(excludeIndex = -1) { if (!this.slideElements || !this.scope.items?.length) { return; } for (let index = 0; index < this.scope.items.length; index++) { if (index !== excludeIndex) { if (this.scope.items[index]) { this.scope.items[index].active = false; } if (this.slideElements[index] && this.slideElements[index].classList) { this.slideElements[index].classList.remove("active"); } } } } protected setSlideActive(index: number) { if (index === -1 || !this.scope.items?.length) { console.warn(new Error("Most centered slide not found!")); index = 0; } if (!this.scope.items?.[index]) { index = 0; } if (!this.scope.items?.[index]) { this.throw(new Error("Slide item to set active not found!")); return 0; } this.setAllSlidesInactive(index); this.scope.items[index].active = true; this.scope.activeIndex = index; this.scope.nextIndex = this.getNextIndex(index); this.scope.prevIndex = this.getPrevIndex(index); this.resetIntervalAutoplay(); if (this.slideElements && this.slideElements[index].classList) { this.slideElements[index].classList.add("active"); } } protected setCenteredSlideActive(): number { const index = this.getMostCenteredSlideIndex(); this.setSlideActive(index); return index; } protected isScrollableToIndex(index: number) { const scrollPosition = this.getScrollPosition(); if (!this.scope.items?.[index] || !this.slideshowInner || !scrollPosition) { return false; } const maxScrollTo = this.scope.angle === "vertical" ? scrollPosition.maxY : scrollPosition.maxX; const scrollTo = this.scope.angle === "vertical" ? this.slideshowInner.scrollTop + this.scope.items[index].position.centerY : this.slideshowInner.scrollLeft + this.scope.items[index].position.centerX; return scrollTo <= maxScrollTo && scrollTo >= 0; } protected setSlidePositions() { if (!this.bound) { return; } if (this.scope.items?.length !== this.slideElements?.length) { console.warn( new Error( `The slide objects must be the same size as the slide elements! items (${this.scope.items?.length}) !== slideElements (${this.slideElements?.length})`, ), this.slideElements, this, ); return; } if (!this.slideshowInner) { return; } const mainBoundingClient = this.slideshowInner.getBoundingClientRect(); for (let i = 0; i < this.scope.items.length; i++) { const slideElement = this.slideElements[i]; const item = this.scope.items[i]; const rect = slideElement.getBoundingClientRect(); rect.x -= mainBoundingClient.x; rect.y -= mainBoundingClient.y; item.position = { ...rect, // 0 if element is in the middle / center centerY: rect.y + rect.height / 2 - mainBoundingClient.height / 2, // 0 if element is in the middle / center centerX: rect.x + rect.width / 2 - mainBoundingClient.width / 2, }; } } protected requiredAttributes(): string[] { return ["items"]; } /** * Similar to attributeChangedCallback but attribute arguments are already parsed as they are stored in the scope * @param attributeName * @param oldValue * @param newValue * @param namespace */ protected parsedAttributeChangedCallback( attributeName: keyof Bs5SlideshowComponentScope, oldValue: any, newValue: any, namespace: string | null, ) { super.parsedAttributeChangedCallback( attributeName, oldValue, newValue, namespace, ); if (attributeName === "items") { this.validateItems(); } if (attributeName === "autoplay") { if (this.scope.autoplay) { this.enableAutoplay(); } else { this.disableAutoplay(); } } if (attributeName === "drag") { if (this.scope.drag) { this.enableDesktopDragscroll(); } else { this.disableDesktopDragscroll(); } } if (attributeName === "touchScroll") { if (this.scope.touchScroll) { this.enableTouchScroll(); } else { this.disableTouchScroll(); } } if (attributeName === "controls" || attributeName === "controlsPosition") { this.setControlsOptions(); } if ( attributeName === "indicators" || attributeName === "indicatorsPosition" ) { this.setIndicatorsOptions(); } } // deconstruction protected disconnectedCallback() { this.removeEventListeners(); // this.scrollEventsService?.destroy(); // this.disableAutoplay(); // this.disableDesktopDragscroll(); // return super.disconnectedCallback(); } protected template(): ReturnType<TemplateFunction> { // Only set the component template if there no childs or the childs are templates if (!hasChildNodesTrim(this) || this.hasOnlyTemplateChilds()) { // ('Full template!', this.templateIndicators); return templateSlides + this.templateControls + this.templateIndicators; } else { // this.debug('Append to template!'); // Prepend control elements if no custom control elements in template are found if (this.controlsElements.length <= 0) { this.innerHTML += this.templateControls; } if (!this.indicatorsElement) { this.innerHTML += this.templateIndicators; } return null; } } }