admin-lte 4.1.0 → 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 (40) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +6 -6
  3. package/dist/css/adminlte-docs.css +1 -1
  4. package/dist/css/adminlte-docs.css.map +1 -1
  5. package/dist/css/adminlte-docs.min.css +1 -1
  6. package/dist/css/adminlte-docs.min.css.map +1 -1
  7. package/dist/css/adminlte-docs.rtl.css +1 -1
  8. package/dist/css/adminlte-docs.rtl.css.map +1 -1
  9. package/dist/css/adminlte-docs.rtl.min.css +1 -1
  10. package/dist/css/adminlte-docs.rtl.min.css.map +1 -1
  11. package/dist/css/adminlte.css +131 -131
  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 +131 -131
  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 +71 -30
  20. package/dist/js/adminlte.esm.js.map +1 -1
  21. package/dist/js/adminlte.esm.min.js +2 -2
  22. package/dist/js/adminlte.esm.min.js.map +1 -1
  23. package/dist/js/adminlte.js +72 -29
  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/adminlte.d.ts +2 -1
  28. package/dist/js/types/base-component.d.ts +24 -0
  29. package/dist/js/types/color-mode.d.ts +24 -6
  30. package/dist/js/types/push-menu.d.ts +21 -0
  31. package/dist/js/types/util/index.d.ts +38 -1
  32. package/package.json +24 -23
  33. package/src/scss/adminlte-docs.scss +1 -1
  34. package/src/scss/adminlte.scss +1 -1
  35. package/src/ts/accessibility.ts +6 -2
  36. package/src/ts/adminlte.ts +5 -3
  37. package/src/ts/base-component.ts +30 -3
  38. package/src/ts/color-mode.ts +100 -20
  39. package/src/ts/push-menu.ts +37 -7
  40. package/src/ts/util/index.ts +79 -15
@@ -1,4 +1,4 @@
1
- import { onDOMContentLoaded } from './util/index.js'
1
+ import { initialize, teardown, onDOMContentLoaded } from './util/index.js'
2
2
  import Layout from './layout.js'
3
3
  import CardWidget from './card-widget.js'
4
4
  import Treeview from './treeview.js'
@@ -9,7 +9,7 @@ import ColorMode from './color-mode.js'
9
9
  import { initAccessibility } from './accessibility.js'
10
10
 
11
11
  /**
12
- * AdminLTE v4.1.0
12
+ * AdminLTE v4.2.0
13
13
  * Author: Colorlib
14
14
  * Website: AdminLTE.io <https://adminlte.io>
15
15
  * License: Open source - MIT <https://opensource.org/licenses/MIT>
@@ -40,5 +40,7 @@ export {
40
40
  FullScreen,
41
41
  PushMenu,
42
42
  ColorMode,
43
- initAccessibility
43
+ initAccessibility,
44
+ initialize,
45
+ teardown
44
46
  }
@@ -9,17 +9,25 @@
9
9
  */
10
10
 
11
11
  /**
12
- * element -> (data key -> component instance). WeakMap keys don't prevent
13
- * garbage collection, so instances die with their elements — important under
14
- * Hotwired Turbo, which swaps the whole <body> on navigation.
12
+ * Component registry: element -> (data key -> component instance). A single
13
+ * element can host several components at once, each stored under its own
14
+ * DATA_KEY. WeakMap keys don't prevent garbage collection, so instances die
15
+ * with their elements — important under Hotwired Turbo, which swaps the whole
16
+ * <body> on navigation.
15
17
  */
16
18
  const componentRegistry = new WeakMap<Element, Map<string, BaseComponent>>()
17
19
 
