UNPKG

flyonui

Version:

The easiest, free and open-source Tailwind CSS component library with semantic classes.

243 lines (185 loc) 7.89 kB
/* * HSScrollspy * @version: 3.2.2 * @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 { getClassProperty, dispatch } from '../../utils' import { IScrollspy, IScrollspyOptions } from './interfaces' import HSBasePlugin from '../base-plugin' import { ICollectionItem } from '../../interfaces' class HSScrollspy extends HSBasePlugin<IScrollspyOptions> implements IScrollspy { private readonly ignoreScrollUp: boolean private readonly links: NodeListOf<HTMLAnchorElement> | null private readonly sections: HTMLElement[] | null private readonly scrollableId: string | null private readonly scrollable: HTMLElement | Document private isScrollingDown: boolean = false private lastScrollTop: number = 0 private onScrollableScrollListener: (evt: Event) => void private onLinkClickListener: | { el: HTMLAnchorElement fn: (evt: Event) => void }[] | null constructor(el: HTMLElement, options = {}) { super(el, options) const data = el.getAttribute('data-scrollspy-options') const dataOptions: IScrollspyOptions = data ? JSON.parse(data) : {} const concatOptions: IScrollspyOptions = { ...dataOptions, ...options } this.ignoreScrollUp = typeof concatOptions.ignoreScrollUp !== 'undefined' ? concatOptions.ignoreScrollUp : false this.links = this.el.querySelectorAll('[href]') this.sections = [] this.scrollableId = this.el.getAttribute('data-scrollspy-scrollable-parent') this.scrollable = this.scrollableId ? (document.querySelector(this.scrollableId) as HTMLElement) : (document as Document) this.onLinkClickListener = [] this.init() } private scrollableScroll(evt: Event) { const currentScrollTop = this.scrollable instanceof HTMLElement ? this.scrollable.scrollTop : window.scrollY this.isScrollingDown = currentScrollTop > this.lastScrollTop this.lastScrollTop = currentScrollTop <= 0 ? 0 : currentScrollTop Array.from(this.sections).forEach((section: HTMLElement) => { if (!section.getAttribute('id')) return false this.update(evt, section) }) } private init() { this.createCollection(window.$hsScrollspyCollection, this) this.links.forEach(el => { this.sections.push(this.scrollable.querySelector(el.getAttribute('href'))) }) this.onScrollableScrollListener = evt => this.scrollableScroll(evt) this.scrollable.addEventListener('scroll', this.onScrollableScrollListener) this.links.forEach(el => { this.onLinkClickListener.push({ el, fn: (evt: Event) => this.linkClick(evt, el) }) el.addEventListener('click', this.onLinkClickListener.find(link => link.el === el).fn) }) } private determineScrollDirection(target: HTMLAnchorElement): boolean { const activeLink = this.el.querySelector('a.active') as HTMLAnchorElement | null if (!activeLink) { return true } const activeIndex = Array.from(this.links).indexOf(activeLink) const targetIndex = Array.from(this.links).indexOf(target) if (targetIndex === -1) { return true } return targetIndex > activeIndex } private linkClick(evt: Event, el: HTMLAnchorElement) { evt.preventDefault() const href = el.getAttribute('href') if (!href || href === 'javascript:;') return const target: HTMLElement | null = href ? document.querySelector(href) : null if (!target) return this.isScrollingDown = this.determineScrollDirection(el) this.scrollTo(el) } private update(evt: Event, section: HTMLElement) { const globalOffset = parseInt(getClassProperty(this.el, '--scrollspy-offset', '0')) const userOffset = parseInt(getClassProperty(section, '--scrollspy-offset')) || globalOffset const scrollableParentOffset = evt.target === document ? 0 : parseInt(String((evt.target as HTMLElement).getBoundingClientRect().top)) const topOffset = parseInt(String(section.getBoundingClientRect().top)) - userOffset - scrollableParentOffset const height = section.offsetHeight const statement = this.ignoreScrollUp ? topOffset <= 0 && topOffset + height > 0 : this.isScrollingDown ? topOffset <= 0 && topOffset + height > 0 : topOffset <= 0 && topOffset < height if (statement) { this.links.forEach(el => el.classList.remove('active')) const current = this.el.querySelector(`[href="#${section.getAttribute('id')}"]`) if (current) { current.classList.add('active') const group = current.closest('[data-scrollspy-group]') if (group) { const parentLink = group.querySelector('[href]') if (parentLink) parentLink.classList.add('active') } } this.fireEvent('afterScroll', current) dispatch('afterScroll.scrollspy', current, this.el) } } private scrollTo(link: HTMLAnchorElement) { const targetId = link.getAttribute('href') const target: HTMLElement = document.querySelector(targetId) const globalOffset = parseInt(getClassProperty(this.el, '--scrollspy-offset', '0')) const userOffset = parseInt(getClassProperty(target, '--scrollspy-offset')) || globalOffset const scrollableParentOffset = this.scrollable === document ? 0 : (this.scrollable as HTMLElement).offsetTop const topOffset = target.offsetTop - userOffset - scrollableParentOffset const view = this.scrollable === document ? window : this.scrollable const scrollFn = () => { window.history.replaceState(null, null, link.getAttribute('href')) if ('scrollTo' in view) { view.scrollTo({ top: topOffset, left: 0, behavior: 'smooth' }) } } const beforeScroll = this.fireEvent('beforeScroll', this.el) dispatch('beforeScroll.scrollspy', this.el, this.el) if (beforeScroll instanceof Promise) beforeScroll.then(() => scrollFn()) else scrollFn() } // Public methods public destroy() { // Remove classes const activeLink = this.el.querySelector('[href].active') activeLink.classList.remove('active') // Remove listeners this.scrollable.removeEventListener('scroll', this.onScrollableScrollListener) if (this.onLinkClickListener.length) this.onLinkClickListener.forEach(({ el, fn }) => { el.removeEventListener('click', fn) }) window.$hsScrollspyCollection = window.$hsScrollspyCollection.filter(({ element }) => element.el !== this.el) } // Static methods static getInstance(target: HTMLElement, isInstance = false) { const elInCollection = window.$hsScrollspyCollection.find( el => el.element.el === (typeof target === 'string' ? document.querySelector(target) : target) ) return elInCollection ? (isInstance ? elInCollection : elInCollection.element.el) : null } static autoInit() { if (!window.$hsScrollspyCollection) window.$hsScrollspyCollection = [] if (window.$hsScrollspyCollection) window.$hsScrollspyCollection = window.$hsScrollspyCollection.filter(({ element }) => document.contains(element.el) ) document.querySelectorAll('[data-scrollspy]:not(.--prevent-on-load-init)').forEach((el: HTMLElement) => { if (!window.$hsScrollspyCollection.find(elC => (elC?.element?.el as HTMLElement) === el)) new HSScrollspy(el) }) } } declare global { interface Window { HSScrollspy: Function $hsScrollspyCollection: ICollectionItem<HSScrollspy>[] } } window.addEventListener('load', () => { HSScrollspy.autoInit() // Uncomment for debug // console.log('Scrollspy collection:', window.$hsScrollspyCollection); }) if (typeof window !== 'undefined') { window.HSScrollspy = HSScrollspy } export default HSScrollspy