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