flyonui
Version:
The easiest, free and open-source Tailwind CSS component library with semantic classes.
243 lines (185 loc) • 7.89 kB
text/typescript
/*
* 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