admin-lte 4.0.4 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +6 -6
  3. package/dist/css/adminlte-docs.css +878 -0
  4. package/dist/css/adminlte-docs.css.map +1 -0
  5. package/dist/css/adminlte-docs.min.css +7 -0
  6. package/dist/css/adminlte-docs.min.css.map +1 -0
  7. package/dist/css/adminlte-docs.rtl.css +877 -0
  8. package/dist/css/adminlte-docs.rtl.css.map +1 -0
  9. package/dist/css/adminlte-docs.rtl.min.css +7 -0
  10. package/dist/css/adminlte-docs.rtl.min.css.map +1 -0
  11. package/dist/css/adminlte.css +132 -912
  12. package/dist/css/adminlte.css.map +1 -1
  13. package/dist/css/adminlte.min.css +2 -2
  14. package/dist/css/adminlte.min.css.map +1 -1
  15. package/dist/css/adminlte.rtl.css +132 -912
  16. package/dist/css/adminlte.rtl.css.map +1 -1
  17. package/dist/css/adminlte.rtl.min.css +2 -2
  18. package/dist/css/adminlte.rtl.min.css.map +1 -1
  19. package/dist/js/adminlte.esm.js +1406 -0
  20. package/dist/js/adminlte.esm.js.map +1 -0
  21. package/dist/js/adminlte.esm.min.js +7 -0
  22. package/dist/js/adminlte.esm.min.js.map +1 -0
  23. package/dist/js/adminlte.js +449 -215
  24. package/dist/js/adminlte.js.map +1 -1
  25. package/dist/js/adminlte.min.js +2 -2
  26. package/dist/js/adminlte.min.js.map +1 -1
  27. package/dist/js/types/accessibility.d.ts +46 -0
  28. package/dist/js/types/adminlte.d.ts +10 -0
  29. package/dist/js/types/base-component.d.ts +60 -0
  30. package/dist/js/types/card-widget.d.ts +30 -0
  31. package/dist/js/types/color-mode.d.ts +74 -0
  32. package/dist/js/types/direct-chat.d.ts +19 -0
  33. package/dist/js/types/fullscreen.d.ts +21 -0
  34. package/dist/js/types/layout.d.ts +19 -0
  35. package/dist/js/types/push-menu.d.ts +123 -0
  36. package/dist/js/types/treeview.d.ts +27 -0
  37. package/dist/js/types/util/index.d.ts +79 -0
  38. package/package.json +52 -25
  39. package/src/scss/_app-header.scss +3 -1
  40. package/src/scss/_app-main.scss +4 -0
  41. package/src/scss/_app-sidebar.scss +3 -1
  42. package/src/scss/_bootstrap-overrides.scss +60 -0
  43. package/src/scss/_info-box.scss +3 -1
  44. package/src/scss/_progress-bars.scss +3 -1
  45. package/src/scss/_table.scss +4 -2
  46. package/src/scss/adminlte-docs.scss +25 -0
  47. package/src/scss/adminlte.scss +5 -4
  48. package/src/scss/pages/_login_and_register.scss +3 -1
  49. package/src/ts/accessibility.ts +6 -2
  50. package/src/ts/adminlte.ts +7 -3
  51. package/src/ts/base-component.ts +111 -0
  52. package/src/ts/card-widget.ts +124 -100
  53. package/src/ts/color-mode.ts +254 -0
  54. package/src/ts/direct-chat.ts +38 -32
  55. package/src/ts/fullscreen.ts +32 -23
  56. package/src/ts/push-menu.ts +106 -43
  57. package/src/ts/treeview.ts +84 -50
  58. package/src/ts/util/index.ts +80 -16
  59. package/src/scss/_bootstrap-variables.scss +0 -1766
@@ -6,6 +6,7 @@
6
6
  * ----------------------------------------------------------------------------
7
7
  */
8
8
 
