xtendui
Version:
Xtend UI is a powerful frontend library of tailwind components enhanced by vanilla js. It helps you build interfaces with advanced interactions and animations.
627 lines (587 loc) • 16.4 kB
JavaScript
/*!
* Xtend UI (https://xtendui.com/)
* @copyright (c) 2017 - 2021 Riccardo Caroli
* @license MIT (https://github.com/xtendui/xtendui/blob/beta/LICENSE.txt)
*/
import { Xt } from './xt'
import RJSON from 'relaxed-json'
/**
* Infinitescroll
*/
class Infinitescroll {
/**
* fields
*/
_optionsCustom
_optionsDefault
_optionsInitial
_componentNs
_itemsFake
_url
_scrollResume
_scrollTopOld
componentName
ns
options
initial
disabled = false
container
current
triggersUp
triggersDown
itemsContainer
spaceAdditionals
paginations
/**
* constructor
* @param {Node|HTMLElement|EventTarget|Window} object Base node
* @param {Object} optionsCustom User options
* @constructor
*/
constructor(object, optionsCustom = {}) {
const self = this
self.container = object
self._optionsCustom = optionsCustom
self.componentName = self.constructor.componentName
self._componentNs = self.componentName.replace('-', '.')
// init
self._initVars()
self._initLogic()
}
//
// init
//
/**
* init vars
*/
_initVars() {
const self = this
// options
self._optionsDefault = Xt.merge([self.constructor.optionsDefault, Xt.options[self.componentName]])
self._optionsInitial = self.options = Xt.merge([self._optionsDefault, self._optionsCustom])
// vars
const options = self.options
self.current = options.min
// fake
if (!options.get) {
self._itemsFake = self.container.querySelector(options.elements.itemsContainer).cloneNode(true)
}
}
/**
* init logic
*/
_initLogic() {
const self = this
const options = self.options
// set self
Xt._set({ name: self.componentName, el: self.container, self })
// namespace
self.ns = self.ns ?? Xt.uniqueId()
// enable first for proper initial activation
self.enable()
// matches
Xt._initMatches({ self, optionsInitial: self._optionsInitial })
// vars
self.initial = true
// elements
self.triggersUp = self.container.querySelectorAll(options.elements.scrollUp)
self.triggersDown = self.container.querySelectorAll(options.elements.scrollDown)
self.itemsContainer = self.container.querySelector(options.elements.itemsContainer)
self.spaceAdditionals = self.container.querySelectorAll(options.elements.spaceAdditional)
self.paginations = self.container.querySelectorAll(options.elements.pagination)
// events
const unloadHandler = Xt.dataStorage.put(window, `unload/${self.ns}`, self._eventUnload.bind(self))
addEventListener('unload', unloadHandler)
const beforeunloadHandler = Xt.dataStorage.put(
window,
`beforeunload/${self.ns}`,
self._eventBeforeunload.bind(self)
)
addEventListener('beforeunload', beforeunloadHandler)
const scrollHandler = Xt.dataStorage.put(window, `scroll/${self.ns}`, self._eventScroll.bind(self))
addEventListener('scroll', scrollHandler)
// trigger
const events = options.events?.on ? [...options.events.on.split(' ')] : []
if (events.length) {
for (const trigger of [...Array.from(self.triggersUp), ...Array.from(self.triggersDown)]) {
const triggerHandler = Xt.dataStorage.put(
trigger,
`${options.events.on}/${self.ns}`,
self._eventTrigger.bind(self, { trigger })
)
for (const event of events) {
trigger.addEventListener(event, triggerHandler)
}
}
}
// initial
self._initStart()
// init
Xt.frame({
el: self.container,
func: () => {
// initialized class
self.container.setAttribute(`data-${self.componentName}-init`, '')
// dispatch event
self.container.dispatchEvent(new CustomEvent(`init.${self._componentNs}`))
self.initial = false
// debug
if (options.debug) {
// eslint-disable-next-line no-console
console.debug(`${self.componentName} init`, self)
}
},
ns: `${self.ns}Init`,
})
// disable last for proper options.disableDeactivate
if (self.options.disabled) {
self.disable()
}
}
/**
* init start
*/
_initStart() {
const self = this
const options = self.options
// disabled
if (self.disabled) {
return
}
// logic
self._setCurrent()
self._update()
self._paginate()
if (self.itemsContainer) {
const found = self.itemsContainer.querySelector(options.elements.item)
if (found) {
found.setAttribute('data-item-first', self.current)
}
}
// resume state
const add = self._additionalSpace()
const state = history.state
if (state && state[`scrollResume${self._componentNs}`]) {
const found = self.itemsContainer.querySelector(options.elements.item)
document.scrollingElement.scrollTop = state[`scrollResume${self._componentNs}`] + found.offsetTop + add
}
}
//
// methods
//
/**
* trigger
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.trigger
*/
_eventTrigger({ trigger } = {}) {
const self = this
const options = self.options
// disabled
if (self.disabled) {
return
}
// request
const up = parseFloat(trigger.getAttribute('data-xt-infinitescroll-up'))
const down = parseFloat(trigger.getAttribute('data-xt-infinitescroll-down'))
const amount = up || down
if (!amount) {
self._setCurrent({ page: options.min })
location = self._url.href
} else {
let current = self.current + amount
current = current < options.min ? options.min : current
current = current > options.max ? options.max : current
const items = self.itemsContainer.querySelectorAll(`[data-item-first="${current}"]`)
if (current !== self.current && !items.length) {
self._setCurrent({ page: current })
self.inverse = !!up
self._request()
}
}
}
/**
* unload
*/
_eventUnload() {
const self = this
// disabled
if (self.disabled) {
return
}
// save scroll position
if (self._scrollResume) {
document.scrollingElement.scrollTop = 0
}
}
/**
* unload
*/
_eventBeforeunload() {
const self = this
// disabled
if (self.disabled) {
return
}
// save scroll position
if (self._scrollResume) {
const state = {}
state[`scrollResume${self._componentNs}`] = self._scrollResume
history.replaceState(state, '', self._url.href)
}
}
/**
* scroll
*/
_eventScroll() {
const self = this
const options = self.options
// disabled
if (self.disabled) {
return
}
// scroll
const scrollTop = document.scrollingElement.scrollTop
const windowHeight = window.innerHeight
// current page
let found = self.itemsContainer.querySelector(options.elements.item)
const items = self.itemsContainer.querySelectorAll('[data-item-first]')
for (const item of items) {
const itemTop = item.offsetTop
if (scrollTop > itemTop - windowHeight / 2) {
found = item
}
}
self._setCurrent({ page: parseFloat(found.getAttribute('data-item-first')) })
self._paginate()
// save scroll position
const add = self._additionalSpace()
self._scrollResume = scrollTop - found.offsetTop - add
// replace state
const linkOrigin = self._url.origin || `${self._url.protocol}//${self._url.host}`
if (linkOrigin === location.origin) {
if (self._url.href !== location.href) {
history.replaceState(null, '', self._url.href)
}
} else {
console.error('Error: Xt.Infinitescroll cannot set history with different origin', linkOrigin)
}
// triggers
const events = options.events?.on ? [...options.events.on.split(' ')] : []
if (events.length) {
if (options.events.scrollUp && self._scrollTopOld > scrollTop) {
for (const trigger of self.triggersUp) {
const top = trigger.offsetTop
if (scrollTop < top) {
const triggerHandler = Xt.dataStorage.get(trigger, `${options.events.on}/${self.ns}`)
triggerHandler({ target: trigger })
}
}
}
if (options.events.scrollDown && self._scrollTopOld < scrollTop) {
for (const trigger of self.triggersDown) {
const top = trigger.offsetTop
const bottom = top + trigger.offsetHeight
if (scrollTop + windowHeight > bottom) {
const triggerHandler = Xt.dataStorage.get(trigger, `${options.events.on}/${self.ns}`)
triggerHandler({ target: trigger })
}
}
}
}
self._scrollTopOld = scrollTop
}
/**
* request
*/
_request() {
const self = this
// not if requesting
if (!self.container.classList.contains('xt-infinitescroll-loading')) {
// class
self.container.classList.add('xt-infinitescroll-loading')
// request
const request = new XMLHttpRequest()
request.open('GET', self._url.href, true)
request.onload = () => {
// response
self._response({ request })
}
request.onerror = () => {
// response
self._response({ request })
}
request.send()
}
}
/**
* response
* @param {Object} params
* @param {XMLHttpRequest|Object} params.request Html response
*/
_response({ request } = {}) {
const self = this
// request
if (request.status >= 200 && request.status <= 300) {
self._success({ request })
} else {
self._error()
}
}
/**
* success
* @param {Object} params
* @param {XMLHttpRequest|Object} params.request Html response
*/
_success({ request } = {}) {
const self = this
const options = self.options
// set substitute
const html = document.createElement('html')
html.innerHTML = request.responseText.trim()
const itemsContainer = html.querySelector(options.elements.itemsContainer)
if (options.get && itemsContainer) {
self._populate({ itemsContainer })
} else {
// fake
setTimeout(() => {
self._populate({ itemsContainer: self._itemsFake.cloneNode(true) })
}, 1000)
}
}
/**
* error
*/
_error() {
const self = this
// class
self.container.classList.remove('xt-infinitescroll-loading')
}
/**
* populate
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.itemsContainer Items element
*/
_populate({ itemsContainer } = {}) {
const self = this
const options = self.options
// vars
const items = itemsContainer.querySelectorAll(options.elements.item)
// current page
items[0].setAttribute('data-item-first', self.current)
// populate dom
const first = self.itemsContainer.querySelector(`${options.elements.item}:first-child`)
let last
for (const item of items) {
if (self.inverse) {
first.before(item)
} else {
// querySelector here because it always needs to be the last inside loop
last = self.itemsContainer.querySelector(`${options.elements.item}:last-child`)
last.after(item)
}
}
// class
self.container.classList.remove('xt-infinitescroll-loading')
// update
self._update()
self._paginate()
self._eventScroll()
// populate
Xt.frame({
el: self.container,
ns: `${self.ns}Populate`,
func: () => {
// dispatch event
self.container.dispatchEvent(new CustomEvent(`populate.${self._componentNs}`))
},
})
}
/**
* paginate
*/
_paginate() {
const self = this
const options = self.options
// paginate
for (const pagination of self.paginations) {
pagination.dataset.html = pagination.dataset.html ? pagination.dataset.html : pagination.innerHTML
let html = pagination.dataset.html
let regex = new RegExp('xt-num', 'ig')
if (html.search(regex) !== -1) {
html = html.replace(regex, self.current)
}
regex = new RegExp('xt-tot', 'ig')
if (html.search(regex) !== -1) {
html = html.replace(regex, options.max)
}
pagination.innerHTML = Xt.sanitize(html)
}
}
/**
* update
*/
_update() {
const self = this
const options = self.options
// class
if (self.current <= options.min) {
self.container.classList.add('xt-infinitescroll-first')
}
if (self.current >= options.max) {
self.container.classList.add('xt-infinitescroll-last')
}
}
//
// status
//
/**
* enable
*/
enable() {
const self = this
if (self.disabled) {
// enable
self.disabled = false
// dispatch event
self.container.dispatchEvent(new CustomEvent(`status.${self._componentNs}`))
}
}
/**
* disable
* @param {Object} params
* @param {Boolean} params.skipEvent Skip dispatch event
*/
disable({ skipEvent = false } = {}) {
const self = this
if (!self.disabled) {
// disable
self.disabled = true
// dispatch event
if (!skipEvent) {
self.container.dispatchEvent(new CustomEvent(`status.${self._componentNs}`))
}
}
}
//
// util
//
/**
* additionalSpace
* @return {Number} additionalSpace
*/
_additionalSpace() {
const self = this
// logic
let add = 0
for (const additional of self.spaceAdditionals) {
add += additional.offsetHeight
}
return add
}
/**
* setCurrent
* @param {Object} params
* @param {Number} params.page Page number to set
*/
_setCurrent({ page = null } = {}) {
const self = this
const options = self.options
// check url
const url = new URL(location.href)
const searchParams = new URLSearchParams(url.search)
// set current
const get = searchParams.get(options.get)
self.current = page !== null ? page : get ? parseFloat(get) : self.current
searchParams.set(options.get, self.current)
// set url
url.search = searchParams.toString()
self._url = url
}
/**
* reinit
*/
reinit() {
const self = this
// reinit
self._initLogic()
}
/**
* destroy
*/
destroy() {
const self = this
const options = self.options
// events
const unloadHandler = Xt.dataStorage.get(window, `unload/${self.ns}`)
removeEventListener('unload', unloadHandler)
const beforeunloadHandler = Xt.dataStorage.get(window, `beforeunload/${self.ns}`)
removeEventListener('beforeunload', beforeunloadHandler)
const scrollHandler = Xt.dataStorage.get(window, `scroll/${self.ns}`)
removeEventListener('scroll', scrollHandler)
for (const trigger of [...Array.from(self.triggersUp), ...Array.from(self.triggersDown)]) {
const triggerHandler = Xt.dataStorage.get(trigger, `${options.events.on}/${self.ns}`)
trigger.removeEventListener(options.events.on, triggerHandler)
}
// initialized class
self.container.removeAttribute(`data-${self.componentName}-init`)
// set self
Xt._remove({ name: self.componentName, el: self.container })
// dispatch event
self.container.dispatchEvent(new CustomEvent(`destroy.${self._componentNs}`))
// delete
delete this
}
//
}
//
// options
//
Infinitescroll.componentName = 'xt-infinitescroll'
Infinitescroll.optionsDefault = {
debug: false,
// infinitescroll
get: false,
// quantity
min: 1,
max: 'Infinity',
// event
events: {
scrollUp: false,
scrollDown: false,
on: 'click',
},
// element
elements: {
items: false,
item: false,
scrollUp: false,
scrollDown: false,
spaceAdditional: false,
pagination: false,
},
}
//
// export
//
Xt.Infinitescroll = Infinitescroll
//
// observe
//
if (typeof window !== 'undefined') {
Xt.mount({
matches: `[data-${Xt.Infinitescroll.componentName}]`,
mount: ({ ref }) => {
// vars
const optionsMarkup = ref.getAttribute(`data-${Xt.Infinitescroll.componentName}`)
const options = optionsMarkup ? RJSON.parse(optionsMarkup) : {}
// init
let self = new Xt.Infinitescroll(ref, options)
// unmount
return () => {
self.destroy()
self = null
}
},
})
}