UNPKG

@dschen/slidy

Version:

Slidy is a simple npm package that allows you to create customizable sliders for your web projects.

435 lines (321 loc) 16.1 kB
import { ISliderElements, ISliderOptions } from "./slider.interface"; export const init = (options: ISliderOptions | ISliderOptions[]) => document.addEventListener('DOMContentLoaded', () => initSlidy(options)); function initSlidy(options: ISliderOptions | ISliderOptions[]): void { class Slider { private readonly SWIPPER_THRESHOLD = 1; private isMoving: boolean = false; private slider: HTMLElement; private index: number; private nSlides: number; private currentSlide: number = 0; private nSlidesVisible: number = 0; private isResizing: boolean = false; private sliderIsVisible: boolean; private options: ISliderOptions = { indicatorsAsButtons: true, animationDuration: 0 }; private sliderElements: ISliderElements; constructor(slider: HTMLElement, index: number, options: ISliderOptions) { this.slider = slider; this.index = index; this.options = { ...this.options, ...options }; this.onInit(); } private async onInit() { const sliderStage = this.slider.querySelector('.slidy-stage') as HTMLElement; const mutationObserver = new MutationObserver(async ()=>{ await this.delay(500); this.restart(sliderStage); }); mutationObserver.observe(sliderStage, {childList: true, subtree: true}); if (!sliderStage) return // records number of childs this.nSlides = sliderStage.childElementCount; const slides = [...sliderStage.children].map((c, i) => { const el = c as HTMLElement; el.dataset.id = `${i}`; return el }); this.slider.id = `slidy-${this.index}`; this.sliderElements = { slider: this.slider, sliderStage, slides }; this.initNavigationButtons(); await this.updateNumberVisibleSlides(); this.initNavigationIndicators(); let options = { root: document, rootMargin: "0px", threshold: .5, }; let observer = new IntersectionObserver(e => { e.forEach(async (entry) => { this.initSlidesObserver(entry.isIntersecting); this.sliderIsVisible = entry.isIntersecting; if (entry.isIntersecting) { await this.updateNumberVisibleSlides(); this.handleIndicators(); } }); observer.disconnect(); }, options); observer.observe(this.slider); } private async restart(sliderStage: HTMLElement) { // records number of childs this.nSlides = sliderStage.childElementCount; const slides = [...sliderStage.children].map((c, i) => { const el = c as HTMLElement; el.dataset.id = `${i}`; return el }); this.slider.id = `slidy-${this.index}`; this.sliderElements = { slider: this.slider, sliderStage, slides }; this.initNavigationButtons(true); await this.updateNumberVisibleSlides(); this.initNavigationIndicators(); let options = { root: document, rootMargin: "0px", threshold: .5, }; let observer = new IntersectionObserver(e => { e.forEach(async (entry) => { this.initSlidesObserver(entry.isIntersecting); this.sliderIsVisible = entry.isIntersecting; if (entry.isIntersecting) { await this.updateNumberVisibleSlides(); this.handleIndicators(); } }); }, options); observer.observe(this.slider); } private toggleActiveClass(element: HTMLElement, isIntersecting: boolean) { if (this.isResizing || !element) return; if (isIntersecting) { element.classList.add("slidy-active"); element.ariaCurrent = "true"; element.ariaHidden = "false"; element.ariaDisabled = "false"; [...element.querySelectorAll("a, button")].map(p => p.setAttribute("tabIndex", "0")); } else { element.classList.remove("slidy-active"); element.ariaCurrent = "false"; element.ariaHidden = "true"; element.ariaDisabled = "true"; [...element.querySelectorAll("a, button")].map(p => p.setAttribute("tabIndex", "-1")); } } private updateNumberVisibleSlides(): Promise<void> { const observeElements = (resolve) => { if (!this.sliderElements) return const { slides, sliderStage } = this.sliderElements; let options = { root: sliderStage, rootMargin: "0px", threshold: this.SWIPPER_THRESHOLD, }; let observer = new IntersectionObserver(e => { this.nSlidesVisible = 0; e.forEach(entry => { if (!entry.isIntersecting) return; this.nSlidesVisible += 1; }) observer.disconnect(); resolve(); }, options); slides.forEach((s) => { observer.observe(s) }); } return new Promise((resolve, _) => { observeElements(resolve) }) } private initSlidesObserver(connect: boolean) { if (!this.sliderElements) return const { slides, sliderStage, sliderIndicators } = this.sliderElements; let options = { root: sliderStage, rootMargin: "0px", threshold: this.SWIPPER_THRESHOLD - 0.01, }; let observer = new IntersectionObserver(e => { e.forEach((entry) => { this.toggleActiveClass(entry.target as HTMLElement, entry.isIntersecting); if (!entry.isIntersecting || !sliderIndicators || this.isMoving || this.isResizing) return; const id = Number((entry.target as HTMLElement)?.dataset?.id); const hasIndicator = sliderIndicators.querySelector(`.slidy-indicator-${id}`); if (hasIndicator) { this.handleActiveIndicators(id); } if (id === this.nSlides - 1) { this.handleNavigationButtonsState("end"); } if (id === 0) { this.handleNavigationButtonsState("start"); } }); }, options); if (connect) { slides.forEach((s, i) => { observer.observe(s) }); } else { observer.disconnect() } } private async delay(time: number) { return new Promise((resolve, _) => setTimeout(() => resolve(true), time)) } private initNavigationIndicators(): void { const sliderIndicators = this.slider.querySelector('.slidy-indicators') as HTMLElement; if (!sliderIndicators) return this.sliderElements = { ...this.sliderElements, sliderIndicators }; this.handleIndicators(); window.addEventListener('resize', async () => { this.isResizing = true; if (!this.sliderIsVisible) { this.isResizing = false; return; } await this.updateNumberVisibleSlides(); this.handleIndicators(); this.isResizing = false; }); } private initNavigationButtons(restart?: boolean): void { // add previous/next buttons events let sliderBtnNext = this.slider.querySelector('.slidy-next') as HTMLElement; let sliderBtnPrevious = this.slider.querySelector('.slidy-previous') as HTMLElement; if(restart){ const newNext = sliderBtnNext.cloneNode(true); const newPrevious = sliderBtnPrevious.cloneNode(true); sliderBtnNext.parentNode?.replaceChild(newNext, sliderBtnNext); sliderBtnPrevious.parentNode?.replaceChild(newPrevious, sliderBtnPrevious); sliderBtnNext = newNext as HTMLElement; sliderBtnPrevious = newPrevious as HTMLElement; } if (!sliderBtnNext && !sliderBtnPrevious) return const sliderButtons = {}; if (sliderBtnPrevious) { Object.assign(sliderButtons, { sliderBtnPrevious }); sliderBtnPrevious.addEventListener('click', () => this.nextSlide(-1)); } if (sliderBtnNext) { Object.assign(sliderButtons, { sliderBtnNext }); sliderBtnNext.addEventListener('click', () => this.nextSlide(1)); } this.sliderElements = { ...this.sliderElements, sliderButtons } } private getActiveIndex(direction: number): number { const currentSlide = this.currentSlide; const nSlides = this.nSlides; let nSlidesVisible = this.nSlidesVisible; let index = currentSlide + direction * nSlidesVisible >= nSlides ? currentSlide : currentSlide + direction * nSlidesVisible; index = index < 0 ? 0 : index; return index } private nextSlide(direction: number) { if (!this.sliderElements) return const index = this.getActiveIndex(direction); this.handleActiveIndicators(index); this.goToSlide(index); this.currentSlide = index; } private handleIndicators() { if (!this.sliderElements) return const { sliderIndicators } = this.sliderElements; let nSlidesVisible = this.nSlidesVisible; if (sliderIndicators && nSlidesVisible > 0) { let nIndicators = Math.ceil(this.nSlides / nSlidesVisible); sliderIndicators.innerHTML = ''; // adds the number of indicators according to the number of visible cards // adds event on click sliderIndicators.innerHTML = ""; for (let i = 0; i < nIndicators; i++) { if (nIndicators === 1) break; const temp = document.createElement('div'); const id = nSlidesVisible * i; if (this.options.indicatorsAsButtons) { temp.innerHTML = ` <button type="button" class="slidy-indicator slidy-indicator-${id} ${id === this.currentSlide ? 'active ' : ''}btn btn-indicator" aria-label="Slide ${id}"> </button>`; temp.children[0]!.addEventListener('click', () => { this.goToSlide(id); this.handleActiveIndicators(id) }) } else { temp.innerHTML = ` <div class="slidy-indicator slidy-indicator-${id} ${id === this.currentSlide ? 'active ' : ''}btn btn-indicator"> </div>`; } sliderIndicators.append(temp.children[0]); temp.remove(); } // this.handleEventIndicators(sliderElements); // moves to slide after resizing const regexMobile = /Mobi|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; if (this.isResizing && !regexMobile.test(navigator?.userAgent)) { this.goToSlide(this.currentSlide) } } } private handleActiveIndicators(index: number) { if (!this.sliderElements) return const { sliderIndicators } = this.sliderElements; sliderIndicators?.querySelectorAll('.slidy-indicator')!.forEach(btn => btn.classList.remove('active')); sliderIndicators?.querySelector(`.slidy-indicator-${index}`)?.classList.add('active'); } private async goToSlide(index: number) { this.isMoving = true; if (!this.sliderElements) return const { sliderStage } = this.sliderElements; const currentSlide = sliderStage.children[index] as HTMLElement; const previousSlide = sliderStage.children[this.currentSlide] as HTMLElement; this.toggleActiveClass(previousSlide, false); await this.delay(this.options.animationDuration ?? 0); currentSlide.scrollIntoView({ inline: 'start', block: 'nearest' }); this.toggleActiveClass(currentSlide, true); let gap = sliderStage.children[index - 1]?.getBoundingClientRect().left - sliderStage.children[index]?.getBoundingClientRect().left + sliderStage.children[index - 1]?.clientWidth; gap = gap ? Math.abs(gap) : 0; let point; this.currentSlide = index; await this.delay(this.options.animationDuration ?? 0); if (Math.round(sliderStage.scrollLeft + currentSlide.clientWidth + gap) + 2 >= sliderStage.scrollWidth) point = 'end'; if (sliderStage.scrollLeft === 0) point = 'start'; this.handleNavigationButtonsState(point); this.isMoving = false; } private handleNavigationButtonsState(point?: string) { if (!this.sliderElements) return const { sliderButtons } = this.sliderElements; if (!sliderButtons) return const sliderBtnNext = sliderButtons.sliderBtnNext as HTMLButtonElement; const sliderBtnPrevious = sliderButtons.sliderBtnPrevious as HTMLButtonElement; if (sliderBtnNext) { sliderBtnNext.disabled = point === "end" ? true : false; sliderBtnNext.ariaDisabled = point === "end" ? "true" : "false"; } if (sliderBtnPrevious) { sliderBtnPrevious.disabled = point === "start" ? true : false; sliderBtnPrevious.ariaDisabled = point === "start" ? "true" : "false"; } } } const sliders = [...document.querySelectorAll('.slidy')] as HTMLElement[]; sliders.forEach((slide: HTMLElement, i: number) => { let sliderOptions; if (Array.isArray(options)) { sliderOptions = options.find(op => op.id === i) ?? {}; } else { sliderOptions = options.id === i ? options : {}; } new Slider(slide, i, sliderOptions); }); } export default { init }