9
+ import { BaseComponent, dispatchCustomEvent } from './base-component'
9
10
  import {
10
11
  getLifecycleSignal,
11
12
  onDOMContentLoaded
@@ -17,11 +18,17 @@ import {
17
18
  * ----------------------------------------------------------------------------
18
19
  */
19
20
 
20
- const DATA_KEY = 'lte.push-menu'
21
- const EVENT_KEY = `.${DATA_KEY}`
21
+ const NAME = 'push-menu'
22
+ const EVENT_KEY = `.lte.${NAME}`
23
+
24
+ // Cancelable "before" events: preventDefault() aborts the state change.
22
25
  const EVENT_OPEN = `open${EVENT_KEY}`
23
26
  const EVENT_COLLAPSE = `collapse${EVENT_KEY}`
24
27
 
28
+ // "After" events, dispatched once the classes have been applied.
29
+ const EVENT_OPENED = `opened${EVENT_KEY}`
30
+ const EVENT_COLLAPSED = `collapsed${EVENT_KEY}`
31
+
25
32
  const CLASS_NAME_SIDEBAR_MINI = 'sidebar-mini'
26
33
  const CLASS_NAME_SIDEBAR_EXPAND = 'sidebar-expand'
27
34
  const CLASS_NAME_SIDEBAR_OVERLAY = 'sidebar-overlay'
@@ -68,12 +75,44 @@ const Defaults: Config = {
68
75
  * ----------------------------------------------------------------------------
69
76
  */
70
77
 
71
- class PushMenu {
72
- _element: HTMLElement
78
+ class PushMenu extends BaseComponent {
79
+ static get NAME(): string {
80
+ return NAME
81
+ }
82
+
83
+ /**
84
+ * Look up the PushMenu already attached to the given element.
85
+ *
86
+ * @param element The sidebar element to look up.
87
+ * @returns The existing instance, or null if the sidebar has none yet.
88
+ */
89
+ static getInstance(element: Element | null | undefined): PushMenu | null {
90
+ return this._getInstance(element) as PushMenu | null
91
+ }
92
+
93
+ /**
94
+ * Look up the PushMenu attached to the given element, creating one when the
95
+ * element has none. `config` is ignored if an instance already exists.
96
+ *
97
+ * @param element The sidebar element.
98
+ * @param config Overrides merged over the defaults for a new instance.
99
+ * @returns The existing or newly created instance.
100
+ */
101
+ static getOrCreateInstance(element: HTMLElement, config: Partial<Config> = {}): PushMenu {
102
+ return this.getInstance(element) ?? new this(element, config)
103
+ }
104
+
105
+ /**
106
+ * The defaults merged with the overrides this instance was created with.
107
+ */
73
108
  _config: Config
74
109
 
75
- constructor(element: HTMLElement, config: Config) {
76
- this._element = element
110
+ /**
111
+ * @param element The sidebar element to attach to.
112
+ * @param config Overrides merged over the defaults.
113
+ */
114
+ constructor(element: HTMLElement, config: Partial<Config> = {}) {
115
+ super(element)
77
116
  this._config = { ...Defaults, ...config }
78
117
  }
79
118
 
@@ -118,6 +157,12 @@ class PushMenu {
118
157
  * Expand the sidebar menu.
119
158
  */
120
159
  expand(): void {
160
+ // The "open" event is cancelable: preventDefault() keeps the sidebar
161
+ // in its current state.
162
+ if (dispatchCustomEvent(this._element, EVENT_OPEN, { cancelable: true }).defaultPrevented) {
163
+ return
164
+ }
165
+
121
166
  // Remove the sidebar-collapse class. Only on mobile, add the sidebar-open
122
167
  // class to indicate the sidebar is explicitly open.
123
168
 
@@ -127,24 +172,26 @@ class PushMenu {
127
172
  document.body.classList.add(CLASS_NAME_SIDEBAR_OPEN)
128
173
  }
129
174
 
130
- // Dispatch the expand event.
131
-
132
- this._element.dispatchEvent(new Event(EVENT_OPEN))
175
+ dispatchCustomEvent(this._element, EVENT_OPENED)
133
176
  }
134
177
 
135
178
  /**
136
179
  * Collapse the sidebar menu.
137
180
  */
138
181
  collapse(): void {
182
+ // The "collapse" event is cancelable: preventDefault() keeps the sidebar
183
+ // in its current state.
184
+ if (dispatchCustomEvent(this._element, EVENT_COLLAPSE, { cancelable: true }).defaultPrevented) {
185
+ return
186
+ }
187
+
139
188
  // Remove the sidebar-open class (if present), and add the sidebar-collapse
140
189
  // class.
141
190
 
142
191
  document.body.classList.remove(CLASS_NAME_SIDEBAR_OPEN)
143
192
  document.body.classList.add(CLASS_NAME_SIDEBAR_COLLAPSE)
144
193
 
145
- // Dispatch the collapse event.
146
-
147
- this._element.dispatchEvent(new Event(EVENT_COLLAPSE))
194
+ dispatchCustomEvent(this._element, EVENT_COLLAPSED)
148
195
  }
149
196
 
150
197
  /**
@@ -317,12 +364,12 @@ class PushMenu {
317
364
 
318
365
  // When persistence is enabled and screen size is above the breakpoint, load
319
366
  // the saved sidebar state from local storage. Otherwise, use responsive
320
- // logic to set the initial state. On low screen sizes, the sidebar should
321
- // always be collapsed by default unless explicitly opened.
367
+ // logic to set the initial state (unless explicitly set as collapsed at
368
+ // initialization).
322
369
 
323
370
  if (this._config.enablePersistence && !this.isMobileSize()) {
324
371
  this.loadSidebarState()
325
- } else {
372
+ } else if (!this.isCollapsed()) {
326
373
  this.updateStateByResponsiveLogic()
327
374
  }
328
375
  }
@@ -332,12 +379,40 @@ class PushMenu {
332
379
  * ----------------------------------------------------------------------------
333
380
  * Data Api implementation
334
381
  * ----------------------------------------------------------------------------
382
+ * Toggle clicks are handled by one delegated listener on `document`, so
383
+ * toggle buttons added later work and the listener survives Turbo's <body>
384
+ * swaps. The instance itself is created per page load below and can be
385
+ * retrieved anywhere with:
386
+ *
387
+ * PushMenu.getInstance(document.querySelector('.app-sidebar'))
335
388
  */
336
389
 
390
+ document.addEventListener('click', event => {
391
+ const target = event.target
392
+
393
+ if (!(target instanceof Element)) {
394
+ return
395
+ }
396
+
397
+ const button = target.closest(SELECTOR_SIDEBAR_TOGGLE)
398
+
399
+ if (!button) {
400
+ return
401
+ }
402
+
403
+ event.preventDefault()
404
+
405
+ const sidebar = document.querySelector(SELECTOR_APP_SIDEBAR) as HTMLElement | null
406
+
407
+ if (sidebar) {
408
+ PushMenu.getOrCreateInstance(sidebar).toggle()
409
+ }
410
+ })
411
+
337
412
  onDOMContentLoaded(() => {
338
413
  // Find the sidebar element in the DOM.
339
414
 
340
- const sidebar = document?.querySelector(SELECTOR_APP_SIDEBAR) as HTMLElement | undefined
415
+ const sidebar = document.querySelector(SELECTOR_APP_SIDEBAR) as HTMLElement | null
341
416
 
342
417
  if (!sidebar) {
343
418
  return
@@ -358,9 +433,11 @@ onDOMContentLoaded(() => {
358
433
  enablePersistenceAttr === 'true'
359
434
  }
360
435
 
361
- // Initialize the PushMenu plugin (a unique instance).
436
+ // Initialize the PushMenu plugin (a unique instance per sidebar element;
437
+ // Turbo navigations swap in a fresh element, so a fresh instance is
438
+ // created and the old one is garbage-collected with the old <body>).
362
439
 
363
- const pushMenu = new PushMenu(sidebar, config)
440
+ const pushMenu = PushMenu.getOrCreateInstance(sidebar, config)
364
441
  pushMenu.init()
365
442
 
366
443
  // Update the sidebar state only when the viewport crosses the sidebar
@@ -392,16 +469,23 @@ onDOMContentLoaded(() => {
392
469
  // Handle touch events on overlay (area outside sidebar), usually we want to
393
470
  // close the sidebar when the user taps outside the sidebar on mobile
394
471
  // devices.
472
+ //
473
+ // These are bound with the lifecycle signal even though the overlay lives
474
+ // inside <body>: the node above is reused when it already exists, so under a
475
+ // framework that re-initialises against a persistent <body>, an unsignalled
476
+ // binding would stack another set of handlers on the same element per cycle.
477
+
478
+ const overlaySignal = getLifecycleSignal()
395
479
 
396
480
  let overlayTouchMoved = false
397
481
 
398
482
  sidebarOverlay.addEventListener('touchstart', () => {
399
483
  overlayTouchMoved = false
400
- }, { passive: true })
484
+ }, { passive: true, signal: overlaySignal })
401
485
 
402
486
  sidebarOverlay.addEventListener('touchmove', () => {
403
487
  overlayTouchMoved = true
404
- }, { passive: true })
488
+ }, { passive: true, signal: overlaySignal })
405
489
 
406
490
  sidebarOverlay.addEventListener('touchend', event => {
407
491
  if (!overlayTouchMoved) {
@@ -410,33 +494,12 @@ onDOMContentLoaded(() => {
410
494
  }
411
495
 
412
496
  overlayTouchMoved = false
413
- }, { passive: false })
497
+ }, { passive: false, signal: overlaySignal })
414
498
 
415
499
  sidebarOverlay.addEventListener('click', event => {
416
500
  event.preventDefault()
417
501
  pushMenu.collapse()
418
- })
419
-
420
- // Handle click events on sidebar toggle buttons.
421
-
422
- const fullBtn = document.querySelectorAll(SELECTOR_SIDEBAR_TOGGLE)
423
-
424
- fullBtn.forEach(btn => {
425
- btn.addEventListener('click', event => {
426
- event.preventDefault()
427
-
428
- let button = event.currentTarget as HTMLElement | undefined
429
-
430
- if (button?.dataset.lteToggle !== 'sidebar') {
431
- button = button?.closest(SELECTOR_SIDEBAR_TOGGLE) as HTMLElement | undefined
432
- }
433
-
434
- if (button) {
435
- event?.preventDefault()
436
- pushMenu.toggle()
437
- }
438
- })
439
- })
502
+ }, { signal: overlaySignal })
440
503
  })
441
504
 
442
505
  export default PushMenu
@@ -6,6 +6,7 @@
6
6
  * --------------------------------------------
7
7
  */
8
8
 
9
+ import { BaseComponent, dispatchCustomEvent } from './base-component'
9
10
  import {
10
11
  onDOMContentLoaded,
11
12
  slideDown,
@@ -18,10 +19,14 @@ import {
18
19
  * ------------------------------------------------------------------------
19
20
  */
20
21
 
21
- // const NAME = 'Treeview'
22
- const DATA_KEY = 'lte.treeview'
23
- const EVENT_KEY = `.${DATA_KEY}`
22
+ const NAME = 'treeview'
23
+ const EVENT_KEY = `.lte.${NAME}`
24
24
 
25
+ // Cancelable "before" events (dispatched on the nav-item as an action starts)
26
+ const EVENT_EXPAND = `expand${EVENT_KEY}`
27
+ const EVENT_COLLAPSE = `collapse${EVENT_KEY}`
28
+
29
+ // "After" events (dispatched on the nav-item when the animation finished)
25
30
  const EVENT_EXPANDED = `expanded${EVENT_KEY}`
26
31
  const EVENT_COLLAPSED = `collapsed${EVENT_KEY}`
27
32
  const EVENT_LOAD_DATA_API = `load${EVENT_KEY}`
@@ -56,17 +61,30 @@ const setAriaExpanded = (navItem: Element, expanded: boolean): void => {
56
61
  * ====================================================
57
62
  */
58
63
 
59
- class Treeview {
60
- _element: HTMLElement
64
+ class Treeview extends BaseComponent {
65
+ static get NAME(): string {
66
+ return NAME
67
+ }
68
+
69
+ static getInstance(element: Element | null | undefined): Treeview | null {
70
+ return this._getInstance(element) as Treeview | null
71
+ }
72
+
73
+ static getOrCreateInstance(element: HTMLElement, config: Partial<Config> = {}): Treeview {
74
+ return this.getInstance(element) ?? new this(element, config)
75
+ }
76
+
61
77
  _config: Config
62
78
 
63
- constructor(element: HTMLElement, config: Config) {
64
- this._element = element
79
+ constructor(element: HTMLElement, config: Partial<Config> = {}) {
80
+ super(element)
65
81
  this._config = { ...Default, ...config }
66
82
  }
67
83
 
68
84
  open(): void {
69
- const event = new Event(EVENT_EXPANDED)
85
+ if (dispatchCustomEvent(this._element, EVENT_EXPAND, { cancelable: true }).defaultPrevented) {
86
+ return
87
+ }
70
88
 
71
89
  if (this._config.accordion) {
72
90
  const openMenuList = this._element.parentElement?.querySelectorAll(`${SELECTOR_NAV_ITEM}.${CLASS_NAME_MENU_OPEN}`)
@@ -90,26 +108,36 @@ class Treeview {
90
108
  this._element.classList.add(CLASS_NAME_MENU_OPEN)
91
109
  setAriaExpanded(this._element, true)
92
110
 
93
- const childElement = this._element?.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
111
+ const childElement = this._element.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
94
112
  if (childElement) {
95
113
  slideDown(childElement, this._config.animationSpeed)
96
114
  }
97
115
 
98
- this._element.dispatchEvent(event)
116
+ setTimeout(() => {
117
+ if (this._element.classList.contains(CLASS_NAME_MENU_OPEN)) {
118
+ dispatchCustomEvent(this._element, EVENT_EXPANDED)
119
+ }
120
+ }, this._config.animationSpeed)
99
121
  }
100
122
 
101
123
  close(): void {
102
- const event = new Event(EVENT_COLLAPSED)
124
+ if (dispatchCustomEvent(this._element, EVENT_COLLAPSE, { cancelable: true }).defaultPrevented) {
125
+ return
126
+ }
103
127
 
104
128
  this._element.classList.remove(CLASS_NAME_MENU_OPEN)
105
129
  setAriaExpanded(this._element, false)
106
130
 
107
- const childElement = this._element?.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
131
+ const childElement = this._element.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
108
132
  if (childElement) {
109
133
  slideUp(childElement, this._config.animationSpeed)
110
134
  }
111
135
 
112
- this._element.dispatchEvent(event)
136
+ setTimeout(() => {
137
+ if (!this._element.classList.contains(CLASS_NAME_MENU_OPEN)) {
138
+ dispatchCustomEvent(this._element, EVENT_COLLAPSED)
139
+ }
140
+ }, this._config.animationSpeed)
113
141
  }
114
142
 
115
143
  toggle(): void {
@@ -125,8 +153,50 @@ class Treeview {
125
153
  * ------------------------------------------------------------------------
126
154
  * Data Api implementation
127
155
  * ------------------------------------------------------------------------
156
+ * Clicks are handled by one delegated listener on `document`, so nav items
157
+ * added after load (dynamic menus, Turbo Frames) work without
158
+ * re-initialisation. Initial state (pre-opened menus, ARIA stamping) is
159
+ * applied per page load below.
128
160
  */
129
161
 
162
+ document.addEventListener('click', event => {
163
+ const target = event.target
164
+
165
+ if (!(target instanceof Element)) {
166
+ return
167
+ }
168
+
169
+ const toggleRoot = target.closest(SELECTOR_DATA_TOGGLE) as HTMLElement | null
170
+
171
+ if (!toggleRoot) {
172
+ return
173
+ }
174
+
175
+ const targetItem = target.closest(SELECTOR_NAV_ITEM) as HTMLElement | null
176
+ const targetLink = target.closest(SELECTOR_NAV_LINK)
177
+
178
+ // Avoid creating Treeview instances on non menu elements
179
+ if (!targetItem?.querySelector(SELECTOR_TREEVIEW_MENU)) {
180
+ return
181
+ }
182
+
183
+ if (target.getAttribute('href') === '#' || targetLink?.getAttribute('href') === '#') {
184
+ event.preventDefault()
185
+ }
186
+
187
+ // Read config from data attributes on the treeview root, falling back to
188
+ // Default. The config of the first interaction sticks to the instance.
189
+ const accordionAttr = toggleRoot.dataset.accordion
190
+ const animationSpeedAttr = toggleRoot.dataset.animationSpeed
191
+
192
+ const config: Config = {
193
+ accordion: accordionAttr === undefined ? Default.accordion : accordionAttr === 'true',
194
+ animationSpeed: animationSpeedAttr === undefined ? Default.animationSpeed : Number(animationSpeedAttr)
195
+ }
196
+
197
+ Treeview.getOrCreateInstance(targetItem, config).toggle()
198
+ })
199
+
130
200
  onDOMContentLoaded(() => {
131
201
  const openMenuItems = document.querySelectorAll(`${SELECTOR_NAV_ITEM}.${CLASS_NAME_MENU_OPEN}`)
132
202
 
@@ -149,42 +219,6 @@ onDOMContentLoaded(() => {
149
219
  }
150
220
  })
151
221
  })
152
-
153
- const button = document.querySelectorAll(SELECTOR_DATA_TOGGLE)
154
-
155
- button.forEach(btn => {
156
- btn.addEventListener('click', event => {
157
- const target = event.target as HTMLElement
158
- const targetItem = target.closest(SELECTOR_NAV_ITEM) as HTMLElement | undefined
159
- const targetLink = target.closest(SELECTOR_NAV_LINK) as HTMLAnchorElement | undefined
160
- const targetTreeviewMenu = targetItem?.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
161
- const lteToggleElement = event.currentTarget as HTMLElement
162
-
163
- // Avoid creating Treeview instances on non menu elements
164
- if (!targetTreeviewMenu) {
165
- return
166
- }
167
-
168
- if (target?.getAttribute('href') === '#' || targetLink?.getAttribute('href') === '#') {
169
- event.preventDefault()
170
- }
171
-
172
- if (targetItem) {
173
- // Read data attributes
174
- const accordionAttr = lteToggleElement.dataset.accordion
175
- const animationSpeedAttr = lteToggleElement.dataset.animationSpeed
176
-
177
- // Build config from data attributes, fallback to Default
178
- const config: Config = {
179
- accordion: accordionAttr === undefined ? Default.accordion : accordionAttr === 'true',
180
- animationSpeed: animationSpeedAttr === undefined ? Default.animationSpeed : Number(animationSpeedAttr)
181
- }
182
-
183
- const data = new Treeview(targetItem, config)
184
- data.toggle()
185
- }
186
- })
187
- })
188
222
  })
189
223
 
190
- export default Treeview
224
+ export default Treeview
@@ -16,6 +16,18 @@
16
16
  * cycle's listeners before the callbacks run again. Listeners bound to elements
17
17
  * inside <body> don't need the signal — Turbo discards the old <body>, so they
18
18
  * are cleaned up automatically.
19
+ *
20
+ * Turbo is not the only environment that renders after `DOMContentLoaded`:
21
+ * client-side frameworks that build the layout themselves (GWT, and other
22
+ * imperative widget toolkits) have an empty <body> when the initial batch runs,
23
+ * so the per-page init pass finds no sidebar and no menu. Those consumers call
24
+ * the exported `initialize()` once the layout is attached — it performs the same
25
+ * reset-then-replay cycle Turbo gets, without faking Turbo events.
26
+ *
27
+ * Unlike Turbo, such frameworks keep the same <body> across a re-init, so
28
+ * element-level listeners are NOT discarded for them. Callbacks should therefore
29
+ * pass `getLifecycleSignal()` to every `addEventListener` they make — including
30
+ * ones on elements — whenever the element can outlive the cycle.
19
31
  */
20
32
 
21
33
  const lifecycleCallbacks: Array<() => void> = []
@@ -24,7 +36,10 @@ const lifecycleCallbacks: Array<() => void> = []
24
36
  // without reassigning top-level bindings.
25
37
  const lifecycleState = {
26
38
  controller: new AbortController(),
27
- hasInitialized: false
39
+ hasInitialized: false,
40
+ // True while the callback batch is executing — lets initialize() refuse
41
+ // re-entrant calls made from inside a lifecycle callback.
42
+ isReplaying: false
28
43
  }
29
44
 
30
45
  /**
@@ -40,9 +55,14 @@ const runLifecycleCallbacks = (): void => {
40
55
  }
41
56
 
42
57
  lifecycleState.hasInitialized = true
58
+ lifecycleState.isReplaying = true
43
59
 
44
- for (const callback of lifecycleCallbacks) {
45
- callback()
60
+ try {
61
+ for (const callback of lifecycleCallbacks) {
62
+ callback()
63
+ }
64
+ } finally {
65
+ lifecycleState.isReplaying = false
46
66
  }
47
67
  }
48
68
 
@@ -58,24 +78,66 @@ const onDOMContentLoaded = (callback: () => void): void => {
58
78
  }
59
79
  }
60
80
 
61
- // Initial page load.
81
+ /**
82
+ * End the current lifecycle: abort the cycle's signal so listeners registered
83
+ * with it are removed, then arm a fresh cycle for the next replay.
84
+ *
85
+ * Exported for SPA containers that unmount the AdminLTE layout: calling it
86
+ * drops the window/document listeners the current cycle added without
87
+ * immediately re-initialising. Internally it is also the first half of
88
+ * `initialize()` and the `turbo:before-render` handler.
89
+ */
90
+ const teardown = (): void => {
91
+ lifecycleState.controller.abort()
92
+ lifecycleState.controller = new AbortController()
93
+ lifecycleState.hasInitialized = false
94
+ }
95
+
96
+ /**
97
+ * Re-run every plugin's initialisation against the DOM as it stands right now.
98
+ *
99
+ * Intended for frameworks that render the layout after `DOMContentLoaded` has
100
+ * already fired — call it once the sidebar and menu are attached, and PushMenu,
101
+ * Treeview and ColorMode pick them up as if they had been in the initial HTML.
102
+ * Delegated click handling never needs this; only the per-page init pass does.
103
+ *
104
+ * The previous cycle is torn down first, so calling it repeatedly does not stack
105
+ * listeners registered with `getLifecycleSignal()`. Calling it before the
106
+ * initial batch has run (while `document.readyState === 'loading'`) runs that
107
+ * batch early, against whatever DOM exists at the time — the initial
108
+ * `DOMContentLoaded` pass below still replays against the complete DOM.
109
+ */
110
+ const initialize = (): void => {
111
+ // Re-entrancy guard: a lifecycle callback calling initialize() would tear
112
+ // down its own cycle mid-replay and recurse without end.
113
+ if (lifecycleState.isReplaying) {
114
+ return
115
+ }
116
+
117
+ teardown()
118
+ runLifecycleCallbacks()
119
+ }
120
+
121
+ // Initial page load. Routed through initialize() so that an early initialize()
122
+ // call (a framework initialising before the document finished loading) cannot
123
+ // mark the cycle as done and suppress this pass — the replay tears the early
124
+ // cycle down and re-runs every callback against the complete DOM.
62
125
  if (document.readyState === 'loading') {
63
- document.addEventListener('DOMContentLoaded', runLifecycleCallbacks, { once: true })
126
+ document.addEventListener('DOMContentLoaded', initialize, { once: true })
64
127
  } else {
65
128
  runLifecycleCallbacks()
66
129
  }
67
130
 
68
131
  // Hotwired Turbo: drop the previous cycle's window/document listeners, then
69
- // re-run initialisation against the freshly rendered <body>.
70
- document.addEventListener('turbo:before-render', () => {
71
- lifecycleState.controller.abort()
72
- lifecycleState.controller = new AbortController()
73
- lifecycleState.hasInitialized = false
74
- })
132
+ // re-run initialisation against the freshly rendered <body>. The teardown has to
133
+ // happen at `before-render` rather than as part of the replay, so the outgoing
134
+ // <body>'s listeners are gone before Turbo swaps in the new one — which is why
135
+ // this is the two-step form of `initialize()` rather than a call to it.
136
+ document.addEventListener('turbo:before-render', teardown)
75
137
 
76
138
  document.addEventListener('turbo:load', runLifecycleCallbacks)
77
139
 
78
- /* ES2022 UTILITY FUNCTIONS */
140
+ // ES2022 UTILITY FUNCTIONS
79
141
 
80
142
  /**
81
143
  * Check if an element has a specific data attribute using ES2022 Object.hasOwn()
@@ -109,7 +171,7 @@ const safePropertyAccess = (obj: Record<string, unknown>, property: string): unk
109
171
  * the previous animation's steps: without this, rapidly toggling a treeview or
110
172
  * card leaves a stale cleanup timer that strips height/transition mid-animation
111
173
  * and desyncs the element's display state from its component's classes. */
112
- const slideTimers = new WeakMap<HTMLElement, number[]>()
174
+ const slideTimers = new WeakMap<HTMLElement, Array<ReturnType<typeof globalThis.setTimeout>>>()
113
175
 
114
176
  const cancelSlide = (target: HTMLElement): void => {
115
177
  const timers = slideTimers.get(target) ?? []
@@ -126,7 +188,7 @@ const clearSlideStyles = (target: HTMLElement): void => {
126
188
  }
127
189
  }
128
190
 
129
- /* SLIDE UP */
191
+ // SLIDE UP
130
192
  const slideUp = (target: HTMLElement, duration = 500) => {
131
193
  cancelSlide(target)
132
194
 
@@ -159,7 +221,7 @@ const slideUp = (target: HTMLElement, duration = 500) => {
159
221
  slideTimers.set(target, [stepTimer, cleanupTimer])
160
222
  }
161
223
 
162
- /* SLIDE DOWN */
224
+ // SLIDE DOWN
163
225
  const slideDown = (target: HTMLElement, duration = 500) => {
164
226
  cancelSlide(target)
165
227
  // Drop inline styles a cancelled slideUp may have left behind (height: 0,
@@ -206,7 +268,7 @@ const slideDown = (target: HTMLElement, duration = 500) => {
206
268
  slideTimers.set(target, [stepTimer, cleanupTimer])
207
269
  }
208
270
 
209
- /* TOGGLE */
271
+ // TOGGLE
210
272
  const slideToggle = (target: HTMLElement, duration = 500) => {
211
273
  if (globalThis.getComputedStyle(target).display === 'none') {
212
274
  slideDown(target, duration)
@@ -219,6 +281,8 @@ const slideToggle = (target: HTMLElement, duration = 500) => {
219
281
  export {
220
282
  onDOMContentLoaded,
221
283
  getLifecycleSignal,
284
+ initialize,
285
+ teardown,
222
286
  slideUp,
223
287
  slideDown,
224
288
  slideToggle,