UNPKG

preline

Version:

Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.

360 lines (280 loc) 9.29 kB
/* * HSScrollNav * @version: 3.1.0 * @author: Preline Labs Ltd. * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) * Copyright 2024 Preline Labs Ltd. */ import { debounce } from '../../utils'; import { IScrollNavOptions, IScrollNav, IScrollNavCurrentState } from './interfaces'; import HSBasePlugin from '../base-plugin'; import { ICollectionItem } from '../../interfaces'; class HSScrollNav extends HSBasePlugin<IScrollNavOptions> implements IScrollNav { private readonly paging: boolean; private readonly autoCentering: boolean; private body: HTMLElement; private items: HTMLElement[]; private prev: HTMLElement | null; private next: HTMLElement | null; private currentState: IScrollNavCurrentState; constructor(el: HTMLElement, options?: IScrollNavOptions) { super(el, options); const data = el.getAttribute('data-hs-scroll-nav'); const dataOptions: IScrollNavOptions = data ? JSON.parse(data) : {}; const defaultOptions: IScrollNavOptions = { paging: true, autoCentering: false, }; const concatOptions: IScrollNavOptions = { ...defaultOptions, ...dataOptions, ...options, }; this.paging = concatOptions.paging ?? true; this.autoCentering = concatOptions.autoCentering ?? false; this.body = this.el.querySelector('.hs-scroll-nav-body') as HTMLElement; this.items = this.body ? Array.from(this.body.querySelectorAll(':scope > *')) : []; this.prev = this.el.querySelector('.hs-scroll-nav-prev') as HTMLElement || null; this.next = this.el.querySelector('.hs-scroll-nav-next') as HTMLElement || null; this.setCurrentState(); this.init(); } private init() { if (!this.body || !this.items.length) return false; this.createCollection(window.$hsScrollNavCollection, this); this.setCurrentState(); if (this.paging) { if (this.prev) this.buildPrev(); if (this.next) this.buildNext(); } else { if (this.prev) this.buildPrevSingle(); if (this.next) this.buildNextSingle(); } if (this.autoCentering) this.scrollToActiveElement(); this.body.addEventListener('scroll', debounce(() => this.setCurrentState(), 200)); window.addEventListener('resize', debounce(() => { this.setCurrentState(); if (this.autoCentering) this.scrollToActiveElement(); }, 200)); } private setCurrentState() { this.currentState = { first: this.getFirstVisibleItem(), last: this.getLastVisibleItem(), center: this.getCenterVisibleItem() }; if (this.prev) this.setPrevToDisabled(); if (this.next) this.setNextToDisabled(); } private setPrevToDisabled() { if (this.currentState.first === this.items[0]) { this.prev.setAttribute('disabled', 'disabled'); this.prev.classList.add('disabled'); } else { this.prev.removeAttribute('disabled'); this.prev.classList.remove('disabled'); } } private setNextToDisabled() { if (this.currentState.last === this.items[this.items.length - 1]) { this.next.setAttribute('disabled', 'disabled'); this.next.classList.add('disabled'); } else { this.next.removeAttribute('disabled'); this.next.classList.remove('disabled'); } } private buildPrev() { if (!this.prev) return; this.prev.addEventListener('click', () => { const firstVisible = this.currentState.first; if (!firstVisible) return; const visibleCount = this.getVisibleItemsCount(); let target: HTMLElement = firstVisible; for (let i = 0; i < visibleCount; i++) { if (target.previousElementSibling) { target = target.previousElementSibling as HTMLElement; } else { break; } } this.goTo(target); }); } private buildNext() { if (!this.next) return; this.next.addEventListener('click', () => { const lastVisible = this.currentState.last; if (!lastVisible) return; const visibleCount = this.getVisibleItemsCount(); let target: HTMLElement = lastVisible; for (let i = 0; i < visibleCount; i++) { if (target.nextElementSibling) { target = target.nextElementSibling as HTMLElement; } else { break; } } this.goTo(target); }); } private buildPrevSingle() { this.prev?.addEventListener('click', () => { const firstVisible = this.currentState.first; if (!firstVisible) return; const prev = firstVisible.previousElementSibling as HTMLElement | null; if (prev) this.goTo(prev); }); } private buildNextSingle() { this.next?.addEventListener('click', () => { const lastVisible = this.currentState.last; if (!lastVisible) return; const next = lastVisible.nextElementSibling as HTMLElement | null; if (next) this.goTo(next); }); } private getCenterVisibleItem(): HTMLElement | null { const containerCenter = this.body.scrollLeft + this.body.clientWidth / 2; let closestItem: HTMLElement | null = null; let minDistance = Infinity; this.items.forEach((item: HTMLElement) => { const itemCenter = item.offsetLeft + (item.offsetWidth / 2); const distance = Math.abs(itemCenter - containerCenter); if (distance < minDistance) { minDistance = distance; closestItem = item; } }); return closestItem; } private getFirstVisibleItem(): HTMLElement | null { const containerRect = this.body.getBoundingClientRect(); for (let item of this.items) { const itemRect = item.getBoundingClientRect(); const isFullyVisible = ( itemRect.left >= containerRect.left && itemRect.right <= containerRect.right ); if (isFullyVisible) { return item; } } return null; } private getLastVisibleItem(): HTMLElement | null { const containerRect = this.body.getBoundingClientRect(); for (let i = this.items.length - 1; i >= 0; i--) { const item = this.items[i]; const itemRect = item.getBoundingClientRect(); const isPartiallyVisible = ( itemRect.left < containerRect.right && itemRect.right > containerRect.left ); if (isPartiallyVisible) { return item; } } return null; } private getVisibleItemsCount(): number { const containerWidth = this.body.clientWidth; let count = 0; let totalWidth = 0; for (let item of this.items) { totalWidth += item.offsetWidth; if (totalWidth <= containerWidth) { count++; } else { break; } } return count; } private scrollToActiveElement() { const active = this.body.querySelector('.active') as HTMLElement; if (!active) return false; this.centerElement(active); } // Public methods public getCurrentState() { return this.currentState; } public goTo(el: Element, cb?: () => void) { (el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); const observer = new IntersectionObserver((entries, observerInstance) => { entries.forEach(entry => { if (entry.target === el && entry.isIntersecting) { if (typeof cb === 'function') cb(); observerInstance.disconnect(); } }); }, { root: this.body, threshold: 1.0 }); observer.observe(el); } public centerElement(el: HTMLElement, behavior: ScrollBehavior = 'smooth'): void { if (!this.body.contains(el)) { return; } const elementCenter = el.offsetLeft + (el.offsetWidth / 2); const scrollTo = elementCenter - (this.body.clientWidth / 2); this.body.scrollTo({ left: scrollTo, behavior: behavior }); } public destroy() { if (this.paging) { if (this.prev) this.prev.removeEventListener('click', this.buildPrev); if (this.next) this.next.removeEventListener('click', this.buildNext); } else { if (this.prev) this.prev.removeEventListener('click', this.buildPrevSingle); if (this.next) this.next.removeEventListener('click', this.buildNextSingle); } window.removeEventListener('resize', debounce(() => this.setCurrentState(), 200)); window.$hsScrollNavCollection = window.$hsScrollNavCollection.filter(({ element }) => element.el !== this.el); } // Static method static getInstance(target: HTMLElement, isInstance?: boolean) { const elInCollection = window.$hsScrollNavCollection.find( (el) => el.element.el === (typeof target === 'string' ? document.querySelector(target) : target) || el.element.el === (typeof target === 'string' ? document.querySelector(target) : target), ); return elInCollection ? isInstance ? elInCollection : elInCollection.element.el : null; } static autoInit() { if (!window.$hsScrollNavCollection) window.$hsScrollNavCollection = []; if (window.$hsScrollNavCollection) window.$hsRemoveElementCollection = window.$hsRemoveElementCollection.filter(({ element }) => document.contains(element.el)); document .querySelectorAll('[data-hs-scroll-nav]:not(.--prevent-on-load-init)') .forEach((el: HTMLElement) => { if (!window.$hsScrollNavCollection.find((elC) => (elC?.element?.el as HTMLElement) === el)) new HSScrollNav(el); }); } } declare global { interface Window { HSScrollNav: typeof HSScrollNav; $hsScrollNavCollection: ICollectionItem<HSScrollNav>[]; } } window.addEventListener('load', () => { HSScrollNav.autoInit(); // Uncomment for debug // console.log('Scroll nav collection:', window.$hsScrollNavCollection); }); if (typeof window !== 'undefined') { window.HSScrollNav = HSScrollNav; } export default HSScrollNav;