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.
- package/ACCESSIBILITY-COMPLIANCE.md +60 -339
- package/CHANGELOG.md +53 -0
- package/README.md +15 -2
- package/dist/css/adminlte.css +24 -786
- package/dist/css/adminlte.css.map +1 -1
- package/dist/css/adminlte.min.css +2 -2
- package/dist/css/adminlte.min.css.map +1 -1
- package/dist/css/adminlte.rtl.css +24 -786
- package/dist/css/adminlte.rtl.css.map +1 -1
- package/dist/css/adminlte.rtl.min.css +2 -2
- package/dist/css/adminlte.rtl.min.css.map +1 -1
- package/dist/js/adminlte.js +233 -128
- package/dist/js/adminlte.js.map +1 -1
- package/dist/js/adminlte.min.js +2 -2
- package/dist/js/adminlte.min.js.map +1 -1
- package/package.json +30 -17
- package/src/scss/_callouts.scss +13 -3
- package/src/scss/_dropdown.scss +3 -1
- package/src/scss/adminlte.scss +1 -1
- package/src/scss/parts/_core.scss +0 -1
- package/src/ts/accessibility.ts +129 -69
- package/src/ts/adminlte.ts +1 -1
- package/src/ts/card-widget.ts +33 -8
- package/src/ts/fullscreen.ts +48 -42
- package/src/ts/layout.ts +2 -1
- package/src/ts/push-menu.ts +24 -8
- package/src/ts/treeview.ts +27 -1
- package/src/ts/util/index.ts +114 -29
- package/.browserslistrc +0 -17
- package/.prettierrc +0 -6
- package/CODE_OF_CONDUCT.md +0 -43
- package/SECURITY.md +0 -99
- package/dist/UI/general.html +0 -2317
- package/dist/UI/icons.html +0 -1126
- package/dist/UI/timeline.html +0 -1212
- package/dist/docs/browser-support.html +0 -826
- package/dist/docs/color-mode.html +0 -1142
- package/dist/docs/components/main-header.html +0 -1209
- package/dist/docs/components/main-sidebar.html +0 -924
- package/dist/docs/customization.html +0 -1080
- package/dist/docs/deployment.html +0 -1145
- package/dist/docs/faq.html +0 -1392
- package/dist/docs/getting-started.html +0 -991
- package/dist/docs/how-to-contribute.html +0 -890
- package/dist/docs/index.html +0 -16
- package/dist/docs/integrations.html +0 -1231
- package/dist/docs/introduction.html +0 -1008
- package/dist/docs/javascript/accessibility.html +0 -949
- package/dist/docs/javascript/card-widget.html +0 -1019
- package/dist/docs/javascript/direct-chat.html +0 -896
- package/dist/docs/javascript/fullscreen.html +0 -905
- package/dist/docs/javascript/layout.html +0 -908
- package/dist/docs/javascript/plugins-overview.html +0 -1144
- package/dist/docs/javascript/pushmenu.html +0 -1045
- package/dist/docs/javascript/treeview.html +0 -1024
- package/dist/docs/layout-blueprint.html +0 -1087
- package/dist/docs/layout.html +0 -842
- package/dist/docs/license.html +0 -807
- package/dist/docs/migration.html +0 -1149
- package/dist/docs/recipes.html +0 -1185
- package/dist/docs/rtl.html +0 -957
- package/dist/examples/lockscreen.html +0 -249
- package/dist/examples/login-v2.html +0 -282
- package/dist/examples/login.html +0 -272
- package/dist/examples/register-v2.html +0 -291
- package/dist/examples/register.html +0 -277
- package/dist/forms/elements.html +0 -1405
- package/dist/forms/layout.html +0 -1267
- package/dist/forms/validation.html +0 -1275
- package/dist/forms/wizard.html +0 -1410
- package/dist/generate/theme.html +0 -1082
- package/dist/index.html +0 -1745
- package/dist/index2.html +0 -2277
- package/dist/index3.html +0 -1532
- package/dist/layout/collapsed-sidebar-without-hover.html +0 -1144
- package/dist/layout/collapsed-sidebar.html +0 -1137
- package/dist/layout/fixed-complete.html +0 -1138
- package/dist/layout/fixed-footer.html +0 -1136
- package/dist/layout/fixed-header.html +0 -1136
- package/dist/layout/fixed-sidebar.html +0 -1138
- package/dist/layout/layout-custom-area.html +0 -1177
- package/dist/layout/layout-rtl.html +0 -1137
- package/dist/layout/logo-switch.html +0 -683
- package/dist/layout/sidebar-mini.html +0 -1137
- package/dist/layout/unfixed-sidebar.html +0 -1136
- package/dist/mailbox/compose.html +0 -1151
- package/dist/mailbox/inbox.html +0 -1509
- package/dist/mailbox/read.html +0 -1202
- package/dist/pages/404.html +0 -239
- package/dist/pages/500.html +0 -227
- package/dist/pages/calendar.html +0 -1266
- package/dist/pages/chat.html +0 -1427
- package/dist/pages/faq.html +0 -1383
- package/dist/pages/file-manager.html +0 -1835
- package/dist/pages/invoice.html +0 -1199
- package/dist/pages/kanban.html +0 -1439
- package/dist/pages/maintenance.html +0 -230
- package/dist/pages/pricing.html +0 -1279
- package/dist/pages/profile.html +0 -1382
- package/dist/pages/projects.html +0 -1644
- package/dist/pages/settings.html +0 -1387
- package/dist/tables/data.html +0 -1319
- package/dist/tables/simple.html +0 -1435
- package/dist/widgets/cards.html +0 -1356
- package/dist/widgets/info-box.html +0 -1429
- package/dist/widgets/small-box.html +0 -1225
- package/eslint.config.js +0 -82
- package/github-issue-response.md +0 -40
- package/index.html +0 -12
- package/netlify.toml +0 -16
package/src/ts/accessibility.ts
CHANGED
|
@@ -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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
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
|
|
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
|
})
|
package/src/ts/adminlte.ts
CHANGED
|
@@ -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.
|
|
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>
|
package/src/ts/card-widget.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
268
|
+
const target = event.currentTarget as HTMLElement
|
|
244
269
|
const data = new CardWidget(target, Default)
|
|
245
270
|
data.toggleMaximize()
|
|
246
271
|
})
|
package/src/ts/fullscreen.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
|
|
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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)
|
package/src/ts/push-menu.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
|
|
|
@@ -55,7 +56,9 @@ type Config = {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
const Defaults: Config = {
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
366
|
-
|
|
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
|
|
373
|
-
sidebarOverlay
|
|
374
|
-
|
|
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
|