admin-lte 4.0.2 → 4.1.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 (146) hide show
  1. package/ACCESSIBILITY-COMPLIANCE.md +60 -339
  2. package/CHANGELOG.md +66 -0
  3. package/README.md +15 -2
  4. package/dist/css/adminlte-docs.css +878 -0
  5. package/dist/css/adminlte-docs.css.map +1 -0
  6. package/dist/css/adminlte-docs.min.css +7 -0
  7. package/dist/css/adminlte-docs.min.css.map +1 -0
  8. package/dist/css/adminlte-docs.rtl.css +877 -0
  9. package/dist/css/adminlte-docs.rtl.css.map +1 -0
  10. package/dist/css/adminlte-docs.rtl.min.css +7 -0
  11. package/dist/css/adminlte-docs.rtl.min.css.map +1 -0
  12. package/dist/css/adminlte.css +1176 -2718
  13. package/dist/css/adminlte.css.map +1 -1
  14. package/dist/css/adminlte.min.css +2 -2
  15. package/dist/css/adminlte.min.css.map +1 -1
  16. package/dist/css/adminlte.rtl.css +1176 -2718
  17. package/dist/css/adminlte.rtl.css.map +1 -1
  18. package/dist/css/adminlte.rtl.min.css +2 -2
  19. package/dist/css/adminlte.rtl.min.css.map +1 -1
  20. package/dist/js/adminlte.esm.js +1365 -0
  21. package/dist/js/adminlte.esm.js.map +1 -0
  22. package/dist/js/adminlte.esm.min.js +7 -0
  23. package/dist/js/adminlte.esm.min.js.map +1 -0
  24. package/dist/js/adminlte.js +606 -310
  25. package/dist/js/adminlte.js.map +1 -1
  26. package/dist/js/adminlte.min.js +2 -2
  27. package/dist/js/adminlte.min.js.map +1 -1
  28. package/dist/js/types/accessibility.d.ts +46 -0
  29. package/dist/js/types/adminlte.d.ts +9 -0
  30. package/dist/js/types/base-component.d.ts +36 -0
  31. package/dist/js/types/card-widget.d.ts +30 -0
  32. package/dist/js/types/color-mode.d.ts +56 -0
  33. package/dist/js/types/direct-chat.d.ts +19 -0
  34. package/dist/js/types/fullscreen.d.ts +21 -0
  35. package/dist/js/types/layout.d.ts +19 -0
  36. package/dist/js/types/push-menu.d.ts +102 -0
  37. package/dist/js/types/treeview.d.ts +27 -0
  38. package/dist/js/types/util/index.d.ts +42 -0
  39. package/package.json +60 -21
  40. package/src/scss/_app-header.scss +3 -1
  41. package/src/scss/_app-main.scss +4 -0
  42. package/src/scss/_app-sidebar.scss +3 -1
  43. package/src/scss/_bootstrap-overrides.scss +60 -0
  44. package/src/scss/_callouts.scss +13 -3
  45. package/src/scss/_dropdown.scss +3 -1
  46. package/src/scss/_info-box.scss +3 -1
  47. package/src/scss/_progress-bars.scss +3 -1
  48. package/src/scss/_table.scss +4 -2
  49. package/src/scss/adminlte-docs.scss +25 -0
  50. package/src/scss/adminlte.scss +5 -4
  51. package/src/scss/pages/_login_and_register.scss +3 -1
  52. package/src/scss/parts/_core.scss +0 -1
  53. package/src/ts/accessibility.ts +129 -69
  54. package/src/ts/adminlte.ts +3 -1
  55. package/src/ts/base-component.ts +84 -0
  56. package/src/ts/card-widget.ts +134 -85
  57. package/src/ts/color-mode.ts +174 -0
  58. package/src/ts/direct-chat.ts +38 -32
  59. package/src/ts/fullscreen.ts +78 -63
  60. package/src/ts/layout.ts +2 -1
  61. package/src/ts/push-menu.ts +93 -44
  62. package/src/ts/treeview.ts +107 -47
  63. package/src/ts/util/index.ts +114 -29
  64. package/.browserslistrc +0 -17
  65. package/.prettierrc +0 -6
  66. package/CODE_OF_CONDUCT.md +0 -43
  67. package/SECURITY.md +0 -99
  68. package/dist/UI/general.html +0 -2317
  69. package/dist/UI/icons.html +0 -1126
  70. package/dist/UI/timeline.html +0 -1212
  71. package/dist/docs/browser-support.html +0 -826
  72. package/dist/docs/color-mode.html +0 -1142
  73. package/dist/docs/components/main-header.html +0 -1209
  74. package/dist/docs/components/main-sidebar.html +0 -924
  75. package/dist/docs/customization.html +0 -1080
  76. package/dist/docs/deployment.html +0 -1145
  77. package/dist/docs/faq.html +0 -1392
  78. package/dist/docs/getting-started.html +0 -991
  79. package/dist/docs/how-to-contribute.html +0 -890
  80. package/dist/docs/index.html +0 -16
  81. package/dist/docs/integrations.html +0 -1231
  82. package/dist/docs/introduction.html +0 -1008
  83. package/dist/docs/javascript/accessibility.html +0 -949
  84. package/dist/docs/javascript/card-widget.html +0 -1019
  85. package/dist/docs/javascript/direct-chat.html +0 -896
  86. package/dist/docs/javascript/fullscreen.html +0 -905
  87. package/dist/docs/javascript/layout.html +0 -908
  88. package/dist/docs/javascript/plugins-overview.html +0 -1144
  89. package/dist/docs/javascript/pushmenu.html +0 -1045
  90. package/dist/docs/javascript/treeview.html +0 -1024
  91. package/dist/docs/layout-blueprint.html +0 -1087
  92. package/dist/docs/layout.html +0 -842
  93. package/dist/docs/license.html +0 -807
  94. package/dist/docs/migration.html +0 -1149
  95. package/dist/docs/recipes.html +0 -1185
  96. package/dist/docs/rtl.html +0 -957
  97. package/dist/examples/lockscreen.html +0 -249
  98. package/dist/examples/login-v2.html +0 -282
  99. package/dist/examples/login.html +0 -272
  100. package/dist/examples/register-v2.html +0 -291
  101. package/dist/examples/register.html +0 -277
  102. package/dist/forms/elements.html +0 -1405
  103. package/dist/forms/layout.html +0 -1267
  104. package/dist/forms/validation.html +0 -1275
  105. package/dist/forms/wizard.html +0 -1410
  106. package/dist/generate/theme.html +0 -1082
  107. package/dist/index.html +0 -1745
  108. package/dist/index2.html +0 -2277
  109. package/dist/index3.html +0 -1532
  110. package/dist/layout/collapsed-sidebar-without-hover.html +0 -1144
  111. package/dist/layout/collapsed-sidebar.html +0 -1137
  112. package/dist/layout/fixed-complete.html +0 -1138
  113. package/dist/layout/fixed-footer.html +0 -1136
  114. package/dist/layout/fixed-header.html +0 -1136
  115. package/dist/layout/fixed-sidebar.html +0 -1138
  116. package/dist/layout/layout-custom-area.html +0 -1177
  117. package/dist/layout/layout-rtl.html +0 -1137
  118. package/dist/layout/logo-switch.html +0 -683
  119. package/dist/layout/sidebar-mini.html +0 -1137
  120. package/dist/layout/unfixed-sidebar.html +0 -1136
  121. package/dist/mailbox/compose.html +0 -1151
  122. package/dist/mailbox/inbox.html +0 -1509
  123. package/dist/mailbox/read.html +0 -1202
  124. package/dist/pages/404.html +0 -239
  125. package/dist/pages/500.html +0 -227
  126. package/dist/pages/calendar.html +0 -1266
  127. package/dist/pages/chat.html +0 -1427
  128. package/dist/pages/faq.html +0 -1383
  129. package/dist/pages/file-manager.html +0 -1835
  130. package/dist/pages/invoice.html +0 -1199
  131. package/dist/pages/kanban.html +0 -1439
  132. package/dist/pages/maintenance.html +0 -230
  133. package/dist/pages/pricing.html +0 -1279
  134. package/dist/pages/profile.html +0 -1382
  135. package/dist/pages/projects.html +0 -1644
  136. package/dist/pages/settings.html +0 -1387
  137. package/dist/tables/data.html +0 -1319
  138. package/dist/tables/simple.html +0 -1435
  139. package/dist/widgets/cards.html +0 -1356
  140. package/dist/widgets/info-box.html +0 -1429
  141. package/dist/widgets/small-box.html +0 -1225
  142. package/eslint.config.js +0 -82
  143. package/github-issue-response.md +0 -40
  144. package/index.html +0 -12
  145. package/netlify.toml +0 -16
  146. package/src/scss/_bootstrap-variables.scss +0 -1766
