@ribajs/bs5
Version:
Bootstrap 5 module for Riba.js
870 lines (742 loc) • 23.2 kB
text/typescript
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;
}
}