preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
385 lines (309 loc) • 9.04 kB
text/typescript
/*
* HSScrollNav
* @version: 4.2.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';
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);
});
}
}
export default HSScrollNav;