@@ -6,7 +6,9 @@
6
6
  * --------------------------------------------
7
7
  */
8
8
 
9
+ import { BaseComponent, dispatchCustomEvent } from './base-component'
9
10
  import {
11
+ getLifecycleSignal,
10
12
  onDOMContentLoaded
11
13
  } from './util/index'
12
14
 
@@ -14,8 +16,8 @@ import {
14
16
  * Constants
15
17
  * ============================================================================
16
18
  */
17
- const DATA_KEY = 'lte.fullscreen'
18
- const EVENT_KEY = `.${DATA_KEY}`
19
+ const NAME = 'fullscreen'
20
+ const EVENT_KEY = `.lte.${NAME}`
19
21
  const EVENT_MAXIMIZED = `maximized${EVENT_KEY}`
20
22
  const EVENT_MINIMIZED = `minimized${EVENT_KEY}`
21
23
 
@@ -23,68 +25,75 @@ const SELECTOR_FULLSCREEN_TOGGLE = '[data-lte-toggle="fullscreen"]'
23
25
  const SELECTOR_MAXIMIZE_ICON = '[data-lte-icon="maximize"]'
24
26
  const SELECTOR_MINIMIZE_ICON = '[data-lte-icon="minimize"]'
25
27
 
28
+ /**
29
+ * Keep the icons and custom events in sync with the browser's actual
30
+ * fullscreen state. Driving this from the `fullscreenchange` event (rather
31
+ * than from the request/exit calls) means the UI stays correct no matter how
32
+ * the transition happened — including the cases the imperative approach could
33
+ * never catch: a request that was denied (permissions policy, missing
34
+ * `allowfullscreen` on an iframe, lost user gesture) and an exit triggered by
35
+ * the user pressing ESC or F11.
36
+ */
37
+ function syncFullScreenState(): void {
38
+ const iconMaximize = document.querySelector<HTMLElement>(SELECTOR_MAXIMIZE_ICON)
39
+ const iconMinimize = document.querySelector<HTMLElement>(SELECTOR_MINIMIZE_ICON)
40
+ const isFullScreen = Boolean(document.fullscreenElement)
41
+
42
+ // Toggle Bootstrap's .d-none utility instead of hardcoding inline
43
+ // display:block. The previous approach overrode the icon library's
44
+ // natural display value (eg. some icon fonts use inline-block) and
45
+ // caused the icon to shift its position. Fixes #6021.
46
+ iconMaximize?.classList.toggle('d-none', isFullScreen)
47
+ iconMinimize?.classList.toggle('d-none', !isFullScreen)
48
+
49
+ const eventName = isFullScreen ? EVENT_MAXIMIZED : EVENT_MINIMIZED
50
+
51
+ document.querySelectorAll(SELECTOR_FULLSCREEN_TOGGLE).forEach(button => {
52
+ dispatchCustomEvent(button, eventName)
53
+ })
54
+ }
55
+
26
56
  /**
27
57
  * Class Definition.
28
58
  * ============================================================================
29
59
  */
30
- class FullScreen {
31
- _element: HTMLElement
32
- _config: undefined
33
-
34
- constructor(element: HTMLElement, config?: undefined) {
35
- this._element = element
36
- this._config = config
60
+ class FullScreen extends BaseComponent {
61
+ static get NAME(): string {
62
+ return NAME
37
63
  }
38
64
 
39
- inFullScreen(): void {
40
- const event = new Event(EVENT_MAXIMIZED)
41
-
42
- const iconMaximize = document.querySelector<HTMLElement>(SELECTOR_MAXIMIZE_ICON)
43
- const iconMinimize = document.querySelector<HTMLElement>(SELECTOR_MINIMIZE_ICON)
44
-
45
- void document.documentElement.requestFullscreen()
46
-
47
- // Toggle Bootstrap's .d-none utility instead of hardcoding inline
48
- // display:block. The previous approach overrode the icon library's
49
- // natural display value (eg. some icon fonts use inline-block) and
50
- // caused the icon to shift its position. Fixes #6021.
51
- if (iconMaximize) {
52
- iconMaximize.classList.add('d-none')
53
- }
65
+ static getInstance(element: Element | null | undefined): FullScreen | null {
66
+ return this._getInstance(element) as FullScreen | null
67
+ }
54
68
 
55
- if (iconMinimize) {
56
- iconMinimize.classList.remove('d-none')
57
- }
69
+ static getOrCreateInstance(element: HTMLElement): FullScreen {
70
+ return this.getInstance(element) ?? new this(element)
71
+ }
58
72
 
59
- this._element.dispatchEvent(event)
73
+ inFullScreen(): void {
74
+ // Fire-and-forget: the resulting `fullscreenchange` event updates the
75
+ // icons and dispatches `maximized.lte.fullscreen`. If the request is
76
+ // denied the event never fires, so the UI correctly stays untouched.
77
+ void document.documentElement.requestFullscreen().catch(() => {
78
+ // Request denied — nothing to undo.
79
+ })
60
80
  }
61
81
 
62
82
  outFullscreen(): void {
63
- const event = new Event(EVENT_MINIMIZED)
64
-
65
- const iconMaximize = document.querySelector<HTMLElement>(SELECTOR_MAXIMIZE_ICON)
66
- const iconMinimize = document.querySelector<HTMLElement>(SELECTOR_MINIMIZE_ICON)
67
-
68
- void document.exitFullscreen()
69
-
70
- if (iconMaximize) {
71
- iconMaximize.classList.remove('d-none')
72
- }
73
-
74
- if (iconMinimize) {
75
- iconMinimize.classList.add('d-none')
76
- }
77
-
78
- this._element.dispatchEvent(event)
83
+ void document.exitFullscreen().catch(() => {
84
+ // Exit failed — nothing to undo.
85
+ })
79
86
  }
80
87
 
81
88
  toggleFullScreen(): void {
82
- if (document.fullscreenEnabled) {
83
- if (document.fullscreenElement) {
84
- this.outFullscreen()
85
- } else {
86
- this.inFullScreen()
87
- }
89
+ if (!document.fullscreenEnabled) {
90
+ return
91
+ }
92
+
93
+ if (document.fullscreenElement) {
94
+ this.outFullscreen()
95
+ } else {
96
+ this.inFullScreen()
88
97
  }
89
98
  }
90
99
  }
@@ -92,23 +101,29 @@ class FullScreen {
92
101
  /**
93
102
  * Data Api implementation
94
103
  * ============================================================================
104
+ * Toggle clicks are delegated on `document`; the fullscreenchange listener is
105
+ * re-registered per page load with the lifecycle signal.
95
106
  */
96
- onDOMContentLoaded(() => {
97
- const buttons = document.querySelectorAll(SELECTOR_FULLSCREEN_TOGGLE)
98
107
 
99
- buttons.forEach(btn => {
100
- btn.addEventListener('click', event => {
101
- event.preventDefault()
108
+ document.addEventListener('click', event => {
109
+ const target = event.target
102
110
 
103
- const target = event.target as HTMLElement
104
- const button = target.closest(SELECTOR_FULLSCREEN_TOGGLE) as HTMLElement | undefined
111
+ if (!(target instanceof Element)) {
112
+ return
113
+ }
105
114
 
106
- if (button) {
107
- const data = new FullScreen(button, undefined)
108
- data.toggleFullScreen()
109
- }
110
- })
111
- })
115
+ const button = target.closest(SELECTOR_FULLSCREEN_TOGGLE) as HTMLElement | null
116
+
117
+ if (!button) {
118
+ return
119
+ }
120
+
121
+ event.preventDefault()
122
+ FullScreen.getOrCreateInstance(button).toggleFullScreen()
123
+ })
124
+
125
+ onDOMContentLoaded(() => {
126
+ document.addEventListener('fullscreenchange', syncFullScreenState, { signal: getLifecycleSignal() })
112
127
  })
113
128
 
114
129
  export default FullScreen
package/src/ts/layout.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import {
10
+ getLifecycleSignal,
10
11
  onDOMContentLoaded
11
12
  } from './util/index'
12
13
 
@@ -62,7 +63,7 @@ class Layout {
62
63
 
63
64
  onDOMContentLoaded(() => {
64
65
  const layout = new Layout(document.body)
65
- window.addEventListener('resize', () => layout.holdTransition(200))
66
+ window.addEventListener('resize', () => layout.holdTransition(200), { signal: getLifecycleSignal() })
66
67
 
67
68
  setTimeout(() => {
68
69
  document.body.classList.add(CLASS_NAME_APP_LOADED)
@@ -6,7 +6,9 @@
6
6
  * ----------------------------------------------------------------------------
7
7
  */
8
8
 
9
+ import { BaseComponent, dispatchCustomEvent } from './base-component'
9
10
  import {
11
+ getLifecycleSignal,
10
12
  onDOMContentLoaded
11
13
  } from './util/index'
12
14
 
@@ -16,11 +18,17 @@ import {
16
18
  * ----------------------------------------------------------------------------
17
19
  */
18
20
 
19
- const DATA_KEY = 'lte.push-menu'
20
- 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.
21
25
  const EVENT_OPEN = `open${EVENT_KEY}`
22
26
  const EVENT_COLLAPSE = `collapse${EVENT_KEY}`
23
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
+
24
32
  const CLASS_NAME_SIDEBAR_MINI = 'sidebar-mini'
25
33
  const CLASS_NAME_SIDEBAR_EXPAND = 'sidebar-expand'
26
34
  const CLASS_NAME_SIDEBAR_OVERLAY = 'sidebar-overlay'
@@ -55,7 +63,9 @@ type Config = {
55
63
  }
56
64
 
57
65
  const Defaults: Config = {
58
- sidebarBreakpoint: 992,
66
+ // Matches the CSS convention (breakpoint-max = breakpoint - .02) so that a
67
+ // window of exactly 992px is classified as desktop by both JS and CSS.
68
+ sidebarBreakpoint: 991.98,
59
69
  enablePersistence: false
60
70
  }
61
71
 
@@ -65,12 +75,23 @@ const Defaults: Config = {
65
75
  * ----------------------------------------------------------------------------
66
76
  */
67
77
 
68
- class PushMenu {
69
- _element: HTMLElement
78
+ class PushMenu extends BaseComponent {
79
+ static get NAME(): string {
80
+ return NAME
81
+ }
82
+
83
+ static getInstance(element: Element | null | undefined): PushMenu | null {
84
+ return this._getInstance(element) as PushMenu | null
85
+ }
86
+
87
+ static getOrCreateInstance(element: HTMLElement, config: Partial<Config> = {}): PushMenu {
88
+ return this.getInstance(element) ?? new this(element, config)
89
+ }
90
+
70
91
  _config: Config
71
92
 
72
- constructor(element: HTMLElement, config: Config) {
73
- this._element = element
93
+ constructor(element: HTMLElement, config: Partial<Config> = {}) {
94
+ super(element)
74
95
  this._config = { ...Defaults, ...config }
75
96
  }
76
97
 
@@ -115,6 +136,12 @@ class PushMenu {
115
136
  * Expand the sidebar menu.
116
137
  */
117
138
  expand(): void {
139
+ // The "open" event is cancelable: preventDefault() keeps the sidebar
140
+ // in its current state.
141
+ if (dispatchCustomEvent(this._element, EVENT_OPEN, { cancelable: true }).defaultPrevented) {
142
+ return
143
+ }
144
+
118
145
  // Remove the sidebar-collapse class. Only on mobile, add the sidebar-open
119
146
  // class to indicate the sidebar is explicitly open.
120
147
 
@@ -124,24 +151,24 @@ class PushMenu {
124
151
  document.body.classList.add(CLASS_NAME_SIDEBAR_OPEN)
125
152
  }
126
153
 
127
- // Dispatch the expand event.
128
-
129
- this._element.dispatchEvent(new Event(EVENT_OPEN))
154
+ dispatchCustomEvent(this._element, EVENT_OPENED)
130
155
  }
131
156
 
132
157
  /**
133
158
  * Collapse the sidebar menu.
134
159
  */
135
160
  collapse(): void {
161
+ if (dispatchCustomEvent(this._element, EVENT_COLLAPSE, { cancelable: true }).defaultPrevented) {
162
+ return
163
+ }
164
+
136
165
  // Remove the sidebar-open class (if present), and add the sidebar-collapse
137
166
  // class.
138
167
 
139
168
  document.body.classList.remove(CLASS_NAME_SIDEBAR_OPEN)
140
169
  document.body.classList.add(CLASS_NAME_SIDEBAR_COLLAPSE)
141
170
 
142
- // Dispatch the collapse event.
143
-
144
- this._element.dispatchEvent(new Event(EVENT_COLLAPSE))
171
+ dispatchCustomEvent(this._element, EVENT_COLLAPSED)
145
172
  }
146
173
 
147
174
  /**
@@ -329,12 +356,40 @@ class PushMenu {
329
356
  * ----------------------------------------------------------------------------
330
357
  * Data Api implementation
331
358
  * ----------------------------------------------------------------------------
359
+ * Toggle clicks are handled by one delegated listener on `document`, so
360
+ * toggle buttons added later work and the listener survives Turbo's <body>
361
+ * swaps. The instance itself is created per page load below and can be
362
+ * retrieved anywhere with:
363
+ *
364
+ * PushMenu.getInstance(document.querySelector('.app-sidebar'))
332
365
  */
333
366
 
367
+ document.addEventListener('click', event => {
368
+ const target = event.target
369
+
370
+ if (!(target instanceof Element)) {
371
+ return
372
+ }
373
+
374
+ const button = target.closest(SELECTOR_SIDEBAR_TOGGLE)
375
+
376
+ if (!button) {
377
+ return
378
+ }
379
+
380
+ event.preventDefault()
381
+
382
+ const sidebar = document.querySelector(SELECTOR_APP_SIDEBAR) as HTMLElement | null
383
+
384
+ if (sidebar) {
385
+ PushMenu.getOrCreateInstance(sidebar).toggle()
386
+ }
387
+ })
388
+
334
389
  onDOMContentLoaded(() => {
335
390
  // Find the sidebar element in the DOM.
336
391
 
337
- const sidebar = document?.querySelector(SELECTOR_APP_SIDEBAR) as HTMLElement | undefined
392
+ const sidebar = document.querySelector(SELECTOR_APP_SIDEBAR) as HTMLElement | null
338
393
 
339
394
  if (!sidebar) {
340
395
  return
@@ -355,23 +410,38 @@ onDOMContentLoaded(() => {
355
410
  enablePersistenceAttr === 'true'
356
411
  }
357
412
 
358
- // Initialize the PushMenu plugin (a unique instance).
413
+ // Initialize the PushMenu plugin (a unique instance per sidebar element;
414
+ // Turbo navigations swap in a fresh element, so a fresh instance is
415
+ // created and the old one is garbage-collected with the old <body>).
359
416
 
360
- const pushMenu = new PushMenu(sidebar, config)
417
+ const pushMenu = PushMenu.getOrCreateInstance(sidebar, config)
361
418
  pushMenu.init()
362
419
 
363
- // Update the sidebar state on window resize events.
420
+ // Update the sidebar state only when the viewport crosses the sidebar
421
+ // breakpoint. matchMedia fires exactly on crossings, so height-only resizes
422
+ // (mobile URL bar / keyboard show-hide) and same-side width changes never
423
+ // disturb a sidebar state the user chose. init() has already read the
424
+ // effective breakpoint from the CSS, so the query uses the final value.
425
+
426
+ const breakpointQuery = globalThis.matchMedia(`(max-width: ${pushMenu._config.sidebarBreakpoint}px)`)
364
427
 
365
- window.addEventListener('resize', () => {
366
- pushMenu.setupSidebarBreakPoint()
428
+ breakpointQuery.addEventListener('change', () => {
367
429
  pushMenu.updateStateByResponsiveLogic()
368
- })
430
+ }, { signal: getLifecycleSignal() })
369
431
 
370
432
  // Create the sidebar overlay element and append it to the app wrapper.
433
+ // Reuse an existing overlay if present: Turbo restores cached <body>
434
+ // snapshots that already contain the injected node (listeners are gone
435
+ // after the restore — the snapshot is a clone — so they are rebound below).
371
436
 
372
- const sidebarOverlay = document.createElement('div')
373
- sidebarOverlay.className = CLASS_NAME_SIDEBAR_OVERLAY
374
- document.querySelector(SELECTOR_APP_WRAPPER)?.append(sidebarOverlay)
437
+ const appWrapper = document.querySelector(SELECTOR_APP_WRAPPER)
438
+ let sidebarOverlay = appWrapper?.querySelector(`:scope > .${CLASS_NAME_SIDEBAR_OVERLAY}`) as HTMLElement | null
439
+
440
+ if (!sidebarOverlay) {
441
+ sidebarOverlay = document.createElement('div')
442
+ sidebarOverlay.className = CLASS_NAME_SIDEBAR_OVERLAY
443
+ appWrapper?.append(sidebarOverlay)
444
+ }
375
445
 
376
446
  // Handle touch events on overlay (area outside sidebar), usually we want to
377
447
  // close the sidebar when the user taps outside the sidebar on mobile
@@ -400,27 +470,6 @@ onDOMContentLoaded(() => {
400
470
  event.preventDefault()
401
471
  pushMenu.collapse()
402
472
  })
403
-
404
- // Handle click events on sidebar toggle buttons.
405
-
406
- const fullBtn = document.querySelectorAll(SELECTOR_SIDEBAR_TOGGLE)
407
-
408
- fullBtn.forEach(btn => {
409
- btn.addEventListener('click', event => {
410
- event.preventDefault()
411
-
412
- let button = event.currentTarget as HTMLElement | undefined
413
-
414
- if (button?.dataset.lteToggle !== 'sidebar') {
415
- button = button?.closest(SELECTOR_SIDEBAR_TOGGLE) as HTMLElement | undefined
416
- }
417
-
418
- if (button) {
419
- event?.preventDefault()
420
- pushMenu.toggle()
421
- }
422
- })
423
- })
424
473
  })
425
474
 
426
475
  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}`
@@ -42,29 +47,56 @@ type Config = {
42
47
  accordion: boolean;
43
48
  }
44
49
 
50
+ /**
51
+ * Reflect a submenu's open state on its toggle link so screen readers can
52
+ * announce the treeview as expandable/expanded (WCAG 4.1.2).
53
+ */
54
+ const setAriaExpanded = (navItem: Element, expanded: boolean): void => {
55
+ const link = navItem.querySelector(`:scope > ${SELECTOR_NAV_LINK}`)
56
+ link?.setAttribute('aria-expanded', String(expanded))
57
+ }
58
+
45
59
  /**
46
60
  * Class Definition
47
61
  * ====================================================
48
62
  */
49
63
 
50
- class Treeview {
51
- _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
+
52
77
  _config: Config
53
78
 
54
- constructor(element: HTMLElement, config: Config) {
55
- this._element = element
79
+ constructor(element: HTMLElement, config: Partial<Config> = {}) {
80
+ super(element)
56
81
  this._config = { ...Default, ...config }
57
82
  }
58
83
 
59
84
  open(): void {
60
- const event = new Event(EVENT_EXPANDED)
85
+ if (dispatchCustomEvent(this._element, EVENT_EXPAND, { cancelable: true }).defaultPrevented) {
86
+ return
87
+ }
61
88
 
62
89
  if (this._config.accordion) {
63
90
  const openMenuList = this._element.parentElement?.querySelectorAll(`${SELECTOR_NAV_ITEM}.${CLASS_NAME_MENU_OPEN}`)
64
91
 
65
92
  openMenuList?.forEach(openMenu => {
66
- if (openMenu !== this._element.parentElement) {
93
+ // Skip the item being opened and anything nested inside it. The old
94
+ // guard compared against the parent <ul> — which never matches a
95
+ // nav-item — so calling open() on an already-open item slid its own
96
+ // menu shut.
97
+ if (!this._element.contains(openMenu)) {
67
98
  openMenu.classList.remove(CLASS_NAME_MENU_OPEN)
99
+ setAriaExpanded(openMenu, false)
68
100
  const childElement = openMenu?.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
69
101
  if (childElement) {
70
102
  slideUp(childElement, this._config.animationSpeed)
@@ -74,26 +106,38 @@ class Treeview {
74
106
  }
75
107
 
76
108
  this._element.classList.add(CLASS_NAME_MENU_OPEN)
109
+ setAriaExpanded(this._element, true)
77
110
 
78
- const childElement = this._element?.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
111
+ const childElement = this._element.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
79
112
  if (childElement) {
80
113
  slideDown(childElement, this._config.animationSpeed)
81
114
  }
82
115
 
83
- 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)
84
121
  }
85
122
 
86
123
  close(): void {
87
- const event = new Event(EVENT_COLLAPSED)
124
+ if (dispatchCustomEvent(this._element, EVENT_COLLAPSE, { cancelable: true }).defaultPrevented) {
125
+ return
126
+ }
88
127
 
89
128
  this._element.classList.remove(CLASS_NAME_MENU_OPEN)
129
+ setAriaExpanded(this._element, false)
90
130
 
91
- const childElement = this._element?.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
131
+ const childElement = this._element.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
92
132
  if (childElement) {
93
133
  slideUp(childElement, this._config.animationSpeed)
94
134
  }
95
135
 
96
- 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)
97
141
  }
98
142
 
99
143
  toggle(): void {
@@ -109,8 +153,50 @@ class Treeview {
109
153
  * ------------------------------------------------------------------------
110
154
  * Data Api implementation
111
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.
112
160
  */
113
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
+
114
200
  onDOMContentLoaded(() => {
115
201
  const openMenuItems = document.querySelectorAll(`${SELECTOR_NAV_ITEM}.${CLASS_NAME_MENU_OPEN}`)
116
202
 
@@ -124,41 +210,15 @@ onDOMContentLoaded(() => {
124
210
  }
125
211
  })
126
212
 
127
- const button = document.querySelectorAll(SELECTOR_DATA_TOGGLE)
128
-
129
- button.forEach(btn => {
130
- btn.addEventListener('click', event => {
131
- const target = event.target as HTMLElement
132
- const targetItem = target.closest(SELECTOR_NAV_ITEM) as HTMLElement | undefined
133
- const targetLink = target.closest(SELECTOR_NAV_LINK) as HTMLAnchorElement | undefined
134
- const targetTreeviewMenu = targetItem?.querySelector(SELECTOR_TREEVIEW_MENU) as HTMLElement | undefined
135
- const lteToggleElement = event.currentTarget as HTMLElement
136
-
137
- // Avoid creating Treeview instances on non menu elements
138
- if (!targetTreeviewMenu) {
139
- return
140
- }
141
-
142
- if (target?.getAttribute('href') === '#' || targetLink?.getAttribute('href') === '#') {
143
- event.preventDefault()
144
- }
145
-
146
- if (targetItem) {
147
- // Read data attributes
148
- const accordionAttr = lteToggleElement.dataset.accordion
149
- const animationSpeedAttr = lteToggleElement.dataset.animationSpeed
150
-
151
- // Build config from data attributes, fallback to Default
152
- const config: Config = {
153
- accordion: accordionAttr === undefined ? Default.accordion : accordionAttr === 'true',
154
- animationSpeed: animationSpeedAttr === undefined ? Default.animationSpeed : Number(animationSpeedAttr)
155
- }
156
-
157
- const data = new Treeview(targetItem, config)
158
- data.toggle()
213
+ // Stamp the initial ARIA state on every submenu toggle so assistive tech
214
+ // knows the items are expandable before any interaction.
215
+ document.querySelectorAll(SELECTOR_DATA_TOGGLE).forEach(root => {
216
+ root.querySelectorAll(SELECTOR_NAV_ITEM).forEach(item => {
217
+ if (item.querySelector(`:scope > ${SELECTOR_TREEVIEW_MENU}`)) {
218
+ setAriaExpanded(item, item.classList.contains(CLASS_NAME_MENU_OPEN))
159
219
  }
160
220
  })
161
221
  })
162
222
  })
163
223
 
164
- export default Treeview
224
+ export default Treeview