xtendui
Version:
Xtend UI is a powerful frontend library of Tailwind CSS components enhanced by vanilla js. It helps you build interfaces with advanced interactions and animations.
1,264 lines (1,188 loc) • 35.7 kB
JavaScript
/*!
* Xtend UI (https://xtendui.github.io/xtendui/)
* @copyright (c) 2017-2026 Riccardo Caroli
* @license MIT (https://github.com/xtendui/xtendui/blob/master/LICENSE.txt)
*/
//
// constructor
//
export const Xt = {}
if (typeof window !== 'undefined') {
//
// global
//
if (window.XtSetGlobal) {
global[typeof window.XtSetGlobal === 'string' ? window.XtSetGlobal : 'Xt'] = Xt
}
//
// vars
//
Xt._running = {}
Xt._currents = {} // Xt currents based on namespace (so shared between Xt objects)
Xt.options = {}
Xt._mountArr = []
Xt._unmountArr = []
Xt.resizeSkip = () => matchMedia('(hover: none), (pointer: coarse)').matches
Xt.resizeDelay = 200
Xt.medialoadedDelay = 200
Xt.durationTimescale = 1
Xt.autoTimescale = 1
Xt.scrolltoHashforce = null
Xt.formScrollWindowFactor = 0.2
Xt._observerArr = []
Xt.observer = null
Xt._observerOptions = { root: null, threshold: [0.001] }
Xt.observerCheck = entry => {
return entry.intersectionRatio > 0.001
}
//
// initialization
//
/**
* ready
* @param {Object} params
* @param {Function} params.func Function to execute
* @param {String} params.state States separated by space, can be 'loading' 'interactive' 'complete'
* @param {Function} params.raf Use requestAnimationFrame if the state is instantly matched
*/
Xt.ready = ({ func, state = 'interactive complete', raf = false } = {}) => {
const states = state.split(' ')
if (states.includes(document.readyState)) {
if (raf) {
// raf because we need all functions defined after mount (e.g. all html demos with mount)
requestAnimationFrame(() => {
func()
})
} else {
func()
}
} else {
const interactive = () => {
if (states.includes(document.readyState)) {
func()
// needs to be removed or it will call multiple times
document.removeEventListener('readystatechange', interactive)
}
}
document.addEventListener('readystatechange', interactive)
}
}
/**
* mount
* @param {Object} obj
* @param {Boolean} perf Use setTimeout 0
*/
Xt.mount = (obj, perf = true) => {
Xt._mountArr.push(obj)
Xt.ready({
raf: obj.raf,
func: () => {
// after perf or Xt._intersectionObserverInit doesn't work (e.g. alpinejs template)
Xt.perf({
func: () => {
Xt._mountCheck({ obj, perf })
},
})
},
})
}
/**
* unmount
* @param {Object} obj
*/
Xt.unmount = obj => {
Xt._unmountArr.push(obj)
}
/**
* mountCheck
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.added
* @param {Object} params.obj
* @param {Object} params.perf
*/
Xt._mountCheck = ({ added = document.documentElement, obj, perf } = {}) => {
// fix multiple mount
// we do not mount if not in document, it happends for example when you mount ScrollTrigger after overlay menu
if (!added.closest('html')) {
return
}
const arr = obj ? [obj] : Xt._mountArr
for (const obj of arr) {
// check
const refs = []
if (added.matches(obj.matches)) {
refs.push(added)
}
for (const ref of added.querySelectorAll(obj.matches)) {
refs.push(ref)
}
// call
if (refs.length) {
for (const [index, ref] of refs.entries()) {
// root
if (obj.root && !obj.root.contains(ref)) {
continue
}
// ignore
const ignoreStr = obj.ignoreMount ?? '.xt-ignore'
if (ignoreStr && ref.closest(ignoreStr)) {
continue
}
// fix multiple mount
// we don't remount nodes not unmounted
obj.done = obj.done ? obj.done : []
if (obj.done.includes(ref)) {
return
}
obj.done.push(ref)
// call
Xt.perf({
skip: !perf,
func: () => {
const call = obj.mount({ ref, obj, index })
// destroy
if (call) {
Xt.unmount({
ref,
root: obj.root,
ignoreUnmount: obj.ignoreUnmount,
unmount: call,
unmountRemove: function () {
// fix multiple mount
obj.done = obj.done.filter(x => x !== ref)
// unmount remove
Xt._unmountArr = Xt._unmountArr.filter(x => {
return x !== this // this is unmount object using function
})
},
})
}
},
})
}
}
}
}
/**
* unmountCheck
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.removed
*/
Xt._unmountCheck = ({ removed = document.documentElement } = {}) => {
// fix multiple mount
// we do not mount if in document, it happends for example when you move nodes
if (removed.closest('html')) {
return
}
for (const obj of Xt._unmountArr) {
// check
if (removed === obj.ref || removed.contains(obj.ref)) {
// root
if (obj.root && !obj.root.contains(obj.ref)) {
continue
}
// ignore
const ignoreStr = obj.ignoreUnmount ?? '.xt-ignore'
if (ignoreStr && obj.ref.closest(ignoreStr)) {
continue
}
// call
obj.unmount({ obj })
obj.unmountRemove()
}
}
}
//
// component
//
/**
* intersection observer
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.container DOM element
* @param {Promise} params.promise Promise to resolve when container is inside viewport
* @param {Function} params.func Function to resolve when container is inside viewport
* @param {Boolean} params.observer If load with IntersectionObserver
* @param {Function} params.id Id for observer removal
*/
Xt.observe = ({ container, promise, func, observer, id } = {}) => {
if (func) {
observer = observer ?? Xt.observer ?? true
} else {
// we execute instantly if not visible because can be in unique mode etc.. DO NOT USE Xt.visible it bugs demo fullscreen
observer = observer ?? Xt.observer ?? container.offsetHeight > 0
}
if (observer) {
return new Promise(resolve => {
Xt._observerArr.push({
container,
resolve,
promise,
func,
id,
})
Xt._intersectionObserver.observe(container)
})
} else {
if (func) {
func(true)
}
if (promise) {
return new Promise(resolve => {
resolve(promise)
})
}
}
}
/**
* intersection observer disconnect
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.container DOM element
* @param {Function} params.id Id for observer removal
*/
Xt.unobserve = ({ container, id } = {}) => {
Xt._observerArr = Xt._observerArr.filter(x => x.id !== id || x.container !== container)
// keepAlive if there is still some containers
const keepAlive = Xt._observerArr.some(x => x.container === container)
if (!keepAlive) {
Xt._intersectionObserver.unobserve(container)
}
}
/**
* intersection observer has already
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.container DOM element
* @param {Function} params.id Id to remove observer
*/
Xt.hasobserve = ({ container, id } = {}) => {
return Xt._observerArr.some(x => x.id === id && x.container === container)
}
/**
* set component
* @param {Object} params
* @param {String} params.name Component name
* @param {Node|HTMLElement|EventTarget|Window} params.el Component's element
* @param {Promise} params.selfPromise Promise with argument self
*/
Xt._set = ({ name, el, selfPromise } = {}) => {
Xt.dataStorage.set(el, name, selfPromise)
}
/**
* get component
* @param {Object} params
* @param {String} params.name Component name
* @param {Node|HTMLElement|EventTarget|Window} params.el Component's element
* @param {Boolean} params.observer If load with IntersectionObserver
* @return {Object}
*/
Xt.get = ({ name, el } = {}) => {
let promise = Xt.dataStorage.get(el, name)
if (!promise) {
promise = new Promise(resolve => {
const setup = () => {
Xt.dataStorage.get(el, name).then(selfPromise => {
resolve(selfPromise)
})
}
// this is the order: Xt._set before self._init and Xt.get listen to setup.xt to have self variables ready
el.addEventListener(`setup.xt.${name.split('-').pop()}`, setup, { once: true })
})
}
return promise
}
/**
* remove component
* @param {Object} params
* @param {String} params.name Component name
* @param {Node|HTMLElement|EventTarget|Window} params.el Component's element
* @return {Object}
*/
Xt._remove = ({ name, el } = {}) => {
return Xt.dataStorage.remove(el, name)
}
//
// methods
//
/**
* init matches
* @param {Object} params
* @param {Object} params.self Self object
* @param {Object} params.optionsInitial Initial options
*/
Xt._initMatches = ({ self, optionsInitial } = {}) => {
const options = self.options
// only on initialization not on reinit because the mql reinit
if (self.initial === undefined) {
// remove matches
if (self.matches) {
Xt._removeMatches({ self, optionsInitial })
}
// matches
if (options.matches) {
self.matches = []
const mqs = Object.entries(options.matches)
if (mqs.length) {
for (const [key, value] of mqs) {
// matches
const mql = matchMedia(key)
self.matches.push({ mql, value })
Xt._eventMatches({ self, mql, value, skipReinit: true, optionsInitial })
if (mql.addEventListener) {
mql.addEventListener('change', Xt._eventMatches.bind(null, { self, mql, value, optionsInitial }))
} else {
mql.addListener(Xt._eventMatches.bind(null, { self, mql, value, optionsInitial }))
}
}
}
}
}
}
/**
* match
* @param {Object} params
* @param {Object} params.self Self object
* @param {Object} params.mql Match media query list
* @param {Object} params.value Match media value
* @param {Boolean} params.skipReinit Skip reinit
*/
Xt._eventMatches = ({ self, mql, value, skipReinit = false, optionsInitial } = {}) => {
// fix NEEDED for chrome not removing mql event listener
if (!self.container.closest('html')) {
return
}
// replace options
if (mql.matches) {
// set options value
self.options = Xt.merge([self.options, value])
} else {
// set options value from initial
self.options = Xt.mergeReset({ start: self.options, reset: optionsInitial, check: value })
}
// reinit one time only with raf
if (!skipReinit) {
// reinit
Xt.frame({
el: self.container,
ns: `${self.ns}MatchFrame`,
func: () => {
Xt._eventReinit({ self })
},
})
}
}
/**
* removeMatches
* @param {Object} params
* @param {Object} params.self Self object
*/
Xt._removeMatches = ({ self, optionsInitial } = {}) => {
// remove matches
if (self.matches?.length) {
for (const obj of self.matches) {
// matches
const mql = obj.mql
const value = obj.value
if (mql.removeEventListener) {
mql.removeEventListener('change', Xt._eventMatches.bind(null, { self, mql, value, optionsInitial }))
} else {
mql.removeListener(Xt._eventMatches.bind(null, { self, mql, value, optionsInitial }))
}
}
}
}
/**
* reinit
* @param {Object} params
* @param {Object} params.self Self object
* @param {Event} e
*/
Xt._eventReinit = ({ self } = {}, e) => {
// triggering e.detail.container
if (!e?.detail?.container || e?.detail?.container.contains(self.container)) {
// handler
self.reinit()
}
}
/**
* load
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.container DOM element
* @param {Object} params.name Class Name
* @param {Object} params.suffix Class suffix
* @param {Boolean} params.observer If load with IntersectionObserver
*/
Xt._load = ({ container, name, suffix, observer } = {}) => {
let promise
if (!Xt[name].loaded[name]) {
promise = import(`./modules/${name.toLowerCase()}${suffix}.mjs`).then(module => {
if (!Xt[name].loaded[name]) {
Xt[name].loaded[name] = true
Object.setPrototypeOf(Xt[name].prototype, module[`${name}${suffix}`].prototype)
}
})
} else {
promise = Promise.resolve()
}
return Xt.observe({ container, promise, observer })
}
//
// dataStorage
// map storage for HTML elements
//
Xt.dataStorage = {
/**
* properties
*/
_storage: new WeakMap(),
/**
* set key/obj pair on element's map
* @param {Node|HTMLElement|EventTarget|Window} el
* @param {String} key
* @param {*} obj
* @return {*}
*/
set: (el, key, obj) => {
if (!el) return
// new map if not already there
if (!Xt.dataStorage._storage.has(el)) {
Xt.dataStorage._storage.set(el, new Map())
}
// set
const getEl = Xt.dataStorage._storage.get(el)
getEl.set(key, obj)
// return
return getEl.get(key)
},
/**
* put key/obj pair on element's map, return old if exist already
* @param {Node|HTMLElement|EventTarget|Window} el
* @param {String} key
* @param {*} obj
* @return {*}
*/
put: (el, key, obj) => {
// new map if not already there
if (!Xt.dataStorage._storage.has(el)) {
Xt.dataStorage._storage.set(el, new Map())
}
// return if already set
const getEl = Xt.dataStorage._storage.get(el)
const getKey = getEl.get(key)
if (getKey) {
return getKey
}
// set
getEl.set(key, obj)
// return
return getEl.get(key)
},
/**
* get obj from key on element's map
* @param {Node|HTMLElement|EventTarget|Window} el
* @param {String} key
* @return {*}
*/
get: (el, key) => {
const getEl = Xt.dataStorage._storage.get(el)
// null if empty
if (!getEl) {
return null
}
// return
return getEl.get(key)
},
/**
* get all obj/key on element's map
* @param {Node|HTMLElement|EventTarget|Window} el
* @return {*}
*/
getAll: el => {
const getEl = Xt.dataStorage._storage.get(el)
// null if empty
if (!getEl) {
return null
}
// return
return getEl
},
/**
* has key on element's map
* @param {Node|HTMLElement|EventTarget|Window} el
* @param {String} key
* @return {Boolean}
*/
has: (el, key) => {
// return
return Xt.dataStorage._storage.get(el).has(key)
},
/**
* remove element's map key
* @param {Node|HTMLElement|EventTarget|Window} el
* @param {String} key
* @return {Boolean}
*/
remove: (el, key) => {
const getEl = Xt.dataStorage._storage.get(el)
// null if empty
if (!getEl) {
return null
}
// remove
const ret = getEl.delete(key)
// remove storage if empty
if (!getEl.size) {
Xt.dataStorage._storage.delete(el)
}
// return
return ret
},
}
//
// classBody
// util to remember classBody state
//
Xt._classBody = {
/**
* properties
*/
currents: [],
/**
* add classBody currents
* @param {Object} obj Object
*/
add: obj => {
Xt._classBody.currents.push(obj)
},
/**
* remove classBody currents
* @param {Object} obj Object
*/
remove: obj => {
Xt._classBody.currents = Xt._classBody.currents.filter(x => x.c !== obj.c || x.ns !== obj.ns)
},
/**
* get classBody currents
* @param {Object} obj Object
* @return {Array} Currents
*/
get: obj => {
return Xt._classBody.currents.filter(x => x.c === obj.c)
},
}
//
// util
//
/**
* Return translate values https://gist.github.com/aderaaij/a6b666bf756b2db1596b366da921755d
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element to check target
* @return {Array} Values [x, y]
*/
Xt.getTranslate = ({ el } = {}) => {
const transArr = []
const style = getComputedStyle(el)
const transform = style.transform
let mat = transform.match(/^matrix3d\((.+)\)$/)
if (mat) {
transArr.push(parseFloat(mat[1].split(', ')[13]))
} else {
mat = transform.match(/^matrix\((.+)\)$/)
mat ? transArr.push(parseFloat(mat[1].split(', ')[4])) : transArr.push(0)
mat ? transArr.push(parseFloat(mat[1].split(', ')[5])) : transArr.push(0)
}
return transArr
}
/**
* Contains for multiple elements
* @param {Object} params
* @param {NodeList|Array|Node|HTMLElement|EventTarget|Window} params.els Elements to check if contains
* @param {Node|HTMLElement|EventTarget|Window} params.tr Element to check if contained
* @return {Boolean}
*/
Xt.contains = ({ els, tr } = {}) => {
if (els instanceof HTMLElement) {
return els.contains(tr)
}
for (const el of els) {
if (el.contains(tr)) {
return true
}
}
return false
}
/**
* Get unique id
* @return {String} Unique id
*/
Xt.uniqueId = () => {
Xt.uid = Xt.uid !== undefined ? Xt.uid : 0
return `xt-${Xt.uid++}`
}
/**
* Merge deep array of objects
* @param {Array} arr Array of objects to merge
* @return {Object} Merged object
*/
Xt.merge = arr => {
const final = {}
for (const obj of arr) {
if (obj) {
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
// if array merge arrays (e.g. options.popperjs modifiers)
final[key] = final[key] ? final[key] : []
final[key].push(...value)
} else if (
value !== null &&
typeof value === 'object' &&
!value.nodeName && // not HTML element
value !== window // not window
) {
// if object deep merge
final[key] = Xt.merge([final[key], value])
} else {
final[key] = value
}
}
}
}
return final
}
/**
* Merge deep reset object only when equals to check
* @param {Object} params
* @param {Object} params.start object Start object
* @param {Object} params.reset object Reset object
* @param {Object} params.check object Check with start object to reset with reset object
* @return {Object} Merged object
*/
Xt.mergeReset = ({ start, reset, check } = {}) => {
const final = start
for (const [key, value] of Object.entries(check)) {
if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) && // not array
!value.nodeName && // not HTML element
value !== window // not window
) {
final[key] = Xt.mergeReset({ start: start[key], reset: reset[key], check: check[key] })
} else {
if (start[key] === check[key]) {
final[key] = reset[key]
}
}
}
return final
}
/**
* Purify html string
* @param {String} str String
* @return {String} Purified string
*/
Xt.sanitize = str => {
return Xt.sanitizeFnc ? Xt.sanitizeFnc(str) : str
}
/**
* Create HTML element from html string
* @param {Object} params
* @param {Boolean} params.sanitize Sanitize
* @param {String} params.str String (only 1 root html tag)
* @return {Node} HTML elements
*/
Xt.node = ({ sanitize = true, str }) => {
const template = document.createElement('template')
template.innerHTML = sanitize ? Xt.sanitize(str.trim()) : str.trim()
return template.content.firstChild
}
/**
* Create HTML elements from html string
* @param {Boolean} params.sanitize Sanitize
* @param {String} params.str Html String
* @return {Node} HTML elements
*/
Xt.nodes = ({ sanitize = true, str }) => {
const template = document.createElement('template')
template.innerHTML = sanitize ? Xt.sanitize(str.trim()) : str.trim()
return template.content.childNodes
}
/**
* Add script to document
* @param {Object} params
* @param {String} params.url
* @param {Function} params.callback
* @param {Boolean} params.defer
* @param {Boolean} params.async
*/
Xt.script = ({ url, callback, defer = true, async = true } = {}) => {
if (!document.querySelector(`script[src="${url}"]`)) {
const asyncfix = async
const script = document.createElement('script')
if (callback) {
script.onload = callback
}
script.type = 'text/javascript'
script.src = url
script.defer = defer
script.async = asyncfix
document.body.append(script)
}
}
/**
* requestAnimationFrame
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element animating
* @param {String} params.ns Namespace
* @param {Function} params.func Function to execute after transition or animation
*/
Xt.frame = ({ el, ns = '', func } = {}) => {
cancelAnimationFrame(Xt.dataStorage.get(el, `${ns}Frame`))
if (func) {
// needs one raf
Xt.dataStorage.set(
el,
`${ns}Frame`,
requestAnimationFrame(() => {
func()
}),
)
}
}
/**
* double requestAnimationFrame
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element animating
* @param {String} params.ns Namespace
* @param {Function} params.func Function to execute after transition or animation
*/
Xt.frameDouble = ({ el, ns = '', func } = {}) => {
cancelAnimationFrame(Xt.dataStorage.get(el, `${ns}FrameDouble`))
if (func) {
// needs two raf or sometimes classes doesn't animate properly
Xt.dataStorage.set(
el,
`${ns}FrameDouble`,
requestAnimationFrame(() => {
Xt.dataStorage.set(
el,
`${ns}FrameDouble`,
requestAnimationFrame(() => {
func()
}),
)
}),
)
}
}
/**
* perf
* @param {Function} params.func Function to execute after setTimeout 0
* @param {Boolean} params.skip Skip setTimeout 0
*/
Xt.perf = ({ func, skip } = {}) => {
if (skip) {
func()
} else {
setTimeout(() => {
func()
}, 0)
}
}
/**
* animation on classes
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element animating
* @param {String} params.ns Namespace
* @param {Number} params.duration Duration
* @param {Boolean} params.raf Use requestAnimationFrame
* @param {Boolean} params.initial Instant animations with initial
* @param {Function} params.callback
*/
Xt.on = ({ el, ns = '', duration, raf = true, initial = false, callback } = {}) => {
Xt.animTimeout({ el, ns })
el.classList.add('on')
el.classList.remove('out')
const func = () => {
el.classList.add('in')
el.classList.remove('done')
Xt.animTimeout({
el,
ns,
duration,
actionCurrent: 'In',
func: () => {
el.classList.add('done')
if (callback) {
callback()
}
},
})
}
if (raf) {
// needs TWO raf or sequential off/on flickr (e.g. display)
Xt.frameDouble({ el, ns, func })
} else {
// fix need to repeat inside frameDouble in case we cancel
Xt.frameDouble({ el, ns })
func()
}
// initial
if (initial) {
el.classList.add('initial')
}
Xt.frameDouble({
el,
ns: `${ns}Initial`,
func: () => {
if (initial) {
el.classList.remove('initial')
}
},
})
}
/**
* animation off classes
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element animating
* @param {String} params.ns Namespace
* @param {Number} params.duration Duration
* @param {Boolean} params.raf Use requestAnimationFrame
* @param {Boolean} params.initial Instant animations with initial
* @param {Function} params.callback
*/
Xt.off = ({ el, ns = '', duration, raf = true, initial = false, callback } = {}) => {
Xt.animTimeout({ el, ns })
// must be outside inside raf or page jumps (e.g. noqueue)
el.classList.remove('on')
const func = () => {
el.classList.remove('in', 'done')
el.classList.add('out')
Xt.animTimeout({
el,
ns,
duration,
actionCurrent: 'Out',
func: () => {
el.classList.remove('out')
if (callback) {
callback()
}
},
})
}
if (raf) {
// needs TWO raf or sequential off/on flickr (e.g. backdrop megamenu)
Xt.frameDouble({ el, ns, func })
} else {
// fix need to repeat inside frameDouble in case we cancel
Xt.frameDouble({ el, ns })
func()
}
// initial
if (initial) {
el.classList.add('initial')
}
Xt.frameDouble({
el,
ns: `${ns}Initial`,
func: () => {
if (initial) {
el.classList.remove('initial')
}
},
})
}
/**
* execute function after transition or animation
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element animating
* @param {String} params.ns Namespace
* @param {Number} params.duration Duration
* @param {String} params.actionCurrent Current action
* @param {Function} params.func Function to execute after transition or animation
*/
Xt.animTimeout = ({ el, ns = '', duration, actionCurrent, func } = {}) => {
clearTimeout(Xt.dataStorage.get(el, `${ns}AnimTimeout`))
if (func) {
duration = Xt.animTime({ el, duration, actionCurrent }) ?? 0
if (!duration) {
func()
} else {
Xt.dataStorage.set(el, `${ns}AnimTimeout`, setTimeout(func, duration))
}
}
}
/**
* get transition or animation time
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element animating
* @param {Number} params.duration Duration
* @param {String} params.actionCurrent Current action
*/
Xt.animTime = ({ el, duration, actionCurrent } = {}) => {
const custom =
(actionCurrent && el.getAttribute(`data-xt-duration-${actionCurrent}`)) || el.getAttribute('data-xt-duration')
if (custom) {
// if not number return the string
return isNaN(parseFloat(custom)) ? custom : parseFloat(custom) / Xt.durationTimescale
} else if (typeof duration === 'function') {
return duration
} else if (duration || duration === 0) {
return duration / Xt.durationTimescale
}
}
/**
* get delay time
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element animating
* @param {Number} params.duration Duration
* @param {String} params.actionCurrent Current action
*/
Xt.delayTime = ({ el, duration, actionCurrent } = {}) => {
const custom =
(actionCurrent && el.getAttribute(`data-xt-delay-${actionCurrent}`)) || el.getAttribute('data-xt-delay')
if (custom) {
// if not number return the string
return isNaN(parseFloat(custom)) ? custom : parseFloat(custom) / Xt.durationTimescale
} else if (typeof duration === 'function') {
return duration
} else if (duration || duration === 0) {
return duration / Xt.durationTimescale
}
}
/**
* query array of elements or element
* @param {Object} params
* @param {NodeList|Array|Node|HTMLElement|EventTarget|Window} params.els Element to search from
* @param {String} params.query Query for querySelectorAll
* @return {Array}
*/
Xt.queryAll = ({ els, query } = {}) => {
// not when no query or empty array
if (!query || els.length === 0) {
return []
}
if (!els.length) {
// search element
return Array.from(els.querySelectorAll(query))
} else {
// search array
const arr = []
for (const el of els) {
arr.push(...el.querySelectorAll(query))
}
return arr
}
}
/**
* check element visibility
* @param {Object} params
* @param {Node|HTMLElement|EventTarget|Window} params.el Element animating
* @return {Boolean}
*/
Xt.visible = ({ el } = {}) => {
let visible = true
if (el.checkVisibility) {
visible = el.checkVisibility()
}
if (visible) {
visible = el.offsetHeight > 0 || el.getClientRects().length > 0
}
return visible
}
/**
* Set scrollbar width of document
*/
Xt._setScrollbarWidth = () => {
Xt.perf({
func: () => {
if (Xt.scrollbarWidth === undefined) {
const scrollbarWidthHandler = Xt.dataStorage.put(window, 'resize/scrollbar', Xt._setScrollbarWidth)
removeEventListener('resize', scrollbarWidthHandler)
addEventListener('resize', scrollbarWidthHandler)
}
// add outer
const outer = document.createElement('div')
outer.style.visibility = 'hidden'
outer.style.width = '100%'
outer.style.msOverflowStyle = 'scrollbar' // needed for WinJS apps
outer.classList.add('xt-ignore', 'xt-overflow-main')
document.body.append(outer)
// force scrollbars
outer.style.overflow = 'scroll'
// add inner
const inner = document.createElement('div')
inner.style.width = '100%'
inner.classList.add('xt-ignore')
outer.append(inner)
// return
const widthNoScroll = outer.offsetWidth
const widthWithScroll = inner.offsetWidth
Xt.scrollbarWidth = widthNoScroll - widthWithScroll
document.documentElement.style.setProperty('--scrollbar-width', `${Xt.scrollbarWidth}px`)
// remove
outer.remove()
},
})
}
Xt.ready({
func: () => {
Xt.perf({
func: () => {
Xt._setScrollbarWidth()
},
})
},
})
/**
* resize.xt
*/
addEventListener('resize', e => {
// we check also if outerWidth changes because on android doesn't change with ScrollTriggers
// we check also innerWidth for resize desktop (e.g. inspect and resize)
const w = window.innerWidth + window.outerWidth
const h = window.innerHeight + window.outerHeight
if (
!e?.detail?.force && // not when setting delay on event
Xt.dataStorage.get(window, 'xtEventDelayWidth') === w && // when width changes
(Xt.resizeSkip() || Xt.dataStorage.get(window, 'xtEventDelayHeight') === h) // when height changes not touch
) {
// only width no height because it changes on scroll on mobile
return
}
clearTimeout(Xt.dataStorage.get(window, `eventDelaySaveTimeout`))
Xt.dataStorage.set(
window,
`eventDelaySaveTimeout`,
setTimeout(() => {
Xt.dataStorage.set(window, 'xtEventDelayWidth', w)
Xt.dataStorage.set(window, 'xtEventDelayHeight', h)
dispatchEvent(new CustomEvent('resize.xt', { detail: e?.detail }))
}, Xt.resizeDelay),
)
})
Xt.dataStorage.set(window, 'xtEventDelayWidth', window.innerWidth + window.outerWidth)
Xt.dataStorage.set(window, 'xtEventDelayHeight', window.innerHeight + window.outerHeight)
/**
* Xt._innerHeightSet
*/
Xt._innerHeightSet = () => {
Xt.innerHeight = window.innerHeight
}
addEventListener('resize.xt', () => {
Xt._innerHeightSet()
})
Xt.ready({
func: () => {
Xt._innerHeightSet()
},
})
//
// plugins
//
/**
* scrolltriggerRerfreshFix fixes refresh on touch screen not on vertical resize
* @param {Object} params
* @param {Function} params.ScrollTrigger
*/
Xt.scrolltriggerRerfreshFix = ({ ScrollTrigger } = {}) => {
Xt.ready({
func: () => {
Xt.frame({
func: () => {
// removed resize we trigger it manually
ScrollTrigger.config({
autoRefreshEvents: 'visibilitychange,DOMContentLoaded,load',
})
// window resize
const resize = () => {
ScrollTrigger.refresh()
}
removeEventListener('resize.xt', resize)
addEventListener('resize.xt', resize)
},
})
},
})
}
//
// mutationObserver
//
Xt._mutationObserver = new MutationObserver(mutationsList => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
// added
for (const added of mutation.addedNodes) {
if (added.nodeType === 1) {
Xt._mountCheck({ added })
}
}
// removed
for (const removed of mutation.removedNodes) {
if (removed.nodeType === 1) {
Xt._unmountCheck({ removed })
}
}
}
}
})
//
// intersectionObserver
//
Xt._intersectionObserverInit = () => {
Xt._intersectionObserver = new IntersectionObserver(function (entries, observer) {
for (const entry of entries) {
const container = entry.target
if (Xt.observerCheck(entry)) {
const objects = Xt._observerArr.filter(x => x.container === container)
// keepAlive if there is a func otherwise we don't need observer anymore
const keepAlive = objects.some(x => x.func)
if (!keepAlive) {
observer.unobserve(container)
Xt._observerArr = Xt._observerArr.filter(x => x.container !== container)
}
// logic
for (const obj of objects) {
if (obj.func) {
obj.func(true)
}
if (obj.resolve) {
obj.resolve(obj.promise)
}
}
} else {
const objects = Xt._observerArr.filter(x => x.container === container)
const keepAlive = objects.some(x => x.func)
if (keepAlive) {
// logic
for (const obj of objects) {
if (obj.func) {
obj.func(false)
}
}
}
}
}
}, Xt._observerOptions)
}
//
// init
//
Xt.ready({
func: () => {
// no perf or github test gives error "Cannot read properties of undefined (reading 'observe')"
Xt._intersectionObserverInit()
Xt.perf({
func: () => {
// after perf or Xt._intersectionObserverInit doesn't work (e.g. alpinejs template)
Xt._mutationObserver.disconnect()
Xt._mutationObserver.observe(document.documentElement, {
characterData: false,
attributes: false,
childList: true,
subtree: true,
})
},
})
},
})
}