UNPKG

@ribajs/bs5

Version:

Bootstrap 5 module for Riba.js

870 lines (742 loc) 23.2 kB
import { Component, TemplateFunction } from "@ribajs/core"; import { EventDispatcher } from "@ribajs/events"; import { scrollTo } from "@ribajs/utils/src/dom.js"; import { debounce } from "@ribajs/utils/src/control"; import { Bs5Service } from "../../services/index.js"; import { Bs5SliderControlsPosition, Bs5SliderIndicatorsPosition, Bs5SliderComponentScope, JsxBs5SliderProps, Bs5SliderSlide, } from "../../types/index.js"; import { Dragscroll, DragscrollOptions, Autoscroll, ScrollPosition, ScrollEventsService, getScrollPosition, ScrollEventDetail, } from "@ribajs/extras"; const SLIDER_INNER_SELECTOR = ".slider-row"; const SLIDES_SELECTOR = `${SLIDER_INNER_SELECTOR} .slide`; /** * @event scrolling - Fires when the slider is scrolling * @event scrollended - Fires when the slider has scrolled */ export class Bs5SliderComponent extends Component { protected resizeObserver?: ResizeObserver; protected bs5: Bs5Service; protected get sliderInner() { return this.querySelector<HTMLElement>(SLIDER_INNER_SELECTOR); } protected get slideElements() { return Array.from(this.querySelectorAll<HTMLElement>(SLIDES_SELECTOR)); } protected get controlsElements() { return this.querySelectorAll(".slider-control-prev, .slider-control-next"); } protected get indicatorsElement() { return this.querySelector(".slider-indicators"); } public static EVENTS = { scrolling: "scrolling", scrollended: "scrollended", }; static get observedAttributes(): (keyof JsxBs5SliderProps)[] { return [ "items", "slides-to-scroll", "controls", "controls-position", "drag", "sticky", "indicators", "indicators-position", "infinite", "columns", ]; } protected defaultScope: Bs5SliderComponentScope = { // Options slidesToScroll: 1, controls: true, controlsPosition: "inside-middle", sticky: false, indicators: true, indicatorsPosition: "inside-bottom", drag: false, touchScroll: true, angle: "horizontal", infinite: false, columns: 0, // States items: [], nextIndex: -1, prevIndex: -1, enableNextControl: false, enablePrevControl: false, showControls: false, showIndicators: false, activeSlides: [], isScrolling: false, slideItemStyle: {}, // 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), // Classes controlsPositionClass: "", indicatorsPositionClass: "", }; public static tagName = "bs5-slider"; protected autobind = true; protected dragscrollService?: Dragscroll; protected continuousAutoplayService?: Autoscroll; protected scrollEventsService?: ScrollEventsService; protected autoplayIntervalIndex: number | null = null; protected continuousAutoplayIntervalIndex: number | null = null; protected resumeTimer: number | null = null; protected routerEvents = new EventDispatcher("main"); public scope: Bs5SliderComponentScope = { ...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.onScroll = debounce(this.onScroll.bind(this), 100) } /** * Go to next slide */ public next() { this.scrollToNextSlide(); } /** * Go to prev slide */ public prev() { this.scrollToPrevSlide(); } /** * Go to slide by index * @param index The index of the slide you want to go to * @param fromRight If true, the index is calculated from the right side of the slider, set this to true if you slide to the right * @returns The index of the slide you went to or -1 if the end of the slider is reached */ protected goTo(index: number, fromRight = false) { if (index === -1 && !this.scope.infinite) { console.warn(`End of slider reached!`); return -1; } // The `scrollTo` method scrolls to the left, so we need to calculate the index of the right slide item if (index !== -1 && fromRight && this.scope.activeSlides.length > 1) { index = index - this.scope.activeSlides.length + 1; if (index < 0) { index = 0; } } const item = this.scope.items[index]; if (!item || !this.sliderInner || !item.el) { console.warn(`Slide element with index "${index}" not found!`); return -1; } scrollTo(item.el, 0, this.sliderInner, this.scope.angle); return index; } /** * Calculate the next index to scroll to * @param currentActive The current active index * @returns The next index or -1 if there is no next index */ protected getNextIndex(currentActive: number) { let nextIndex = currentActive + this.scope.slidesToScroll; if (nextIndex > this.scope.items.length - 1) { if (this.scope.infinite) { nextIndex = nextIndex - this.scope.items.length; } else { // return this.scope.items.length - 1 return -1; } } return nextIndex; } /** * Calculate the previous index to scroll to * @param currentActive The current active index * @returns The previous index or -1 if there is no previous index */ protected getPrevIndex(currentActive: number) { let prevIndex = currentActive - this.scope.slidesToScroll; if (prevIndex < 0) { if (this.scope.infinite) { prevIndex = this.scope.items.length - 1 + (prevIndex + 1); } else { // return 0 return -1; } } return prevIndex; } /** * Scroll to the next slide */ protected scrollToNextSlide() { // Skip slide if already scrolling if (this.scope.isScrolling) { this.scope.nextIndex = this.getNextIndex(this.scope.nextIndex); this.updateControls(); } return this.goTo(this.scope.nextIndex, true); } /** * Scroll to the previous slide */ protected scrollToPrevSlide() { // Skip slide if already scrolling if (this.scope.isScrolling) { this.scope.prevIndex = this.getPrevIndex(this.scope.prevIndex); this.updateControls(); } return this.goTo(this.scope.prevIndex, false); } protected initOptions() { this.setOptions(); } protected setOptions() { if (this.scope.drag) { this.enableDesktopDragscroll(); } else { this.disableDesktopDragscroll(); } if (this.scope.touchScroll) { this.enableTouchScroll(); } else { this.disableTouchScroll(); } this.updateColumns(); this.setControlsOptions(); this.setIndicatorsOptions(); } protected updateColumns() { this.scope.slideItemStyle ||= {}; if (this.scope.columns > 0) { this.scope.slideItemStyle.flex = `0 0 ${100 / this.scope.columns}%`; } else { this.scope.slideItemStyle.flex = ""; } } protected setControlsOptions() { const position = this.scope.controlsPosition?.split( "-", ) as Bs5SliderControlsPosition[]; 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 Bs5SliderIndicatorsPosition[]; 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.updateSlides(); } 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(event: CustomEvent<ScrollEventDetail>) { this.scope.isScrolling = true; // Forward the event to this component, so that other components can listen to it this.dispatchEvent( new CustomEvent<ScrollEventDetail>(event.type, { detail: event.detail }), ); } protected onScrollEnd(event: CustomEvent<ScrollEventDetail>) { this.scope.isScrolling = false; if (!this.scope.items?.length) { return; } if (event.detail.direction === "none") { return; } try { this.updateSlides(); } catch (error: any) { this.throw(error); } // Forward the event to this component, so that other components can listen to it this.dispatchEvent( new CustomEvent<ScrollEventDetail>(event.type, { detail: event.detail }), ); } protected connectedCallback() { if (this.scope.items.length || this.scope.slideTemplate) { this.updateItems(); } else { this.initItemsByChildren(); } super.connectedCallback(); this.init(Bs5SliderComponent.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.sliderInner?.addEventListener( Bs5SliderComponent.EVENTS.scrolling, this.onScroll as EventListener, { passive: true, }, ); this.sliderInner?.addEventListener( Bs5SliderComponent.EVENTS.scrollended, this.onScrollEnd as EventListener, { 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.sliderInner?.removeEventListener( Bs5SliderComponent.EVENTS.scrolling, this.onScroll as EventListener, ); this.sliderInner?.removeEventListener( Bs5SliderComponent.EVENTS.scrollended, this.onScrollEnd as EventListener, ); } protected initAll() { this.initSlideshowInner(); this.initOptions(); this.addEventListeners(); // initial this.updateSlides(); } protected async beforeBind() { await super.beforeBind(); this.validateItems(); } protected async afterBind() { this.initAll(); this.updateItems(); this.classList.add(`${Bs5SliderComponent.tagName}-ready`); await super.afterBind(); } protected async afterAllBind() { this.updateItems(); await super.afterAllBind(); } protected initSlideshowInner() { if (!this.sliderInner) { this.throw(new Error("Can't init slider inner!")); return; } this.scrollEventsService = new ScrollEventsService(this.sliderInner); } protected enableDesktopDragscroll() { if (!this.dragscrollService) { if (!this.sliderInner) { return; } const dragscrollOptions: DragscrollOptions = { detectGlobalMove: true }; this.dragscrollService = new Dragscroll( this.sliderInner, 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 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; } } public updateItems() { let hasChange = false; const slideEls = this.slideElements; // Check if items are missing only if there is no slide template if (!this.scope.slideTemplate) { slideEls.forEach((slideEl, index) => { const exists = this.scope.items.find((item) => item.el === slideEl); if (!exists) { this.addItemByElement(slideEl, index); hasChange = true; } }); } // Check if items are not existing anymore for (const item of this.scope.items) { const exists = slideEls.find((slideEl) => slideEl === item.el); if (!exists) { this.removeItem(item.index, false); hasChange = true; } } if (hasChange) { this.updateItemIndexes(); this.updateSlides(); } return hasChange; } /** * Remove slide item * @param index The index of the item to remove */ protected removeItem(index: number, updateIndex = true) { const item = this.scope.items[index]; if (!item) { return; } item.el?.remove(); this.scope.items.splice(index, 1); if (updateIndex) this.updateItemIndexes(); } protected updateItemIndexes() { for (let i = 0; i < this.scope.items.length; i++) { const item = this.scope.items[i]; item.index = i; } } protected addItemByElement(slideElement: HTMLElement, index: number) { slideElement.setAttribute("index", index.toString()); const attributes: Bs5SliderSlide = { active: false, index, el: slideElement, }; this.scope.items.push(attributes); } /** * Add slides by child elements * @param tpl template element */ protected initItemsByChildren() { if (!this.slideElements) { this.throw( new Error( "Can't not add items by child's because no slide child's are found!", ), ); } this.scope.items = []; this.slideElements.forEach(this.addItemByElement.bind(this)); } protected getScrollPosition(): ScrollPosition | null { if (!this.sliderInner) { return null; } const scrollPosition = getScrollPosition(this.sliderInner); 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 setAllSlidesInactive(excludeIndex = -1) { for (const item of this.scope.items) { if (item.index !== excludeIndex) { item.active = false; item.el?.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; } const item = this.scope.items[index]; item.active = true; item.el?.classList.add("active"); } protected setSlidesActive(slides: number[]) { this.setAllSlidesInactive(); for (const slideIndex of slides) { this.setSlideActive(slideIndex); } } protected isScrollable() { if (!this.sliderInner) { return false; } // Compare the height to see if the element has scrollable content const hasScrollableContent = this.scope.angle === "horizontal" ? this.sliderInner.scrollWidth > this.sliderInner.clientWidth : this.sliderInner.scrollHeight > this.sliderInner.clientHeight; return hasScrollableContent; } protected getSlideElementByIndex(index: number) { if (!this.sliderInner) { return undefined; } const slideEl = this.sliderInner.querySelector( `[index="${index}"]`, ) as HTMLElement; return slideEl; } protected isSlideVisible(item: Bs5SliderSlide, offset: number) { if (!this.sliderInner) { return false; } const containerRect = this.sliderInner.getBoundingClientRect(); item.el ||= this.getSlideElementByIndex(item.index); const slideEl = item.el; if (!slideEl) { console.warn("Slide element not found!"); return false; } const slideRect = slideEl.getBoundingClientRect(); // Check if the element is visible by comparing its position with the container const isVisible = this.scope.angle === "horizontal" ? slideRect.left + offset >= containerRect.left && slideRect.right - offset <= containerRect.right : slideRect.top + offset >= containerRect.top && slideRect.bottom - offset <= containerRect.bottom; return isVisible; } protected getVisibleSlides(offset: number) { const activeSlides: number[] = []; if (!this.scope.items?.length) { return activeSlides; } for (const item of this.scope.items) { if (this.isSlideVisible(item, offset)) { activeSlides.push(item.index); } } return activeSlides.sort((a, b) => a - b); } protected setVisibleSlidesActive(offset: number) { this.setAllSlidesInactive(); const activeSlides = this.getVisibleSlides(offset); this.setSlidesActive(activeSlides); return activeSlides; } updateActiveSlides(offset = 8) { const activeSlides = this.setVisibleSlidesActive(offset); const firstIndex = activeSlides[0] || 0; const lastIndex = activeSlides[activeSlides.length - 1] || 0; const prevIndex = this.getPrevIndex(firstIndex); const nextIndex = this.getNextIndex(lastIndex); return { firstIndex, lastIndex, activeSlides, prevIndex, nextIndex, }; } protected updateSlides(offset = 8, isRetry = false): number[] { if (!this.scope.items.length) { return []; } const { activeSlides, firstIndex, prevIndex, nextIndex } = this.updateActiveSlides(offset); // Try again with a bigger offset if no slides are found if (!activeSlides.length && !isRetry) { let fallbackOffset = offset * 2; if (this.scope.angle === "horizontal") { const slideWidth = this.scope.items[0]?.el?.clientWidth || 0; if (slideWidth) { fallbackOffset = Math.round(slideWidth / 2 - 0.5); } } else { const slideHeight = this.scope.items[0]?.el?.clientHeight || 0; if (slideHeight) { fallbackOffset = Math.round(slideHeight / 2 - 0.5); } } return this.updateSlides(fallbackOffset, true); } // const oldActiveSlides = this.scope.activeSlides; this.scope.activeSlides = activeSlides; this.scope.prevIndex = prevIndex; this.scope.nextIndex = nextIndex; this.updateControls(); this.updateIndicators(); if (this.scope.sticky) { this.goTo(firstIndex); } return activeSlides; } protected updateControls() { const isScrollable = this.isScrollable(); this.scope.showControls = this.scope.controls && isScrollable && this.scope.items.length > 1; if (this.scope.infinite) { this.scope.enableNextControl = true; this.scope.enablePrevControl = true; return; } this.scope.enableNextControl = isScrollable && this.scope.nextIndex !== -1 && this.scope.nextIndex <= this.scope.items.length - 1; this.scope.enablePrevControl = isScrollable && this.scope.prevIndex !== -1 && this.scope.prevIndex >= 0; } protected updateIndicators() { const isScrollable = this.isScrollable(); this.scope.showIndicators = this.scope.indicators && isScrollable && this.scope.items.length > 1; } protected requiredAttributes(): string[] { return []; } /** * 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 Bs5SliderComponentScope, oldValue: any, newValue: any, namespace: string | null, ) { super.parsedAttributeChangedCallback( attributeName, oldValue, newValue, namespace, ); if (attributeName === "items") { this.validateItems(); } 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(); } if (attributeName === "columns") { this.updateColumns(); } } // deconstruction protected disconnectedCallback() { this.removeEventListeners(); // this.scrollEventsService?.destroy(); // this.disableDesktopDragscroll(); // return super.disconnectedCallback(); } protected async beforeTemplate(): Promise<void> { const templates = Array.from(this.querySelectorAll("template")); for (const template of templates) { const type = template.getAttribute("type"); switch (type) { case "slide-item": this.scope.slideTemplate = template.content.children.item(0)?.outerHTML || undefined; this.debug("Slide template found!", this.scope.slideTemplate); break; default: console.warn(`Unknown template type: ${type}`, template); break; } } } protected template(): ReturnType<TemplateFunction> { return null; } }