admin-lte 4.0.1 → 4.0.4

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 (110) hide show
  1. package/ACCESSIBILITY-COMPLIANCE.md +60 -339
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +15 -2
  4. package/dist/css/adminlte.css +24 -786
  5. package/dist/css/adminlte.css.map +1 -1
  6. package/dist/css/adminlte.min.css +2 -2
  7. package/dist/css/adminlte.min.css.map +1 -1
  8. package/dist/css/adminlte.rtl.css +24 -786
  9. package/dist/css/adminlte.rtl.css.map +1 -1
  10. package/dist/css/adminlte.rtl.min.css +2 -2
  11. package/dist/css/adminlte.rtl.min.css.map +1 -1
  12. package/dist/js/adminlte.js +233 -128
  13. package/dist/js/adminlte.js.map +1 -1
  14. package/dist/js/adminlte.min.js +2 -2
  15. package/dist/js/adminlte.min.js.map +1 -1
  16. package/package.json +30 -17
  17. package/src/scss/_callouts.scss +13 -3
  18. package/src/scss/_dropdown.scss +3 -1
  19. package/src/scss/adminlte.scss +1 -1
  20. package/src/scss/parts/_core.scss +0 -1
  21. package/src/ts/accessibility.ts +129 -69
  22. package/src/ts/adminlte.ts +1 -1
  23. package/src/ts/card-widget.ts +33 -8
  24. package/src/ts/fullscreen.ts +48 -42
  25. package/src/ts/layout.ts +2 -1
  26. package/src/ts/push-menu.ts +24 -8
  27. package/src/ts/treeview.ts +27 -1
  28. package/src/ts/util/index.ts +114 -29
  29. package/.browserslistrc +0 -17
  30. package/.prettierrc +0 -6
  31. package/CODE_OF_CONDUCT.md +0 -43
  32. package/SECURITY.md +0 -99
  33. package/dist/UI/general.html +0 -2317
  34. package/dist/UI/icons.html +0 -1126
  35. package/dist/UI/timeline.html +0 -1212
  36. package/dist/docs/browser-support.html +0 -826
  37. package/dist/docs/color-mode.html +0 -1142
  38. package/dist/docs/components/main-header.html +0 -1209
  39. package/dist/docs/components/main-sidebar.html +0 -924
  40. package/dist/docs/customization.html +0 -1080
  41. package/dist/docs/deployment.html +0 -1145
  42. package/dist/docs/faq.html +0 -1392
  43. package/dist/docs/getting-started.html +0 -991
  44. package/dist/docs/how-to-contribute.html +0 -890
  45. package/dist/docs/index.html +0 -16
  46. package/dist/docs/integrations.html +0 -1231
  47. package/dist/docs/introduction.html +0 -1008
  48. package/dist/docs/javascript/accessibility.html +0 -949
  49. package/dist/docs/javascript/card-widget.html +0 -1019
  50. package/dist/docs/javascript/direct-chat.html +0 -896
  51. package/dist/docs/javascript/fullscreen.html +0 -905
  52. package/dist/docs/javascript/layout.html +0 -908
  53. package/dist/docs/javascript/plugins-overview.html +0 -1144
  54. package/dist/docs/javascript/pushmenu.html +0 -1045
  55. package/dist/docs/javascript/treeview.html +0 -1024
  56. package/dist/docs/layout-blueprint.html +0 -1087
  57. package/dist/docs/layout.html +0 -842
  58. package/dist/docs/license.html +0 -807
  59. package/dist/docs/migration.html +0 -1149
  60. package/dist/docs/recipes.html +0 -1185
  61. package/dist/docs/rtl.html +0 -957
  62. package/dist/examples/lockscreen.html +0 -249
  63. package/dist/examples/login-v2.html +0 -282
  64. package/dist/examples/login.html +0 -272
  65. package/dist/examples/register-v2.html +0 -291
  66. package/dist/examples/register.html +0 -277
  67. package/dist/forms/elements.html +0 -1405
  68. package/dist/forms/layout.html +0 -1267
  69. package/dist/forms/validation.html +0 -1275
  70. package/dist/forms/wizard.html +0 -1410
  71. package/dist/generate/theme.html +0 -1082
  72. package/dist/index.html +0 -1745
  73. package/dist/index2.html +0 -2277
  74. package/dist/index3.html +0 -1532
  75. package/dist/layout/collapsed-sidebar-without-hover.html +0 -1144
  76. package/dist/layout/collapsed-sidebar.html +0 -1137
  77. package/dist/layout/fixed-complete.html +0 -1138
  78. package/dist/layout/fixed-footer.html +0 -1136
  79. package/dist/layout/fixed-header.html +0 -1136
  80. package/dist/layout/fixed-sidebar.html +0 -1138
  81. package/dist/layout/layout-custom-area.html +0 -1177
  82. package/dist/layout/layout-rtl.html +0 -1137
  83. package/dist/layout/logo-switch.html +0 -683
  84. package/dist/layout/sidebar-mini.html +0 -1137
  85. package/dist/layout/unfixed-sidebar.html +0 -1136
  86. package/dist/mailbox/compose.html +0 -1151
  87. package/dist/mailbox/inbox.html +0 -1509
  88. package/dist/mailbox/read.html +0 -1202
  89. package/dist/pages/404.html +0 -239
  90. package/dist/pages/500.html +0 -227
  91. package/dist/pages/calendar.html +0 -1266
  92. package/dist/pages/chat.html +0 -1427
  93. package/dist/pages/faq.html +0 -1383
  94. package/dist/pages/file-manager.html +0 -1835
  95. package/dist/pages/invoice.html +0 -1199
  96. package/dist/pages/kanban.html +0 -1439
  97. package/dist/pages/maintenance.html +0 -230
  98. package/dist/pages/pricing.html +0 -1279
  99. package/dist/pages/profile.html +0 -1382
  100. package/dist/pages/projects.html +0 -1644
  101. package/dist/pages/settings.html +0 -1387
  102. package/dist/tables/data.html +0 -1319
  103. package/dist/tables/simple.html +0 -1435
  104. package/dist/widgets/cards.html +0 -1356
  105. package/dist/widgets/info-box.html +0 -1429
  106. package/dist/widgets/small-box.html +0 -1225
  107. package/eslint.config.js +0 -82
  108. package/github-issue-response.md +0 -40
  109. package/index.html +0 -12
  110. package/netlify.toml +0 -16
