admin-lte 4.1.0 → 4.3.1
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/CHANGELOG.md +57 -1
- package/README.md +23 -7
- package/dist/css/adminlte-docs.css +1 -1
- package/dist/css/adminlte-docs.css.map +1 -1
- package/dist/css/adminlte-docs.min.css +1 -1
- package/dist/css/adminlte-docs.min.css.map +1 -1
- package/dist/css/adminlte-docs.rtl.css +1 -1
- package/dist/css/adminlte-docs.rtl.css.map +1 -1
- package/dist/css/adminlte-docs.rtl.min.css +1 -1
- package/dist/css/adminlte-docs.rtl.min.css.map +1 -1
- package/dist/css/adminlte-select2.css +329 -0
- package/dist/css/adminlte-select2.css.map +1 -0
- package/dist/css/adminlte-select2.min.css +12 -0
- package/dist/css/adminlte-select2.min.css.map +1 -0
- package/dist/css/adminlte-select2.rtl.css +328 -0
- package/dist/css/adminlte-select2.rtl.css.map +1 -0
- package/dist/css/adminlte-select2.rtl.min.css +12 -0
- package/dist/css/adminlte-select2.rtl.min.css.map +1 -0
- package/dist/css/adminlte.css +332 -131
- 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 +332 -131
- 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.esm.js +300 -109
- package/dist/js/adminlte.esm.js.map +1 -1
- package/dist/js/adminlte.esm.min.js +2 -2
- package/dist/js/adminlte.esm.min.js.map +1 -1
- package/dist/js/adminlte.js +302 -108
- 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/dist/js/types/adminlte.d.ts +3 -1
- package/dist/js/types/base-component.d.ts +24 -0
- package/dist/js/types/color-mode.d.ts +24 -6
- package/dist/js/types/push-menu.d.ts +21 -0
- package/dist/js/types/sidebar-search.d.ts +46 -0
- package/dist/js/types/util/index.d.ts +38 -1
- package/package.json +24 -23
- package/src/scss/_app-sidebar.scss +12 -0
- package/src/scss/_ribbon.scss +75 -0
- package/src/scss/_variables.scss +28 -0
- package/src/scss/_widgets.scss +192 -0
- package/src/scss/adminlte-docs.scss +1 -1
- package/src/scss/adminlte-select2.scss +31 -0
- package/src/scss/adminlte.scss +1 -1
- package/src/scss/compat/_select2.scss +476 -0
- package/src/scss/parts/_extra-components.scss +2 -0
- package/src/ts/accessibility.ts +31 -15
- package/src/ts/adminlte.ts +7 -3
- package/src/ts/base-component.ts +30 -3
- package/src/ts/color-mode.ts +100 -20
- package/src/ts/push-menu.ts +37 -7
- package/src/ts/sidebar-search.ts +252 -0
- package/src/ts/util/index.ts +79 -15
package/src/ts/color-mode.ts
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
* --------------------------------------------
|
|
3
3
|
* @file AdminLTE color-mode.ts
|
|
4
4
|
* @description Color mode (light/dark/auto) switcher for AdminLTE.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* [data-
|
|
5
|
+
* Resolves the theme from, in order: the visitor's stored choice, the theme
|
|
6
|
+
* the page itself declared in <html data-bs-theme="…">, and finally the OS
|
|
7
|
+
* preference. Keeps [data-bs-theme-value] toggles and [data-lte-theme-icon]
|
|
8
|
+
* indicator icons in sync.
|
|
8
9
|
*
|
|
9
10
|
* Ships in the bundle so applications no longer need to copy the demo's
|
|
10
11
|
* inline script. The tiny no-flash snippet in <head> (see _head.astro)
|
|
11
|
-
* remains inline by design — it must run before first paint.
|
|
12
|
+
* remains inline by design — it must run before first paint. That snippet
|
|
13
|
+
* flags the values it computes itself with [data-lte-theme-resolved], so a
|
|
14
|
+
* theme authored in the markup can be told apart from one it resolved.
|
|
15
|
+
*
|
|
16
|
+
* Applications with their own theming opt out entirely with
|
|
17
|
+
* <html data-lte-color-mode="off">.
|
|
12
18
|
* @license MIT
|
|
13
19
|
* --------------------------------------------
|
|
14
20
|
*/
|
|
@@ -26,11 +32,59 @@ const EVENT_CHANGED = `changed${EVENT_KEY}`
|
|
|
26
32
|
|
|
27
33
|
const STORAGE_KEY = 'lte-theme'
|
|
28
34
|
|
|
29
|
-
const
|
|
35
|
+
const ATTRIBUTE_THEME = 'data-bs-theme'
|
|
36
|
+
const ATTRIBUTE_TOGGLE = 'data-bs-theme-value'
|
|
37
|
+
const ATTRIBUTE_DISABLED = 'data-lte-color-mode'
|
|
38
|
+
const ATTRIBUTE_RESOLVED = 'data-lte-theme-resolved'
|
|
39
|
+
|
|
40
|
+
const SELECTOR_TOGGLE = `[${ATTRIBUTE_TOGGLE}]`
|
|
30
41
|
const SELECTOR_ICON = '[data-lte-theme-icon]'
|
|
31
42
|
|
|
32
43
|
type Theme = 'light' | 'dark' | 'auto'
|
|
33
44
|
|
|
45
|
+
const THEMES = new Set<string>(['light', 'dark', 'auto'])
|
|
46
|
+
|
|
47
|
+
const isValidTheme = (value: string): value is Theme => THEMES.has(value)
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Applications with their own theming take over by adding
|
|
51
|
+
* `data-lte-color-mode="off"` to <html>: ColorMode then never writes
|
|
52
|
+
* `data-bs-theme` — not on load, not on a toggle click, not when the OS
|
|
53
|
+
* preference changes. This is also the escape hatch for custom Bootstrap
|
|
54
|
+
* themes, whose names ColorMode cannot resolve (#6084).
|
|
55
|
+
*
|
|
56
|
+
* Read live rather than captured, so it can be flipped at runtime.
|
|
57
|
+
*/
|
|
58
|
+
const isDisabled = (): boolean =>
|
|
59
|
+
document.documentElement.getAttribute(ATTRIBUTE_DISABLED) === 'off'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The theme the page itself declared in <html data-bs-theme="…">, or null when
|
|
63
|
+
* it declared none.
|
|
64
|
+
*
|
|
65
|
+
* Captured once, at module evaluation, because the attribute is both an input
|
|
66
|
+
* and an output: after the first `_applyTheme()` it holds ColorMode's own
|
|
67
|
+
* write, which must not be mistaken for the page's intent on a later lifecycle
|
|
68
|
+
* pass (Turbo, `initialize()`). Reading it here runs before any of those.
|
|
69
|
+
*
|
|
70
|
+
* The pre-paint snippet in <head> writes before this module is even fetched,
|
|
71
|
+
* so it marks the values it computed itself with [data-lte-theme-resolved] —
|
|
72
|
+
* those are not authored, and are ignored.
|
|
73
|
+
*/
|
|
74
|
+
const readMarkupTheme = (): Theme | null => {
|
|
75
|
+
const { documentElement } = document
|
|
76
|
+
|
|
77
|
+
if (documentElement.hasAttribute(ATTRIBUTE_RESOLVED)) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const declared = documentElement.getAttribute(ATTRIBUTE_THEME)
|
|
82
|
+
|
|
83
|
+
return declared && isValidTheme(declared) ? declared : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const MARKUP_THEME = readMarkupTheme()
|
|
87
|
+
|
|
34
88
|
/**
|
|
35
89
|
* Class Definition
|
|
36
90
|
* ====================================================
|
|
@@ -44,20 +98,32 @@ class ColorMode {
|
|
|
44
98
|
getStoredTheme(): Theme | null {
|
|
45
99
|
try {
|
|
46
100
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
47
|
-
return stored &&
|
|
101
|
+
return stored && isValidTheme(stored) ? stored : null
|
|
48
102
|
} catch {
|
|
49
103
|
return null
|
|
50
104
|
}
|
|
51
105
|
}
|
|
52
106
|
|
|
53
107
|
/**
|
|
54
|
-
* The
|
|
55
|
-
*
|
|
108
|
+
* The theme declared in the markup, for applications that render it
|
|
109
|
+
* server-side from a cookie or a user record. Null when the page declared
|
|
110
|
+
* none, or when the value is a custom Bootstrap theme ColorMode cannot
|
|
111
|
+
* resolve — see `isDisabled` for those.
|
|
112
|
+
*/
|
|
113
|
+
getMarkupTheme(): Theme | null {
|
|
114
|
+
return MARKUP_THEME
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The user's effective choice: the stored theme, then the theme declared in
|
|
119
|
+
* the markup, falling back to the OS preference. Storage comes first because
|
|
120
|
+
* it is the visitor's own click on this device; markup is only the default
|
|
121
|
+
* the page shipped with.
|
|
56
122
|
*/
|
|
57
123
|
getPreferredTheme(): Theme {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
return
|
|
124
|
+
const preferred = this.getStoredTheme() ?? this.getMarkupTheme()
|
|
125
|
+
if (preferred) {
|
|
126
|
+
return preferred
|
|
61
127
|
}
|
|
62
128
|
|
|
63
129
|
return this._prefersDark() ? 'dark' : 'light'
|
|
@@ -99,10 +165,13 @@ class ColorMode {
|
|
|
99
165
|
*/
|
|
100
166
|
_applyTheme(theme: Theme): void {
|
|
101
167
|
const resolved = this.resolveTheme(theme)
|
|
102
|
-
document.documentElement.setAttribute(
|
|
168
|
+
document.documentElement.setAttribute(ATTRIBUTE_THEME, resolved)
|
|
103
169
|
document.documentElement.style.colorScheme = resolved
|
|
104
170
|
}
|
|
105
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Whether the OS preference is currently dark.
|
|
174
|
+
*/
|
|
106
175
|
_prefersDark(): boolean {
|
|
107
176
|
return globalThis.matchMedia('(prefers-color-scheme: dark)').matches
|
|
108
177
|
}
|
|
@@ -113,7 +182,7 @@ class ColorMode {
|
|
|
113
182
|
*/
|
|
114
183
|
_showActiveTheme(theme: Theme): void {
|
|
115
184
|
document.querySelectorAll(SELECTOR_TOGGLE).forEach(toggle => {
|
|
116
|
-
const isActive = toggle.getAttribute(
|
|
185
|
+
const isActive = toggle.getAttribute(ATTRIBUTE_TOGGLE) === theme
|
|
117
186
|
toggle.classList.toggle('active', isActive)
|
|
118
187
|
toggle.setAttribute('aria-pressed', String(isActive))
|
|
119
188
|
toggle.querySelector('.bi-check-lg')?.classList.toggle('d-none', !isActive)
|
|
@@ -128,6 +197,10 @@ class ColorMode {
|
|
|
128
197
|
* Apply the preferred theme and sync the UI without persisting anything.
|
|
129
198
|
*/
|
|
130
199
|
init(): void {
|
|
200
|
+
if (isDisabled()) {
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
131
204
|
const theme = this.getPreferredTheme()
|
|
132
205
|
this._applyTheme(theme)
|
|
133
206
|
this._showActiveTheme(theme)
|
|
@@ -145,14 +218,14 @@ class ColorMode {
|
|
|
145
218
|
document.addEventListener('click', event => {
|
|
146
219
|
const target = event.target
|
|
147
220
|
|
|
148
|
-
if (!(target instanceof Element)) {
|
|
221
|
+
if (!(target instanceof Element) || isDisabled()) {
|
|
149
222
|
return
|
|
150
223
|
}
|
|
151
224
|
|
|
152
225
|
const toggle = target.closest(SELECTOR_TOGGLE)
|
|
153
|
-
const theme = toggle?.getAttribute(
|
|
226
|
+
const theme = toggle?.getAttribute(ATTRIBUTE_TOGGLE)
|
|
154
227
|
|
|
155
|
-
if (theme) {
|
|
228
|
+
if (theme && isValidTheme(theme)) {
|
|
156
229
|
new ColorMode().setTheme(theme)
|
|
157
230
|
}
|
|
158
231
|
})
|
|
@@ -161,12 +234,19 @@ onDOMContentLoaded(() => {
|
|
|
161
234
|
const colorMode = new ColorMode()
|
|
162
235
|
colorMode.init()
|
|
163
236
|
|
|
164
|
-
// Follow the OS while
|
|
237
|
+
// Follow the OS only while the OS *is* the effective choice: nothing stored
|
|
238
|
+
// and nothing declared in the markup, or an explicit "auto". A theme the
|
|
239
|
+
// page declared is a preference too, and outlives an OS change (#6093).
|
|
165
240
|
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
166
|
-
|
|
167
|
-
|
|
241
|
+
if (isDisabled()) {
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const preferred = colorMode.getStoredTheme() ?? colorMode.getMarkupTheme()
|
|
246
|
+
|
|
247
|
+
if (!preferred || preferred === 'auto') {
|
|
168
248
|
colorMode._applyTheme('auto')
|
|
169
|
-
colorMode._showActiveTheme(
|
|
249
|
+
colorMode._showActiveTheme(preferred ?? 'auto')
|
|
170
250
|
}
|
|
171
251
|
}, { signal: getLifecycleSignal() })
|
|
172
252
|
})
|
package/src/ts/push-menu.ts
CHANGED
|
@@ -80,16 +80,37 @@ class PushMenu extends BaseComponent {
|
|
|
80
80
|
return NAME
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Look up the PushMenu already attached to the given element.
|
|
85
|
+
*
|
|
86
|
+
* @param element The sidebar element to look up.
|
|
87
|
+
* @returns The existing instance, or null if the sidebar has none yet.
|
|
88
|
+
*/
|
|
83
89
|
static getInstance(element: Element | null | undefined): PushMenu | null {
|
|
84
90
|
return this._getInstance(element) as PushMenu | null
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Look up the PushMenu attached to the given element, creating one when the
|
|
95
|
+
* element has none. `config` is ignored if an instance already exists.
|
|
96
|
+
*
|
|
97
|
+
* @param element The sidebar element.
|
|
98
|
+
* @param config Overrides merged over the defaults for a new instance.
|
|
99
|
+
* @returns The existing or newly created instance.
|
|
100
|
+
*/
|
|
87
101
|
static getOrCreateInstance(element: HTMLElement, config: Partial<Config> = {}): PushMenu {
|
|
88
102
|
return this.getInstance(element) ?? new this(element, config)
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The defaults merged with the overrides this instance was created with.
|
|
107
|
+
*/
|
|
91
108
|
_config: Config
|
|
92
109
|
|
|
110
|
+
/**
|
|
111
|
+
* @param element The sidebar element to attach to.
|
|
112
|
+
* @param config Overrides merged over the defaults.
|
|
113
|
+
*/
|
|
93
114
|
constructor(element: HTMLElement, config: Partial<Config> = {}) {
|
|
94
115
|
super(element)
|
|
95
116
|
this._config = { ...Defaults, ...config }
|
|
@@ -158,6 +179,8 @@ class PushMenu extends BaseComponent {
|
|
|
158
179
|
* Collapse the sidebar menu.
|
|
159
180
|
*/
|
|
160
181
|
collapse(): void {
|
|
182
|
+
// The "collapse" event is cancelable: preventDefault() keeps the sidebar
|
|
183
|
+
// in its current state.
|
|
161
184
|
if (dispatchCustomEvent(this._element, EVENT_COLLAPSE, { cancelable: true }).defaultPrevented) {
|
|
162
185
|
return
|
|
163
186
|
}
|
|
@@ -341,12 +364,12 @@ class PushMenu extends BaseComponent {
|
|
|
341
364
|
|
|
342
365
|
// When persistence is enabled and screen size is above the breakpoint, load
|
|
343
366
|
// the saved sidebar state from local storage. Otherwise, use responsive
|
|
344
|
-
// logic to set the initial state
|
|
345
|
-
//
|
|
367
|
+
// logic to set the initial state (unless explicitly set as collapsed at
|
|
368
|
+
// initialization).
|
|
346
369
|
|
|
347
370
|
if (this._config.enablePersistence && !this.isMobileSize()) {
|
|
348
371
|
this.loadSidebarState()
|
|
349
|
-
} else {
|
|
372
|
+
} else if (!this.isCollapsed()) {
|
|
350
373
|
this.updateStateByResponsiveLogic()
|
|
351
374
|
}
|
|
352
375
|
}
|
|
@@ -446,16 +469,23 @@ onDOMContentLoaded(() => {
|
|
|
446
469
|
// Handle touch events on overlay (area outside sidebar), usually we want to
|
|
447
470
|
// close the sidebar when the user taps outside the sidebar on mobile
|
|
448
471
|
// devices.
|
|
472
|
+
//
|
|
473
|
+
// These are bound with the lifecycle signal even though the overlay lives
|
|
474
|
+
// inside <body>: the node above is reused when it already exists, so under a
|
|
475
|
+
// framework that re-initialises against a persistent <body>, an unsignalled
|
|
476
|
+
// binding would stack another set of handlers on the same element per cycle.
|
|
477
|
+
|
|
478
|
+
const overlaySignal = getLifecycleSignal()
|
|
449
479
|
|
|
450
480
|
let overlayTouchMoved = false
|
|
451
481
|
|
|
452
482
|
sidebarOverlay.addEventListener('touchstart', () => {
|
|
453
483
|
overlayTouchMoved = false
|
|
454
|
-
}, { passive: true })
|
|
484
|
+
}, { passive: true, signal: overlaySignal })
|
|
455
485
|
|
|
456
486
|
sidebarOverlay.addEventListener('touchmove', () => {
|
|
457
487
|
overlayTouchMoved = true
|
|
458
|
-
}, { passive: true })
|
|
488
|
+
}, { passive: true, signal: overlaySignal })
|
|
459
489
|
|
|
460
490
|
sidebarOverlay.addEventListener('touchend', event => {
|
|
461
491
|
if (!overlayTouchMoved) {
|
|
@@ -464,12 +494,12 @@ onDOMContentLoaded(() => {
|
|
|
464
494
|
}
|
|
465
495
|
|
|
466
496
|
overlayTouchMoved = false
|
|
467
|
-
}, { passive: false })
|
|
497
|
+
}, { passive: false, signal: overlaySignal })
|
|
468
498
|
|
|
469
499
|
sidebarOverlay.addEventListener('click', event => {
|
|
470
500
|
event.preventDefault()
|
|
471
501
|
pushMenu.collapse()
|
|
472
|
-
})
|
|
502
|
+
}, { signal: overlaySignal })
|
|
473
503
|
})
|
|
474
504
|
|
|
475
505
|
export default PushMenu
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --------------------------------------------
|
|
3
|
+
* @file AdminLTE sidebar-search.ts
|
|
4
|
+
* @description Live filter for the sidebar menu.
|
|
5
|
+
* @license MIT
|
|
6
|
+
* --------------------------------------------
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { BaseComponent, dispatchCustomEvent } from './base-component'
|
|
10
|
+
import { onDOMContentLoaded } from './util/index'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Constants
|
|
14
|
+
* ============================================================================
|
|
15
|
+
*/
|
|
16
|
+
const NAME = 'sidebar-search'
|
|
17
|
+
const EVENT_KEY = `.lte.${NAME}`
|
|
18
|
+
const EVENT_FILTERED = `filtered${EVENT_KEY}`
|
|
19
|
+
|
|
20
|
+
const CLASS_NAME_MENU_OPEN = 'menu-open'
|
|
21
|
+
|
|
22
|
+
const SELECTOR_DATA_TOGGLE = '[data-lte-toggle="sidebar-search"]'
|
|
23
|
+
const SELECTOR_SIDEBAR = '.app-sidebar'
|
|
24
|
+
const SELECTOR_MENU = '.sidebar-menu'
|
|
25
|
+
const SELECTOR_NAV_ITEM = '.nav-item'
|
|
26
|
+
const SELECTOR_NAV_HEADER = '.nav-header'
|
|
27
|
+
const SELECTOR_NAV_LINK = ':scope > .nav-link'
|
|
28
|
+
const SELECTOR_TREEVIEW_MENU = ':scope > .nav-treeview'
|
|
29
|
+
const SELECTOR_EMPTY_STATE = '[data-lte-search-empty]'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A submenu's resting state, captured before the first keystroke so clearing
|
|
33
|
+
* the field can put the menu back exactly as the user left it. The inline
|
|
34
|
+
* `display` matters as well as the class: Treeview's slide animation writes
|
|
35
|
+
* one, and it would otherwise win over `.menu-open > .nav-treeview`.
|
|
36
|
+
*/
|
|
37
|
+
type SubmenuState = {
|
|
38
|
+
open: boolean;
|
|
39
|
+
display: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const submenuOf = (item: HTMLElement): HTMLElement | null =>
|
|
43
|
+
item.querySelector<HTMLElement>(SELECTOR_TREEVIEW_MENU)
|
|
44
|
+
|
|
45
|
+
const setOpen = (item: HTMLElement, submenu: HTMLElement, open: boolean, display: string): void => {
|
|
46
|
+
item.classList.toggle(CLASS_NAME_MENU_OPEN, open)
|
|
47
|
+
item.querySelector(SELECTOR_NAV_LINK)?.setAttribute('aria-expanded', String(open))
|
|
48
|
+
submenu.style.display = display
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Class Definition
|
|
53
|
+
* ============================================================================
|
|
54
|
+
*/
|
|
55
|
+
class SidebarSearch extends BaseComponent {
|
|
56
|
+
static get NAME(): string {
|
|
57
|
+
return NAME
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static getInstance(element: Element | null | undefined): SidebarSearch | null {
|
|
61
|
+
return this._getInstance(element) as SidebarSearch | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static getOrCreateInstance(element: HTMLElement): SidebarSearch {
|
|
65
|
+
return this.getInstance(element) ?? new this(element)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_menu: HTMLElement | null
|
|
69
|
+
_emptyState: HTMLElement | null
|
|
70
|
+
_snapshot: Map<HTMLElement, SubmenuState> | null = null
|
|
71
|
+
|
|
72
|
+
constructor(element: HTMLElement) {
|
|
73
|
+
super(element)
|
|
74
|
+
|
|
75
|
+
// `data-lte-target` names the menu explicitly; without it, the nearest
|
|
76
|
+
// sidebar's menu is used, which covers the single-sidebar default.
|
|
77
|
+
const target = element.dataset.lteTarget
|
|
78
|
+
const scope = element.closest(SELECTOR_SIDEBAR) ?? document
|
|
79
|
+
|
|
80
|
+
this._menu = target ?
|
|
81
|
+
document.querySelector<HTMLElement>(target) :
|
|
82
|
+
scope.querySelector<HTMLElement>(SELECTOR_MENU)
|
|
83
|
+
this._emptyState = scope.querySelector<HTMLElement>(SELECTOR_EMPTY_STATE)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Show only the menu entries matching `term`, expanding whatever has to be
|
|
88
|
+
* expanded to reveal them. An empty term restores the menu.
|
|
89
|
+
*
|
|
90
|
+
* @param term The text to match against nav-link labels, case-insensitively.
|
|
91
|
+
*/
|
|
92
|
+
search(term: string): void {
|
|
93
|
+
const menu = this._menu
|
|
94
|
+
|
|
95
|
+
if (!menu) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const query = term.trim().toLowerCase()
|
|
100
|
+
|
|
101
|
+
if (!query) {
|
|
102
|
+
this.clear()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this._snapshot ??= this._takeSnapshot(menu)
|
|
107
|
+
|
|
108
|
+
const items = [...menu.querySelectorAll<HTMLElement>(SELECTOR_NAV_ITEM)]
|
|
109
|
+
const matched = new Set<HTMLElement>()
|
|
110
|
+
|
|
111
|
+
for (const item of items) {
|
|
112
|
+
const label = item.querySelector(SELECTOR_NAV_LINK)?.textContent?.replace(/\s+/g, ' ').trim().toLowerCase()
|
|
113
|
+
|
|
114
|
+
if (label?.includes(query)) {
|
|
115
|
+
matched.add(item)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Deepest-first, so by the time a parent is reached its children have
|
|
120
|
+
// already been hidden or kept and it can simply look for a survivor.
|
|
121
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
122
|
+
const item = items[index]
|
|
123
|
+
item.hidden = !(matched.has(item) || item.querySelector(`${SELECTOR_NAV_ITEM}:not([hidden])`))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A group whose own name matched keeps its whole subtree — otherwise
|
|
127
|
+
// expanding it would lead to an empty list.
|
|
128
|
+
for (const item of matched) {
|
|
129
|
+
for (const descendant of item.querySelectorAll<HTMLElement>(SELECTOR_NAV_ITEM)) {
|
|
130
|
+
descendant.hidden = false
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let visible = 0
|
|
135
|
+
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
if (!item.hidden) {
|
|
138
|
+
visible++
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const submenu = submenuOf(item)
|
|
142
|
+
|
|
143
|
+
if (submenu) {
|
|
144
|
+
const expand = !item.hidden && Boolean(item.querySelector(`${SELECTOR_NAV_ITEM}:not([hidden])`))
|
|
145
|
+
setOpen(item, submenu, expand, expand ? 'block' : 'none')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Section headings label groups that are no longer all present.
|
|
150
|
+
for (const header of menu.querySelectorAll<HTMLElement>(SELECTOR_NAV_HEADER)) {
|
|
151
|
+
header.hidden = true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (this._emptyState) {
|
|
155
|
+
this._emptyState.hidden = visible > 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
dispatchCustomEvent(this._element, EVENT_FILTERED, { detail: { query, matches: visible } })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Drop the filter: every entry becomes visible again and each submenu goes
|
|
163
|
+
* back to the open/closed state it had before the search started.
|
|
164
|
+
*/
|
|
165
|
+
clear(): void {
|
|
166
|
+
const menu = this._menu
|
|
167
|
+
|
|
168
|
+
if (!menu) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const item of menu.querySelectorAll<HTMLElement>(`${SELECTOR_NAV_ITEM}, ${SELECTOR_NAV_HEADER}`)) {
|
|
173
|
+
item.hidden = false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this._snapshot) {
|
|
177
|
+
for (const [item, state] of this._snapshot) {
|
|
178
|
+
const submenu = submenuOf(item)
|
|
179
|
+
|
|
180
|
+
if (submenu) {
|
|
181
|
+
setOpen(item, submenu, state.open, state.display)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this._snapshot = null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (this._emptyState) {
|
|
189
|
+
this._emptyState.hidden = true
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
dispatchCustomEvent(this._element, EVENT_FILTERED, { detail: { query: '', matches: -1 } })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
dispose(): void {
|
|
196
|
+
this.clear()
|
|
197
|
+
super.dispose()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_takeSnapshot(menu: HTMLElement): Map<HTMLElement, SubmenuState> {
|
|
201
|
+
const snapshot = new Map<HTMLElement, SubmenuState>()
|
|
202
|
+
|
|
203
|
+
for (const item of menu.querySelectorAll<HTMLElement>(SELECTOR_NAV_ITEM)) {
|
|
204
|
+
const submenu = submenuOf(item)
|
|
205
|
+
|
|
206
|
+
if (submenu) {
|
|
207
|
+
snapshot.set(item, {
|
|
208
|
+
open: item.classList.contains(CLASS_NAME_MENU_OPEN),
|
|
209
|
+
display: submenu.style.display
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return snapshot
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Data Api implementation
|
|
220
|
+
* ============================================================================
|
|
221
|
+
* Both listeners are delegated on `document`, so a sidebar rendered after load
|
|
222
|
+
* (Turbo Frame, client-side router) needs no re-initialisation.
|
|
223
|
+
*/
|
|
224
|
+
|
|
225
|
+
document.addEventListener('input', event => {
|
|
226
|
+
const target = event.target
|
|
227
|
+
|
|
228
|
+
if (target instanceof HTMLInputElement && target.matches(SELECTOR_DATA_TOGGLE)) {
|
|
229
|
+
SidebarSearch.getOrCreateInstance(target).search(target.value)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
document.addEventListener('keydown', event => {
|
|
234
|
+
const target = event.target
|
|
235
|
+
|
|
236
|
+
if (event.key === 'Escape' && target instanceof HTMLInputElement && target.matches(SELECTOR_DATA_TOGGLE)) {
|
|
237
|
+
target.value = ''
|
|
238
|
+
SidebarSearch.getOrCreateInstance(target).clear()
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// A field restored with a value by the browser (bfcache, back navigation)
|
|
243
|
+
// should show its filtered menu rather than a menu that disagrees with it.
|
|
244
|
+
onDOMContentLoaded(() => {
|
|
245
|
+
document.querySelectorAll<HTMLInputElement>(SELECTOR_DATA_TOGGLE).forEach(input => {
|
|
246
|
+
if (input.value) {
|
|
247
|
+
SidebarSearch.getOrCreateInstance(input).search(input.value)
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
export default SidebarSearch
|