admin-lte
Version:
Responsive open source admin dashboard and control panel.
229 lines (191 loc) • 7.33 kB
text/typescript
/**
* Lifecycle management
* ============================================================================
*
* Plugins register their initialisation through `onDOMContentLoaded`. Besides
* the initial page load, every registered callback is re-run on Hotwired Turbo
* navigations (`turbo:load`): Turbo Drive swaps the <body> without a full page
* reload, so without re-initialisation plugins such as PushMenu and TreeView
* stop working after the first in-app link click (#563, #5890).
*
* Re-running init would normally leak listeners, because callbacks also bind to
* `window`/`document`, which survive Turbo's <body> swap. To prevent that, each
* cycle has its own `AbortController`: callbacks should attach their
* window/document-level listeners with the signal from `getLifecycleSignal()`.
* The signal is aborted on `turbo:before-render`, tearing down the previous
* cycle's listeners before the callbacks run again. Listeners bound to elements
* inside <body> don't need the signal — Turbo discards the old <body>, so they
* are cleaned up automatically.
*/
const lifecycleCallbacks: Array<() => void> = []
// Mutable state is held on an object so the lifecycle hooks below can update it
// without reassigning top-level bindings.
const lifecycleState = {
controller: new AbortController(),
hasInitialized: false
}
/**
* The AbortSignal for the current lifecycle. Pass it as the
* `{ signal }` option to window/document `addEventListener` calls made during
* initialisation so they are removed automatically on the next Turbo render.
*/
const getLifecycleSignal = (): AbortSignal => lifecycleState.controller.signal
const runLifecycleCallbacks = (): void => {
if (lifecycleState.hasInitialized) {
return
}
lifecycleState.hasInitialized = true
for (const callback of lifecycleCallbacks) {
callback()
}
}
const onDOMContentLoaded = (callback: () => void): void => {
lifecycleCallbacks.push(callback)
// Late registration: the batch for the current cycle has already run (the
// script loaded after DOMContentLoaded, or a callback was registered after a
// Turbo visit), so run the newcomer immediately rather than stranding it
// until the next navigation. Preserves the original non-loading behaviour.
if (lifecycleState.hasInitialized) {
callback()
}
}
// Initial page load.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runLifecycleCallbacks, { once: true })
} else {
runLifecycleCallbacks()
}
// Hotwired Turbo: drop the previous cycle's window/document listeners, then
// re-run initialisation against the freshly rendered <body>.
document.addEventListener('turbo:before-render', () => {
lifecycleState.controller.abort()
lifecycleState.controller = new AbortController()
lifecycleState.hasInitialized = false
})
document.addEventListener('turbo:load', runLifecycleCallbacks)
/* ES2022 UTILITY FUNCTIONS */
/**
* Check if an element has a specific data attribute using ES2022 Object.hasOwn()
*/
const hasDataAttribute = (element: HTMLElement, attribute: string): boolean => {
return Object.hasOwn(element.dataset, attribute)
}
/**
* Get the last element from a NodeList using ES2022 Array.at()
*/
const getLastElement = <T extends Element>(elements: NodeListOf<T> | T[]): T | undefined => {
const elementsArray = Array.from(elements)
return elementsArray.at(-1)
}
/**
* Safe property access with better error handling
*/
const safePropertyAccess = (obj: Record<string, unknown>, property: string): unknown => {
try {
return Object.hasOwn(obj, property) ? obj[property] : undefined
} catch (error) {
// ES2022 Error cause
throw new Error(`Failed to access property '${property}'`, { cause: error })
}
}
/* SLIDE ANIMATION BOOKKEEPING
* Pending timers are tracked per element so that starting a new slide cancels
* the previous animation's steps: without this, rapidly toggling a treeview or
* card leaves a stale cleanup timer that strips height/transition mid-animation
* and desyncs the element's display state from its component's classes. */
const slideTimers = new WeakMap<HTMLElement, Array<ReturnType<typeof globalThis.setTimeout>>>()
const cancelSlide = (target: HTMLElement): void => {
const timers = slideTimers.get(target) ?? []
for (const timer of timers) {
globalThis.clearTimeout(timer)
}
slideTimers.delete(target)
}
const clearSlideStyles = (target: HTMLElement): void => {
for (const property of ['height', 'padding-top', 'padding-bottom', 'margin-top', 'margin-bottom', 'overflow', 'transition-duration', 'transition-property']) {
target.style.removeProperty(property)
}
}
/* SLIDE UP */
const slideUp = (target: HTMLElement, duration = 500) => {
cancelSlide(target)
if (duration <= 1) {
target.style.display = 'none'
clearSlideStyles(target)
return
}
target.style.transitionProperty = 'height, margin, padding'
target.style.transitionDuration = `${duration}ms`
target.style.boxSizing = 'border-box'
target.style.height = `${target.offsetHeight}px`
target.style.overflow = 'hidden'
const stepTimer = globalThis.setTimeout(() => {
target.style.height = '0'
target.style.paddingTop = '0'
target.style.paddingBottom = '0'
target.style.marginTop = '0'
target.style.marginBottom = '0'
}, 1)
const cleanupTimer = globalThis.setTimeout(() => {
target.style.display = 'none'
clearSlideStyles(target)
slideTimers.delete(target)
}, duration)
slideTimers.set(target, [stepTimer, cleanupTimer])
}
/* SLIDE DOWN */
const slideDown = (target: HTMLElement, duration = 500) => {
cancelSlide(target)
// Drop inline styles a cancelled slideUp may have left behind (height: 0,
// overflow: hidden, …) before measuring, or the natural height reads as 0.
clearSlideStyles(target)
target.style.removeProperty('display')
let { display } = globalThis.getComputedStyle(target)
if (display === 'none') {
display = 'block'
}
target.style.display = display
if (duration <= 1) {
return
}
const height = target.offsetHeight
target.style.overflow = 'hidden'
target.style.height = '0'
target.style.paddingTop = '0'
target.style.paddingBottom = '0'
target.style.marginTop = '0'
target.style.marginBottom = '0'
const stepTimer = globalThis.setTimeout(() => {
target.style.boxSizing = 'border-box'
target.style.transitionProperty = 'height, margin, padding'
target.style.transitionDuration = `${duration}ms`
target.style.height = `${height}px`
target.style.removeProperty('padding-top')
target.style.removeProperty('padding-bottom')
target.style.removeProperty('margin-top')
target.style.removeProperty('margin-bottom')
}, 1)
const cleanupTimer = globalThis.setTimeout(() => {
clearSlideStyles(target)
slideTimers.delete(target)
}, duration)
slideTimers.set(target, [stepTimer, cleanupTimer])
}
/* TOGGLE */
const slideToggle = (target: HTMLElement, duration = 500) => {
if (globalThis.getComputedStyle(target).display === 'none') {
slideDown(target, duration)
return
}
slideUp(target, duration)
}
export {
onDOMContentLoaded,
getLifecycleSignal,
slideUp,
slideDown,
slideToggle,
hasDataAttribute,
getLastElement,
safePropertyAccess
}