@@ -3,6 +3,8 @@
3
3
  * WCAG 2.1 AA Compliance Features
4
4
  */
5
5
 
6
+ import { getLifecycleSignal } from './util/index'
7
+
6
8
  export interface AccessibilityConfig {
7
9
  announcements: boolean
8
10
  skipLinks: boolean
@@ -15,6 +17,9 @@ export class AccessibilityManager {
15
17
  private config: AccessibilityConfig
16
18
  private liveRegion: HTMLElement | null = null
17
19
  private focusHistory: HTMLElement[] = []
20
+ // Listeners bound to `document` survive Turbo's <body> swap, so they are
21
+ // registered with this signal and torn down before the next re-init.
22
+ private readonly signal: AbortSignal = getLifecycleSignal()
18
23
 
19
24
  constructor(config: Partial<AccessibilityConfig> = {}) {
20
25
  this.config = {
@@ -59,18 +64,34 @@ export class AccessibilityManager {
59
64
  private createLiveRegion(): void {
60
65
  if (this.liveRegion) return
61
66
 
67
+ // Adopt an existing region instead of appending a duplicate: Turbo caches
68
+ // the <body> including JS-injected nodes, so a region created before a
69
+ // navigation is restored along with the snapshot.
70
+ const existingRegion = document.getElementById('live-region')
71
+ if (existingRegion) {
72
+ this.liveRegion = existingRegion
73
+ return
74
+ }
75
+
62
76
  this.liveRegion = document.createElement('div')
63
77
  this.liveRegion.id = 'live-region'
64
78
  this.liveRegion.className = 'live-region'
65
79
  this.liveRegion.setAttribute('aria-live', 'polite')
66
80
  this.liveRegion.setAttribute('aria-atomic', 'true')
67
81
  this.liveRegion.setAttribute('role', 'status')
68
-
82
+
69
83
  document.body.append(this.liveRegion)
70
84
  }
71
85
 
72
86
  // WCAG 2.4.1: Bypass Blocks
73
87
  private addSkipLinks(): void {
88
+ // Same Turbo snapshot concern as createLiveRegion: skip links restored
89
+ // with a cached <body> must not be injected a second time.
90
+ if (document.querySelector('.skip-links')) {
91
+ this.ensureSkipTargets()
92
+ return
93
+ }
94
+
74
95
  const skipLinksContainer = document.createElement('div')
75
96
  skipLinksContainer.className = 'skip-links'
76
97
 
@@ -113,51 +134,22 @@ export class AccessibilityManager {
113
134
 
114
135
  // WCAG 2.4.3: Focus Order & 2.4.7: Focus Visible
115
136
  private initFocusManagement(): void {
137
+ // Note: focus must never be wrapped at the document edges — trapping Tab
138
+ // inside the page prevents keyboard users from reaching browser chrome
139
+ // and is itself a WCAG 2.1.2 (No Keyboard Trap) failure. Focus trapping
140
+ // is only legitimate inside active modal dialogs, which Bootstrap already
141
+ // handles.
116
142
  document.addEventListener('keydown', (event) => {
117
- if (event.key === 'Tab') {
118
- this.handleTabNavigation(event)
119
- }
120
143
  if (event.key === 'Escape') {
121
144
  this.handleEscapeKey(event)
122
145
  }
123
- })
146
+ }, { signal: this.signal })
124
147
 
125
148
  // Focus management for modals and dropdowns
126
149
  this.initModalFocusManagement()
127
150
  this.initDropdownFocusManagement()
128
151
  }
129
152
 
130
- private handleTabNavigation(event: KeyboardEvent): void {
131
- const focusableElements = this.getFocusableElements()
132
- const currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement)
133
-
134
- if (event.shiftKey) {
135
- // Shift+Tab (backward)
136
- if (currentIndex <= 0) {
137
- event.preventDefault()
138
- focusableElements.at(-1)?.focus()
139
- }
140
- } else if (currentIndex >= focusableElements.length - 1) {
141
- // Tab (forward)
142
- event.preventDefault()
143
- focusableElements[0]?.focus()
144
- }
145
- }
146
-
147
- private getFocusableElements(): HTMLElement[] {
148
- const selector = [
149
- 'a[href]',
150
- 'button:not([disabled])',
151
- 'input:not([disabled])',
152
- 'select:not([disabled])',
153
- 'textarea:not([disabled])',
154
- '[tabindex]:not([tabindex="-1"])',
155
- '[contenteditable="true"]'
156
- ].join(', ')
157
-
158
- return Array.from(document.querySelectorAll(selector)) as HTMLElement[]
159
- }
160
-
161
153
  private handleEscapeKey(event: KeyboardEvent): void {
162
154
  // Close dropdowns, but let Bootstrap handle its own modal keyboard behavior
163
155
  const activeModal = document.querySelector('.modal.show')
@@ -181,18 +173,24 @@ export class AccessibilityManager {
181
173
  // Add keyboard support for custom components
182
174
  document.addEventListener('keydown', (event) => {
183
175
  const target = event.target as HTMLElement
184
-
176
+
177
+ // Never intercept keys inside editable controls: arrow keys must keep
178
+ // moving the caret in e.g. a navbar search input.
179
+ if (target.matches('input, textarea, select, [contenteditable], [contenteditable] *')) {
180
+ return
181
+ }
182
+
185
183
  // Handle arrow key navigation for menus
186
184
  if (target.closest('.nav, .navbar-nav, .dropdown-menu')) {
187
185
  this.handleMenuNavigation(event)
188
186
  }
189
-
187
+
190
188
  // Handle Enter and Space for custom buttons
191
189
  if ((event.key === 'Enter' || event.key === ' ') && target.hasAttribute('role') && target.getAttribute('role') === 'button' && !target.matches('button, input[type="button"], input[type="submit"]')) {
192
190
  event.preventDefault()
193
191
  target.click()
194
192
  }
195
- })
193
+ }, { signal: this.signal })
196
194
  }
197
195
 
198
196
  private handleMenuNavigation(event: KeyboardEvent): void {
@@ -201,9 +199,18 @@ export class AccessibilityManager {
201
199
  }
202
200
 
203
201
  const currentElement = event.target as HTMLElement
204
- const menuItems = Array.from(currentElement.closest('.nav, .navbar-nav, .dropdown-menu')?.querySelectorAll('a, button') || []) as HTMLElement[]
202
+ const menuItems = (Array.from(currentElement.closest('.nav, .navbar-nav, .dropdown-menu')?.querySelectorAll('a, button') || []) as HTMLElement[])
203
+ // Skip items hidden inside collapsed submenus or closed dropdowns.
204
+ .filter(item => item.offsetParent !== null)
205
205
  const currentIndex = menuItems.indexOf(currentElement)
206
-
206
+
207
+ // Only take over navigation when focus is actually on a menu item;
208
+ // otherwise stealing the arrow keys would yank focus out of unrelated
209
+ // controls that merely sit inside a nav container.
210
+ if (currentIndex === -1) {
211
+ return
212
+ }
213
+
207
214
  let nextIndex: number
208
215
 
209
216
  switch (event.key) {
@@ -244,16 +251,20 @@ export class AccessibilityManager {
244
251
  // Disable smooth scrolling
245
252
  document.documentElement.style.scrollBehavior = 'auto'
246
253
 
247
- // Reduce animation duration
248
- const style = document.createElement('style')
249
- style.textContent = `
250
- *, *::before, *::after {
251
- animation-duration: 0.01ms !important;
252
- animation-iteration-count: 1 !important;
253
- transition-duration: 0.01ms !important;
254
- }
255
- `
256
- document.head.append(style)
254
+ // Reduce animation duration. The <head> survives Turbo navigations, so
255
+ // only inject the override stylesheet once to avoid stacking duplicates.
256
+ if (!document.getElementById('adminlte-reduce-motion')) {
257
+ const style = document.createElement('style')
258
+ style.id = 'adminlte-reduce-motion'
259
+ style.textContent = `
260
+ *, *::before, *::after {
261
+ animation-duration: 0.01ms !important;
262
+ animation-iteration-count: 1 !important;
263
+ transition-duration: 0.01ms !important;
264
+ }
265
+ `
266
+ document.head.append(style)
267
+ }
257
268
  }
258
269
  }
259
270
 
@@ -283,6 +294,11 @@ export class AccessibilityManager {
283
294
  childList: true,
284
295
  subtree: true
285
296
  })
297
+
298
+ // Stop observing the old <body> before Turbo swaps in a new one.
299
+ this.signal.addEventListener('abort', () => {
300
+ observer.disconnect()
301
+ }, { once: true })
286
302
  }
287
303
 
288
304
  // WCAG 1.3.1: Info and Relationships
@@ -351,6 +367,14 @@ export class AccessibilityManager {
351
367
  }
352
368
 
353
369
  private handleFormError(input: HTMLInputElement): void {
370
+ // Inputs lacking both an id and a name would otherwise collide on a
371
+ // single "-error" id. Assign a stable generated id once and persist it on
372
+ // the element so repeated `invalid` events reuse the same error node
373
+ // instead of appending a new orphaned one each time.
374
+ if (!input.id && !input.name) {
375
+ input.id = accessibilityUtils.generateId('field')
376
+ }
377
+
354
378
  const errorId = `${input.id || input.name}-error`
355
379
  let errorElement = document.getElementById(errorId)
356
380
 
@@ -368,7 +392,14 @@ export class AccessibilityManager {
368
392
  }
369
393
 
370
394
  errorElement.textContent = input.validationMessage
371
- input.setAttribute('aria-describedby', errorId)
395
+ // Append to any existing aria-describedby (e.g. help text) instead of
396
+ // clobbering it.
397
+ const describedBy = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean)
398
+ if (!describedBy.includes(errorId)) {
399
+ describedBy.push(errorId)
400
+ }
401
+
402
+ input.setAttribute('aria-describedby', describedBy.join(' '))
372
403
  input.classList.add('is-invalid')
373
404
 
374
405
  this.announce(`Error in ${input.labels?.[0]?.textContent || input.name}: ${input.validationMessage}`, 'assertive')
@@ -376,25 +407,33 @@ export class AccessibilityManager {
376
407
 
377
408
  // Modal focus management
378
409
  private initModalFocusManagement(): void {
410
+ // Capture the trigger on `show` — before Bootstrap (or the `shown`
411
+ // handler below) moves focus into the modal. Capturing on `shown` would
412
+ // store an element inside the modal itself, so closing it would "restore"
413
+ // focus to a now-hidden node and drop focus to <body>.
414
+ document.addEventListener('show.bs.modal', () => {
415
+ this.focusHistory.push(document.activeElement as HTMLElement)
416
+ }, { signal: this.signal })
417
+
379
418
  document.addEventListener('shown.bs.modal', (event) => {
380
419
  const modal = event.target as HTMLElement
381
- const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
382
-
383
- if (focusableElements.length > 0) {
384
- (focusableElements[0] as HTMLElement).focus()
385
- }
386
-
387
- // Store previous focus
388
- this.focusHistory.push(document.activeElement as HTMLElement)
389
- })
420
+ // Respect an explicit [autofocus] before falling back to the first
421
+ // focusable control.
422
+ const autofocusElement = modal.querySelector('[autofocus]') as HTMLElement | null
423
+ const firstFocusable = autofocusElement ||
424
+ (modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') as HTMLElement | null)
425
+
426
+ firstFocusable?.focus()
427
+ }, { signal: this.signal })
390
428
 
391
429
  document.addEventListener('hidden.bs.modal', () => {
392
- // Restore previous focus
430
+ // Restore previous focus, unless the trigger has since been removed
431
+ // from the document (e.g. a delete-confirmation modal removing its row).
393
432
  const previousElement = this.focusHistory.pop()
394
- if (previousElement) {
433
+ if (previousElement?.isConnected) {
395
434
  previousElement.focus()
396
435
  }
397
- })
436
+ }, { signal: this.signal })
398
437
  }
399
438
 
400
439
  // Dropdown focus management
@@ -407,7 +446,7 @@ export class AccessibilityManager {
407
446
  if (firstItem) {
408
447
  firstItem.focus()
409
448
  }
410
- })
449
+ }, { signal: this.signal })
411
450
  }
