UNPKG

admin-lte

Version:

Responsive open source admin dashboard and control panel.

229 lines (191 loc) 7.33 kB
/** * 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 }