18
20
  class BaseComponent {
21
+ /**
22
+ * Subclasses must override this getter to declare their own name.
23
+ */
19
24
  static get NAME(): string {
20
25
  throw new Error('Component subclasses must override the static NAME getter.')
21
26
  }
22
27
 
28
+ /**
29
+ * Key this component is registered under: `lte.<name>`.
30
+ */
23
31
  static get DATA_KEY(): string {
24
32
  return `lte.${this.NAME}`
25
33
  }
@@ -27,6 +35,9 @@ class BaseComponent {
27
35
  /**
28
36
  * Untyped registry lookup. Every component exposes a typed wrapper
29
37
  * (e.g. CardWidget.getInstance()) built on top of this.
38
+ *
39
+ * @param element The element to look up.
40
+ * @returns The instance for this component, or null if there is none.
30
41
  */
31
42
  protected static _getInstance(element: Element | null | undefined): BaseComponent | null {
32
43
  if (!element) {
@@ -36,8 +47,17 @@ class BaseComponent {
36
47
  return componentRegistry.get(element)?.get(this.DATA_KEY) ?? null
37
48
  }
38
49
 
50
+ /**
51
+ * The element this instance is attached to.
52
+ */
39
53
  _element: HTMLElement
40
54
 
55
+ /**
56
+ * Attach a new instance to the given element and register it under the
57
+ * subclass's DATA_KEY.
58
+ *
59
+ * @param element The element to attach this instance to.
60
+ */
41
61
  constructor(element: HTMLElement) {
42
62
  this._element = element
43
63
 
@@ -55,6 +75,7 @@ class BaseComponent {
55
75
  const instances = componentRegistry.get(this._element)
56
76
  instances?.delete((this.constructor as typeof BaseComponent).DATA_KEY)
57
77
 
78
+ // Drop the element's entry once it holds no components at all.
58
79
  if (instances?.size === 0) {
59
80
  componentRegistry.delete(this._element)
60
81
  }
@@ -65,6 +86,12 @@ class BaseComponent {
65
86
  * Dispatch a namespaced custom event that bubbles — so applications can
66
87
  * listen once on `document` — and can optionally carry a payload or be
67
88
  * canceled. Returns the event so callers can check `defaultPrevented`.
89
+ *
90
+ * @param element The element to dispatch the event on.
91
+ * @param name The namespaced event name, e.g. `collapse.lte.push-menu`.
92
+ * @param options `cancelable` opts the event into preventDefault(); `detail`
93
+ * is the payload handed to listeners.
94
+ * @returns The dispatched event, after listeners have run.
68
95
  */
69
96
  const dispatchCustomEvent = <T = undefined>(
70
97
  element: Element,
@@ -2,13 +2,19 @@
2
2
  * --------------------------------------------
3
3
  * @file AdminLTE color-mode.ts
4
4
  * @description Color mode (light/dark/auto) switcher for AdminLTE.
5
- * Persists the choice in localStorage, follows the OS preference in
6
- * "auto" mode, and keeps [data-bs-theme-value] toggles and
7
- * [data-lte-theme-icon] indicator icons in sync.
5
+ * Resolves the theme from, in order: the visitor's stored choice, the theme
6
+ * the page itself declared in <html data-bs-theme="…">, and finally the OS
7
+ * preference. Keeps [data-bs-theme-value] toggles and [data-lte-theme-icon]
8
+ * indicator icons in sync.
8
9
  *
9
10
  * Ships in the bundle so applications no longer need to copy the demo's
10
11
  * inline script. The tiny no-flash snippet in <head> (see _head.astro)
11
- * remains inline by design — it must run before first paint.
12
+ * remains inline by design — it must run before first paint. That snippet
13
+ * flags the values it computes itself with [data-lte-theme-resolved], so a
14
+ * theme authored in the markup can be told apart from one it resolved.
15
+ *
16
+ * Applications with their own theming opt out entirely with
17
+ * <html data-lte-color-mode="off">.
12
18
  * @license MIT
13
19
  * --------------------------------------------
14
20
  */
@@ -26,11 +32,59 @@ const EVENT_CHANGED = `changed${EVENT_KEY}`
26
32
 
27
33
  const STORAGE_KEY = 'lte-theme'
28
34
 
29
- const SELECTOR_TOGGLE = '[data-bs-theme-value]'
35
+ const ATTRIBUTE_THEME = 'data-bs-theme'
36
+ const ATTRIBUTE_TOGGLE = 'data-bs-theme-value'
37
+ const ATTRIBUTE_DISABLED = 'data-lte-color-mode'
38
+ const ATTRIBUTE_RESOLVED = 'data-lte-theme-resolved'
39
+
40
+ const SELECTOR_TOGGLE = `[${ATTRIBUTE_TOGGLE}]`
30
41
  const SELECTOR_ICON = '[data-lte-theme-icon]'
31
42
 
32
43
  type Theme = 'light' | 'dark' | 'auto'
33
44
 
45
+ const THEMES = new Set<string>(['light', 'dark', 'auto'])
46
+
47
+ const isValidTheme = (value: string): value is Theme => THEMES.has(value)
48
+
49
+ /**
50
+ * Applications with their own theming take over by adding
51
+ * `data-lte-color-mode="off"` to <html>: ColorMode then never writes
52
+ * `data-bs-theme` — not on load, not on a toggle click, not when the OS
53
+ * preference changes. This is also the escape hatch for custom Bootstrap
54
+ * themes, whose names ColorMode cannot resolve (#6084).
55
+ *
56
+ * Read live rather than captured, so it can be flipped at runtime.
57
+ */
58
+ const isDisabled = (): boolean =>
59
+ document.documentElement.getAttribute(ATTRIBUTE_DISABLED) === 'off'
60
+
61
+ /**
62
+ * The theme the page itself declared in <html data-bs-theme="…">, or null when
63
+ * it declared none.
64
+ *
65
+ * Captured once, at module evaluation, because the attribute is both an input
66
+ * and an output: after the first `_applyTheme()` it holds ColorMode's own
67
+ * write, which must not be mistaken for the page's intent on a later lifecycle
68
+ * pass (Turbo, `initialize()`). Reading it here runs before any of those.
69
+ *
70
+ * The pre-paint snippet in <head> writes before this module is even fetched,
71
+ * so it marks the values it computed itself with [data-lte-theme-resolved] —
72
+ * those are not authored, and are ignored.
73
+ */
74
+ const readMarkupTheme = (): Theme | null => {
75
+ const { documentElement } = document
76
+
77
+ if (documentElement.hasAttribute(ATTRIBUTE_RESOLVED)) {
78
+ return null
79
+ }
80
+
81
+ const declared = documentElement.getAttribute(ATTRIBUTE_THEME)
82
+
83
+ return declared && isValidTheme(declared) ? declared : null
84
+ }
85
+
86
+ const MARKUP_THEME = readMarkupTheme()
87
+
34
88
  /**
35
89
  * Class Definition
36
90
  * ====================================================
@@ -44,20 +98,32 @@ class ColorMode {
44
98
  getStoredTheme(): Theme | null {
45
99
  try {
46
100
  const stored = localStorage.getItem(STORAGE_KEY)
47
- return stored && ['light', 'dark', 'auto'].includes(stored) ? stored as Theme : null
101
+ return stored && isValidTheme(stored) ? stored : null
48
102
  } catch {
49
103
  return null
50
104
  }
51
105
  }
52
106
 
53
107
  /**
54
- * The user's effective choice: the stored theme, falling back to the OS
55
- * preference.
108
+ * The theme declared in the markup, for applications that render it
109
+ * server-side from a cookie or a user record. Null when the page declared
110
+ * none, or when the value is a custom Bootstrap theme ColorMode cannot
111
+ * resolve — see `isDisabled` for those.
112
+ */
113
+ getMarkupTheme(): Theme | null {
114
+ return MARKUP_THEME
115
+ }
116
+
117
+ /**
118
+ * The user's effective choice: the stored theme, then the theme declared in
119
+ * the markup, falling back to the OS preference. Storage comes first because
120
+ * it is the visitor's own click on this device; markup is only the default
121
+ * the page shipped with.
56
122
  */
57
123
  getPreferredTheme(): Theme {
58
- const stored = this.getStoredTheme()
59
- if (stored) {
60
- return stored
124
+ const preferred = this.getStoredTheme() ?? this.getMarkupTheme()
125
+ if (preferred) {
126
+ return preferred
61
127
  }
62
128
 
63
129
  return this._prefersDark() ? 'dark' : 'light'
@@ -99,10 +165,13 @@ class ColorMode {
99
165
  */
100
166
  _applyTheme(theme: Theme): void {
101
167
  const resolved = this.resolveTheme(theme)
102
- document.documentElement.setAttribute('data-bs-theme', resolved)
168
+ document.documentElement.setAttribute(ATTRIBUTE_THEME, resolved)
103
169
  document.documentElement.style.colorScheme = resolved
104
170
  }
105
171
 
172
+ /**
173
+ * Whether the OS preference is currently dark.
174
+ */
106
175
  _prefersDark(): boolean {
107
176
  return globalThis.matchMedia('(prefers-color-scheme: dark)').matches
108
177
  }
@@ -113,7 +182,7 @@ class ColorMode {
113
182
  */
114
183
  _showActiveTheme(theme: Theme): void {
115
184
  document.querySelectorAll(SELECTOR_TOGGLE).forEach(toggle => {
116
- const isActive = toggle.getAttribute('data-bs-theme-value') === theme
185
+ const isActive = toggle.getAttribute(ATTRIBUTE_TOGGLE) === theme
117
186
  toggle.classList.toggle('active', isActive)
118
187
  toggle.setAttribute('aria-pressed', String(isActive))
119
188
  toggle.querySelector('.bi-check-lg')?.classList.toggle('d-none', !isActive)
@@ -128,6 +197,10 @@ class ColorMode {
128
197
  * Apply the preferred theme and sync the UI without persisting anything.
129
198
  */
130
199
  init(): void {
200
+ if (isDisabled()) {
201
+ return
202
+ }
203
+
131
204
  const theme = this.getPreferredTheme()
132
205
  this._applyTheme(theme)
133
206
  this._showActiveTheme(theme)
@@ -145,14 +218,14 @@ class ColorMode {
145
218
  document.addEventListener('click', event => {
146
219
  const target = event.target
147
220
 
148
- if (!(target instanceof Element)) {
221
+ if (!(target instanceof Element) || isDisabled()) {
149
222
  return
150
223
  }
151
224
 
152
225
  const toggle = target.closest(SELECTOR_TOGGLE)
153
- const theme = toggle?.getAttribute('data-bs-theme-value') as Theme | null
226
+ const theme = toggle?.getAttribute(ATTRIBUTE_TOGGLE)
154
227
 
155
- if (theme) {
228
+ if (theme && isValidTheme(theme)) {
156
229
  new ColorMode().setTheme(theme)
157
230
  }
158
231
  })
@@ -161,12 +234,19 @@ onDOMContentLoaded(() => {
161
234
  const colorMode = new ColorMode()
162
235
  colorMode.init()
163
236
 
164
- // Follow the OS while no explicit choice (or "auto") is stored.
237
+ // Follow the OS only while the OS *is* the effective choice: nothing stored
238
+ // and nothing declared in the markup, or an explicit "auto". A theme the
239
+ // page declared is a preference too, and outlives an OS change (#6093).
165
240
  globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
166
- const stored = colorMode.getStoredTheme()
167
- if (!stored || stored === 'auto') {
241
+ if (isDisabled()) {
242
+ return
243
+ }
244
+
245
+ const preferred = colorMode.getStoredTheme() ?? colorMode.getMarkupTheme()
246
+
247
+ if (!preferred || preferred === 'auto') {
168
248
  colorMode._applyTheme('auto')
169
- colorMode._showActiveTheme(stored ?? 'auto')
249
+ colorMode._showActiveTheme(preferred ?? 'auto')
170
250
  }
171
251
  }, { signal: getLifecycleSignal() })
172
252
  })
@@ -80,16 +80,37 @@ class PushMenu extends BaseComponent {
80
80
  return NAME
81
81
  }
82
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
+ */
83
89
  static getInstance(element: Element | null | undefined): PushMenu | null {
84
90
  return this._getInstance(element) as PushMenu | null
85
91
  }
86
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
+ */
87
101
  static getOrCreateInstance(element: HTMLElement, config: Partial<Config> = {}): PushMenu {
88
102
  return this.getInstance(element) ?? new this(element, config)
89
103
  }
90
104
 
105
+ /**
106
+ * The defaults merged with the overrides this instance was created with.
107
+ */
91
108
  _config: Config
92
109
 
110
+ /**
111
+ * @param element The sidebar element to attach to.
112
+ * @param config Overrides merged over the defaults.
113
+ */
93
114
  constructor(element: HTMLElement, config: Partial<Config> = {}) {
94
115
  super(element)
95
116
  this._config = { ...Defaults, ...config }
@@ -158,6 +179,8 @@ class PushMenu extends BaseComponent {
158
179
  * Collapse the sidebar menu.
159
180
  */
160
181
  collapse(): void {
182
+ // The "collapse" event is cancelable: preventDefault() keeps the sidebar
183
+ // in its current state.
161
184
  if (dispatchCustomEvent(this._element, EVENT_COLLAPSE, { cancelable: true }).defaultPrevented) {
162
185
  return
163
186
  }
@@ -341,12 +364,12 @@ class PushMenu extends BaseComponent {
341
364
 
342
365
  // When persistence is enabled and screen size is above the breakpoint, load
343
366
  // the saved sidebar state from local storage. Otherwise, use responsive
344
- // logic to set the initial state. On low screen sizes, the sidebar should
345
- // always be collapsed by default unless explicitly opened.
367
+ // logic to set the initial state (unless explicitly set as collapsed at
368
+ // initialization).
346
369
 
347
370
  if (this._config.enablePersistence && !this.isMobileSize()) {
348
371
  this.loadSidebarState()
349
- } else {
372
+ } else if (!this.isCollapsed()) {
350
373
  this.updateStateByResponsiveLogic()
351
374
  }
352
375
  }
@@ -446,16 +469,23 @@ onDOMContentLoaded(() => {
446
469
  // Handle touch events on overlay (area outside sidebar), usually we want to
447
470
  // close the sidebar when the user taps outside the sidebar on mobile
448
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()
449
479
 
450
480
  let overlayTouchMoved = false
451
481
 
452
482
  sidebarOverlay.addEventListener('touchstart', () => {
453
483
  overlayTouchMoved = false
454
- }, { passive: true })
484
+ }, { passive: true, signal: overlaySignal })
455
485
 
456
486
  sidebarOverlay.addEventListener('touchmove', () => {
457
487
  overlayTouchMoved = true
458
- }, { passive: true })
488
+ }, { passive: true, signal: overlaySignal })
459
489
 
460
490
  sidebarOverlay.addEventListener('touchend', event => {
461
491
  if (!overlayTouchMoved) {
@@ -464,12 +494,12 @@ onDOMContentLoaded(() => {
464
494
  }
465
495
 
466
496
  overlayTouchMoved = false
467
- }, { passive: false })
497
+ }, { passive: false, signal: overlaySignal })
468
498
 
469
499
  sidebarOverlay.addEventListener('click', event => {
470
500
  event.preventDefault()
471
501
  pushMenu.collapse()
472
- })
502
+ }, { signal: overlaySignal })
473
503
  })
474
504
 
475
505
  export default PushMenu
@@ -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()
@@ -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,