412
451
 
413
452
  // Public API methods
@@ -460,7 +499,7 @@ export class AccessibilityManager {
460
499
  event.preventDefault()
461
500
  }
462
501
  }
463
- })
502
+ }, { signal: this.signal })
464
503
  }
465
504
 
466
505
  public addLandmarks(): void {
@@ -470,7 +509,9 @@ export class AccessibilityManager {
470
509
  const appMain = document.querySelector('.app-main')
471
510
  if (appMain) {
472
511
  appMain.setAttribute('role', 'main')
473
- appMain.id = 'main'
512
+ if (!appMain.id) {
513
+ appMain.id = 'main'
514
+ }
474
515
  }
475
516
  }
476
517
 
@@ -505,10 +546,29 @@ export const initAccessibility = (config?: Partial<AccessibilityConfig>): Access
505
546
  return new AccessibilityManager(config)
506
547
  }
507
548
 
549
+ // Parse a CSS color into RGB channels. Supports rgb()/rgba() strings and
550
+ // 3- or 6-digit hex notation; anything else falls back to black.
551
+ const parseColorChannels = (color: string): number[] => {
552
+ const hexMatch = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(color.trim())
553
+ if (hexMatch) {
554
+ let hex = hexMatch[1]
555
+ if (hex.length === 3) {
556
+ hex = [...hex].map(character => character + character).join('')
557
+ }
558
+
559
+ return [
560
+ Number.parseInt(hex.slice(0, 2), 16),
561
+ Number.parseInt(hex.slice(2, 4), 16),
562
+ Number.parseInt(hex.slice(4, 6), 16)
563
+ ]
564
+ }
565
+
566
+ return color.match(/\d+/g)?.map(Number) || [0, 0, 0]
567
+ }
568
+
508
569
  // Utility function for luminance calculation
509
570
  const getLuminance = (color: string): number => {
510
- const rgb = color.match(/\d+/g)?.map(Number) || [0, 0, 0]
511
- const [r, g, b] = rgb.map(c => {
571
+ const [r, g, b] = parseColorChannels(color).map(c => {
512
572
  c = c / 255
513
573
  return c <= 0.039_28 ? c / 12.92 : (c + 0.055) ** 2.4 / (1.055 ** 2.4)
514
574
  })
@@ -8,7 +8,7 @@ import PushMenu from './push-menu.js'
8
8
  import { initAccessibility } from './accessibility.js'
9
9
 
10
10
  /**
11
- * AdminLTE v4.0.0
11
+ * AdminLTE v4.0.4
12
12
  * Author: Colorlib
13
13
  * Website: AdminLTE.io <https://adminlte.io>
14
14
  * License: Open source - MIT <https://opensource.org/licenses/MIT>
@@ -75,6 +75,7 @@ class CardWidget {
75
75
 
76
76
  if (this._parent) {
77
77
  this._parent.classList.add(CLASS_NAME_COLLAPSING)
78
+ this._parent.classList.remove(CLASS_NAME_EXPANDING)
78
79
 
79
80
  // Only target direct children to avoid affecting nested cards
80
81
  const elm = this._parent?.querySelectorAll(`:scope > ${SELECTOR_CARD_BODY}, :scope > ${SELECTOR_CARD_FOOTER}`)
@@ -86,7 +87,9 @@ class CardWidget {
86
87
  })
87
88
 
88
89
  setTimeout(() => {
89
- if (this._parent) {
90
+ // Guard against a stale timer: if expand() ran while this collapse
91
+ // was still animating, the card must not end up marked collapsed.
92
+ if (this._parent?.classList.contains(CLASS_NAME_COLLAPSING)) {
90
93
  this._parent.classList.add(CLASS_NAME_COLLAPSED)
91
94
  this._parent.classList.remove(CLASS_NAME_COLLAPSING)
92
95
  }
@@ -101,6 +104,7 @@ class CardWidget {
101
104
 
102
105
  if (this._parent) {
103
106
  this._parent.classList.add(CLASS_NAME_EXPANDING)
107
+ this._parent.classList.remove(CLASS_NAME_COLLAPSING, CLASS_NAME_COLLAPSED)
104
108
 
105
109
  // Only target direct children to avoid affecting nested cards
106
110
  const elm = this._parent?.querySelectorAll(`:scope > ${SELECTOR_CARD_BODY}, :scope > ${SELECTOR_CARD_FOOTER}`)
@@ -112,8 +116,9 @@ class CardWidget {
112
116
  })
113
117
 
114
118
  setTimeout(() => {
115
- if (this._parent) {
116
- this._parent.classList.remove(CLASS_NAME_COLLAPSED, CLASS_NAME_EXPANDING)
119
+ // Same stale-timer guard as collapse(), in the other direction.
120
+ if (this._parent?.classList.contains(CLASS_NAME_EXPANDING)) {
121
+ this._parent.classList.remove(CLASS_NAME_EXPANDING)
117
122
  }
118
123
  }, this._config.animationSpeed)
119
124
  }
@@ -125,14 +130,23 @@ class CardWidget {
125
130
  const event = new Event(EVENT_REMOVE)
126
131
 
127
132
  if (this._parent) {
128
- slideUp(this._parent, this._config.animationSpeed)
133
+ const parent = this._parent
134
+ slideUp(parent, this._config.animationSpeed)
135
+
136
+ // Actually remove the card once the animation finishes — hiding it
137
+ // would keep its form fields submitting and its ids in the document.
138
+ setTimeout(() => {
139
+ parent.remove()
140
+ }, this._config.animationSpeed)
129
141
  }
130
142
 
131
143
  this._element?.dispatchEvent(event)
132
144
  }
133
145
 
134
146
  toggle() {
135
- if (this._parent?.classList.contains(CLASS_NAME_COLLAPSED)) {
147
+ // A card mid-collapse counts as collapsed so a second click during the
148
+ // animation reverses it instead of being swallowed.
149
+ if (this._parent?.classList.contains(CLASS_NAME_COLLAPSED) || this._parent?.classList.contains(CLASS_NAME_COLLAPSING)) {
136
150
  this.expand()
137
151
  return
138
152
  }
@@ -189,6 +203,14 @@ class CardWidget {
189
203
  if (this._parent?.classList.contains(CLASS_NAME_WAS_COLLAPSED)) {
190
204
  this._parent.classList.remove(CLASS_NAME_WAS_COLLAPSED)
191
205
  }
206
+
207
+ // Drop the inline sizing/transition once the restore transition has
208
+ // finished so the card doesn't carry stale styles forever.
209
+ setTimeout(() => {
210
+ this._parent?.style.removeProperty('height')
211
+ this._parent?.style.removeProperty('width')
212
+ this._parent?.style.removeProperty('transition')
213
+ }, 150)
192
214
  }
193
215
  }, 10)
194
216
  }
@@ -215,10 +237,13 @@ class CardWidget {
215
237
  onDOMContentLoaded(() => {
216
238
  const collapseBtn = document.querySelectorAll(SELECTOR_DATA_COLLAPSE)
217
239
 
240
+ // Use currentTarget (the [data-lte-toggle] button itself), not target:
241
+ // clicking the <i> icon inside the button would otherwise make the widget's
242
+ // events fire on the icon instead of the button.
218
243
  collapseBtn.forEach(btn => {
219
244
  btn.addEventListener('click', event => {
220
245
  event.preventDefault()
221
- const target = event.target as HTMLElement
246
+ const target = event.currentTarget as HTMLElement
222
247
  const data = new CardWidget(target, Default)
223
248
  data.toggle()
224
249
  })
@@ -229,7 +254,7 @@ onDOMContentLoaded(() => {
229
254
  removeBtn.forEach(btn => {
230
255
  btn.addEventListener('click', event => {
231
256
  event.preventDefault()
232
- const target = event.target as HTMLElement
257
+ const target = event.currentTarget as HTMLElement
233
258
  const data = new CardWidget(target, Default)
234
259
  data.remove()
235
260
  })
@@ -240,7 +265,7 @@ onDOMContentLoaded(() => {
240
265
  maxBtn.forEach(btn => {
241
266
  btn.addEventListener('click', event => {
242
267
  event.preventDefault()
243
- const target = event.target as HTMLElement
268
+ const target = event.currentTarget as HTMLElement
244
269
  const data = new CardWidget(target, Default)
245
270
  data.toggleMaximize()
246
271
  })
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import {
10
+ getLifecycleSignal,
10
11
  onDOMContentLoaded
11
12
  } from './util/index'
12
13
 
@@ -23,6 +24,34 @@ const SELECTOR_FULLSCREEN_TOGGLE = '[data-lte-toggle="fullscreen"]'
23
24
  const SELECTOR_MAXIMIZE_ICON = '[data-lte-icon="maximize"]'
24
25
  const SELECTOR_MINIMIZE_ICON = '[data-lte-icon="minimize"]'
25
26
 
27
+ /**
28
+ * Keep the icons and custom events in sync with the browser's actual
29
+ * fullscreen state. Driving this from the `fullscreenchange` event (rather
30
+ * than from the request/exit calls) means the UI stays correct no matter how
31
+ * the transition happened — including the cases the imperative approach could
32
+ * never catch: a request that was denied (permissions policy, missing
33
+ * `allowfullscreen` on an iframe, lost user gesture) and an exit triggered by
34
+ * the user pressing ESC or F11.
35
+ */
36
+ function syncFullScreenState(): void {
37
+ const iconMaximize = document.querySelector<HTMLElement>(SELECTOR_MAXIMIZE_ICON)
38
+ const iconMinimize = document.querySelector<HTMLElement>(SELECTOR_MINIMIZE_ICON)
39
+ const isFullScreen = Boolean(document.fullscreenElement)
40
+
41
+ // Toggle Bootstrap's .d-none utility instead of hardcoding inline
42
+ // display:block. The previous approach overrode the icon library's
43
+ // natural display value (eg. some icon fonts use inline-block) and
44
+ // caused the icon to shift its position. Fixes #6021.
45
+ iconMaximize?.classList.toggle('d-none', isFullScreen)
46
+ iconMinimize?.classList.toggle('d-none', !isFullScreen)
47
+
48
+ const eventName = isFullScreen ? EVENT_MAXIMIZED : EVENT_MINIMIZED
49
+
50
+ document.querySelectorAll(SELECTOR_FULLSCREEN_TOGGLE).forEach(button => {
51
+ button.dispatchEvent(new Event(eventName))
52
+ })
53
+ }
54
+
26
55
  /**
27
56
  * Class Definition.
28
57
  * ============================================================================
@@ -37,54 +66,29 @@ class FullScreen {
37
66
  }
38
67
 
39
68
  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
- }
54
-
55
- if (iconMinimize) {
56
- iconMinimize.classList.remove('d-none')
57
- }
58
-
59
- this._element.dispatchEvent(event)
69
+ // Fire-and-forget: the resulting `fullscreenchange` event updates the
70
+ // icons and dispatches `maximized.lte.fullscreen`. If the request is
71
+ // denied the event never fires, so the UI correctly stays untouched.
72
+ void document.documentElement.requestFullscreen().catch(() => {
73
+ // Request denied — nothing to undo.
74
+ })
60
75
  }
61
76
 
62
77
  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)
78
+ void document.exitFullscreen().catch(() => {
79
+ // Exit failed — nothing to undo.
80
+ })
79
81
  }
80
82
 
81
83
  toggleFullScreen(): void {
82
- if (document.fullscreenEnabled) {
83
- if (document.fullscreenElement) {
84
- this.outFullscreen()
85
- } else {
86
- this.inFullScreen()
87
- }
84
+ if (!document.fullscreenEnabled) {
85
+ return
86
+ }
87
+
88
+ if (document.fullscreenElement) {
89
+ this.outFullscreen()
90
+ } else {
91
+ this.inFullScreen()
88
92
  }
89
93
  }
90
94
  }
@@ -94,6 +98,8 @@ class FullScreen {
94
98
  * ============================================================================
95
99
  */
96
100
  onDOMContentLoaded(() => {
101
+ document.addEventListener('fullscreenchange', syncFullScreenState, { signal: getLifecycleSignal() })
102
+
97
103
  const buttons = document.querySelectorAll(SELECTOR_FULLSCREEN_TOGGLE)
98
104
 
99
105
  buttons.forEach(btn => {
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)
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import {
10
+ getLifecycleSignal,
10
11
  onDOMContentLoaded
11
12
  } from './util/index'
12
13
 
@@ -55,7 +56,9 @@ type Config = {
55
56
  }
56
57
 
57
58
  const Defaults: Config = {
58
- sidebarBreakpoint: 992,
59
+ // Matches the CSS convention (breakpoint-max = breakpoint - .02) so that a
60
+ // window of exactly 992px is classified as desktop by both JS and CSS.
61
+ sidebarBreakpoint: 991.98,
59
62
  enablePersistence: false
60
63
  }
61
64
 
@@ -360,18 +363,31 @@ onDOMContentLoaded(() => {
360
363
  const pushMenu = new PushMenu(sidebar, config)
361
364
  pushMenu.init()
362
365
 
363
- // Update the sidebar state on window resize events.
366
+ // Update the sidebar state only when the viewport crosses the sidebar
367
+ // breakpoint. matchMedia fires exactly on crossings, so height-only resizes
368
+ // (mobile URL bar / keyboard show-hide) and same-side width changes never
369
+ // disturb a sidebar state the user chose. init() has already read the
370
+ // effective breakpoint from the CSS, so the query uses the final value.
364
371
 
365
- window.addEventListener('resize', () => {
366
- pushMenu.setupSidebarBreakPoint()
372
+ const breakpointQuery = globalThis.matchMedia(`(max-width: ${pushMenu._config.sidebarBreakpoint}px)`)
373
+
374
+ breakpointQuery.addEventListener('change', () => {
367
375
  pushMenu.updateStateByResponsiveLogic()
368
- })
376
+ }, { signal: getLifecycleSignal() })
369
377
 
370
378
  // Create the sidebar overlay element and append it to the app wrapper.
379
+ // Reuse an existing overlay if present: Turbo restores cached <body>
380
+ // snapshots that already contain the injected node (listeners are gone
381
+ // after the restore — the snapshot is a clone — so they are rebound below).
371
382
 
372
- const sidebarOverlay = document.createElement('div')
373
- sidebarOverlay.className = CLASS_NAME_SIDEBAR_OVERLAY
374
- document.querySelector(SELECTOR_APP_WRAPPER)?.append(sidebarOverlay)
383
+ const appWrapper = document.querySelector(SELECTOR_APP_WRAPPER)
384
+ let sidebarOverlay = appWrapper?.querySelector(`:scope > .${CLASS_NAME_SIDEBAR_OVERLAY}`) as HTMLElement | null
385
+
386
+ if (!sidebarOverlay) {
387
+ sidebarOverlay = document.createElement('div')
388
+ sidebarOverlay.className = CLASS_NAME_SIDEBAR_OVERLAY
389
+ appWrapper?.append(sidebarOverlay)
390
+ }
375
391
 
376
392
  // Handle touch events on overlay (area outside sidebar), usually we want to
377
393
  // close the sidebar when the user taps outside the sidebar on mobile