@madj2k/fe-frontend-kit 2.0.38 → 2.0.40

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.
@@ -0,0 +1,848 @@
1
+ /**!
2
+ * Madj2kSlideMenu
3
+ *
4
+ * A JavaScript class that implements a slide menu system with nested cards,
5
+ * smooth animations, keyboard navigation, accessibility support (WAI-ARIA),
6
+ * and scroll locking.
7
+ *
8
+ * @author Steffen Kroggel <developer@steffenkroggel.de>
9
+ * @copyright 2025 Steffen Kroggel
10
+ * @version 2.0.0
11
+ * @license GNU General Public License v3.0
12
+ *
13
+ * @example:
14
+ * // JS-Initialization
15
+ * The slide menu is initialized on a toggle element (usually a button).
16
+ * This element must provide an `aria-controls` attribute that references
17
+ * the menu container by ID.
18
+ * import { Madj2kSlideMenu } from '@madj2k/fe-frontend-kit/menus/slide-menu';
19
+ *
20
+ * document.querySelectorAll('.js-slide-nav-toggle').forEach((el) => {
21
+ * new Madj2kSlideMenu(el, {
22
+ * menuItemsJson: slideNavItems
23
+ * });
24
+ * });
25
+ *
26
+ * // Documentation
27
+ * Detailed documentation for:
28
+ * - required HTML structure
29
+ * - menu templates
30
+ * - menuItemsJson format
31
+ * - available options and events
32
+ *
33
+ * can be found in:
34
+ * readme.md
35
+ */
36
+
37
+ class Madj2kSlideMenu {
38
+
39
+ /**
40
+ * Creates an instance of the navigation system for the specified element, enabling sliding navigation with various customizable options.
41
+ *
42
+ * @param {HTMLElement} element - The HTML element to which the navigation system will be attached.
43
+ * @param {Object} [options={}] - An optional object containing configuration options for the navigation system.
44
+ * @return {void}
45
+ */
46
+ constructor(element, options = {}) {
47
+
48
+ const defaults = {
49
+ menuItemsJson: [],
50
+
51
+ // status classes
52
+ openStatusClass: 'open',
53
+ openStatusBodyClass: 'slide-open',
54
+ openStatusBodyClassOverflow: 'slide-open-overflow',
55
+
56
+ openCardStatusClass: 'show',
57
+ activeStatusClass: 'active',
58
+ currentStatusClass: 'current',
59
+ hasChildrenStatusClass: 'has-children',
60
+ linkTypeClass: 'link-type',
61
+ isLinkedClass: 'linked',
62
+
63
+ animationOpenStatusClass: 'opening',
64
+ animationCloseStatusClass: 'closing',
65
+
66
+ // toggle classes
67
+ menuToggleClass: 'js-slide-nav-toggle',
68
+ lastCardToggleClass: 'js-slide-nav-back',
69
+ nextCardToggleClass: 'js-slide-nav-next',
70
+
71
+ // card class
72
+ menuWrapClass: "js-slide-nav-container",
73
+ menuCardClass: 'js-slide-nav-card',
74
+
75
+ // content section
76
+ contentSectionClass: 'js-main-content',
77
+
78
+ // special classes
79
+ templatePartsClass: 'js-slide-nav-tmpl',
80
+
81
+ // params
82
+ animationDuration: 500,
83
+ loadOnOpen: true,
84
+ startOnHome: false,
85
+ scrollHelper: true,
86
+ };
87
+
88
+ this.settings = Object.assign({}, defaults, options);
89
+ this.settings.isLoaded = false;
90
+
91
+ this.$element = element;
92
+ this.settings.$element = element;
93
+
94
+ const controls = element.getAttribute('aria-controls');
95
+ this.settings.$menu = document.getElementById(controls);
96
+
97
+ const positionReference = this.settings.$menu?.getAttribute('data-position-ref');
98
+ this.settings.$positionReference = positionReference
99
+ ? document.getElementById(positionReference)
100
+ : null;
101
+
102
+ this.settings.$cards = [];
103
+ this.settings.$activeCards = [];
104
+ this.settings.$openCard = null;
105
+
106
+ // bind persistent handlers
107
+ this.toggleEvent = this.toggleEvent.bind(this);
108
+ this.closeEvent = this.closeEvent.bind(this);
109
+ this.keyboardEvent = this.keyboardEvent.bind(this);
110
+ this.previousCardEvent = this.previousCardEvent.bind(this);
111
+ this.nextCardEvent = this.nextCardEvent.bind(this);
112
+ this.resizeCardsEvent = this.resizeCardsEvent.bind(this);
113
+ this.positionMenuEvent = this.positionMenuEvent.bind(this);
114
+
115
+ this.initNoScrollHelper();
116
+ this.bindInitialEvents();
117
+
118
+ if (!this.settings.loadOnOpen) {
119
+ this.loadMenu();
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Binds initial event listeners to the specified element and document.
125
+ * Sets up click and keydown event listeners on the element and a custom event listener on the document.
126
+ *
127
+ * @return {void} This method does not return a value.
128
+ */
129
+ bindInitialEvents() {
130
+ this.$element.addEventListener('click', this.toggleEvent);
131
+ this.$element.addEventListener('keydown', this.keyboardEvent);
132
+ document.addEventListener('madj2k-slidemenu-close', this.closeEvent);
133
+ }
134
+
135
+ /**
136
+ * Loads the menu by initializing templates, rendering menu items, and caching elements.
137
+ * Verifies the presence of menu container and templates before setting up the menu.
138
+ * Handles menu item initialization and binds necessary events.
139
+ *
140
+ * @return {boolean} Returns true if the menu was successfully loaded, otherwise returns false.
141
+ */
142
+ loadMenu() {
143
+
144
+ if (this.settings.isLoaded) {
145
+ return true;
146
+ }
147
+
148
+ if (!this.settings.$menu) {
149
+ console.warn('Menu container not found. Can not load menu.');
150
+ return false;
151
+ }
152
+
153
+ if (this.settings.menuItemsJson.length) {
154
+
155
+ // get HTML templates
156
+ this.settings.menuWrapTemplate =
157
+ document.querySelector(`.${this.settings.templatePartsClass}[data-type="menuWrap"]`)?.innerHTML || '';
158
+
159
+ this.settings.menuItemTemplate =
160
+ document.querySelector(`.${this.settings.templatePartsClass}[data-type="menuItem"]`)?.innerHTML || '';
161
+
162
+ this.settings.subMenuWrapTemplate =
163
+ document.querySelector(`.${this.settings.templatePartsClass}[data-type="subMenuWrap"]`)?.innerHTML || '';
164
+
165
+ if (
166
+ this.settings.menuWrapTemplate &&
167
+ this.settings.menuItemTemplate &&
168
+ this.settings.subMenuWrapTemplate
169
+ ) {
170
+ this.settings.$menu.innerHTML =
171
+ this.buildHtml(this.settings.menuItemsJson);
172
+ }
173
+ }
174
+
175
+ // cache cards
176
+ this.settings.$menuWrap =
177
+ this.settings.$menu.querySelector(`.${this.settings.menuWrapClass}`);
178
+
179
+ this.settings.$cards =
180
+ Array.from(this.settings.$menu.querySelectorAll(`.${this.settings.menuCardClass}`));
181
+
182
+ if (this.settings.$cards.length) {
183
+ this.settings.$cards[0].classList.add(this.settings.activeStatusClass);
184
+ }
185
+
186
+ this.settings.$activeCards =
187
+ this.settings.$menu.querySelectorAll(
188
+ `.${this.settings.menuCardClass}.${this.settings.activeStatusClass}`
189
+ );
190
+
191
+ this.settings.$menuWrap.style.top = `-100%`;
192
+ this.settings.isLoaded = true;
193
+ this.bindEvents();
194
+
195
+ return true;
196
+ }
197
+
198
+ /**
199
+ * Binds various event listeners to elements and the window for handling user interaction and responsiveness.
200
+ *
201
+ * @return {void} This method does not return a value.
202
+ */
203
+ bindEvents() {
204
+
205
+ this.settings.$menu
206
+ .querySelectorAll(`.${this.settings.lastCardToggleClass}`)
207
+ .forEach(el => el.addEventListener('click', this.previousCardEvent));
208
+
209
+ this.settings.$menu
210
+ .querySelectorAll(`.${this.settings.nextCardToggleClass}`)
211
+ .forEach(el => {
212
+ el.addEventListener('click', this.nextCardEvent);
213
+ el.addEventListener('keydown', this.keyboardEvent);
214
+ });
215
+
216
+ this.settings.$menu
217
+ .querySelectorAll('a,button,input,textarea,select')
218
+ .forEach(el => el.addEventListener('keydown', this.keyboardEvent));
219
+
220
+ window.addEventListener('resize', this.resizeCardsEvent);
221
+ window.addEventListener('resize', this.positionMenuEvent);
222
+ }
223
+
224
+ /**
225
+ * Toggles the state of an event based on the current condition.
226
+ *
227
+ * @param {Event} e - The event object that triggers the toggle action.
228
+ * @return {void} This method does not return a value.
229
+ */
230
+ toggleEvent(e) {
231
+ e.preventDefault();
232
+ if (!this.open()) {
233
+ this.close();
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Handles the close event by preventing the default behavior
239
+ * and invoking the close method.
240
+ *
241
+ * @param {Event} e - The event object associated with the close action.
242
+ * @return {void} This method does not return any value.
243
+ */
244
+ closeEvent (e) {
245
+ e.preventDefault();
246
+ this.close();
247
+ }
248
+
249
+ /**
250
+ * Opens the menu and performs various operations to adjust state, animations, and accessibility.
251
+ * Includes triggering events, toggling classes, repositioning, and handling ARIA attributes.
252
+ *
253
+ * @return {boolean} Returns `true` if the menu was opened successfully. Returns `false` if the menu was already open, an animation was in progress, or if menu initialization failed.
254
+ */
255
+ open() {
256
+
257
+ if (
258
+ !this.loadMenu() ||
259
+ this.settings.$menu.classList.contains(this.settings.openStatusClass) ||
260
+ this.settings.$menu.classList.contains(this.settings.animationOpenStatusClass)
261
+ ) {
262
+ return false;
263
+ }
264
+
265
+ document.dispatchEvent(new Event('madj2k-flyoutmenu-close'));
266
+ document.dispatchEvent(new Event('madj2k-slidemenu-opening'));
267
+
268
+ this.toggleNoScroll();
269
+ this.disableTabIndexOnAllCards();
270
+ this.positionMenu();
271
+
272
+ this.settings.$menu.classList.add(
273
+ this.settings.openStatusClass,
274
+ this.settings.animationOpenStatusClass
275
+ );
276
+
277
+ this.$element.classList.add(
278
+ this.settings.openStatusClass,
279
+ this.settings.animationOpenStatusClass
280
+ );
281
+
282
+ this.$element.setAttribute('aria-expanded', 'true');
283
+
284
+ let openCard = this.settings.$activeCards[
285
+ this.settings.startOnHome ? 0 : this.settings.$activeCards.length - 1
286
+ ];
287
+
288
+ this.setOpenCard(openCard);
289
+ this.resizeCards();
290
+ this.repositionCards();
291
+
292
+ this.animateElement(
293
+ this.settings.$menuWrap,
294
+ { top: '-100%' },
295
+ { top: '0' }
296
+ );
297
+
298
+ setTimeout(() => {
299
+ this.settings.$menu.classList.remove(this.settings.animationOpenStatusClass);
300
+ this.$element.classList.remove(this.settings.animationOpenStatusClass);
301
+
302
+ this.toggleTabIndexOnOpenCard();
303
+ this.toggleWaiAriaForOpenCard();
304
+ this.focusFirstItemOfOpenCard();
305
+
306
+ document.dispatchEvent(new Event('madj2k-slidemenu-opened'));
307
+ }, this.settings.animationDuration);
308
+
309
+ return true;
310
+ }
311
+
312
+
313
+ /**
314
+ * Closes the menu if it is currently open and not already in the process of closing.
315
+ * Dispatches events to notify when the menu starts closing (`madj2k-slidemenu-closing`)
316
+ * and when the menu has fully closed (`madj2k-slidemenu-closed`).
317
+ * Handles animations, updates ARIA attributes, and manages other related DOM properties.
318
+ *
319
+ * @return {boolean} Returns `true` if the menu was successfully closed, or `false` if
320
+ * the menu was not in an open or closable state.
321
+ */
322
+ close() {
323
+
324
+ if (
325
+ !this.settings.$menu.classList.contains(this.settings.openStatusClass) ||
326
+ this.settings.$menu.classList.contains(this.settings.animationCloseStatusClass)
327
+ ) {
328
+ return false;
329
+ }
330
+
331
+ document.dispatchEvent(new Event('madj2k-slidemenu-closing'));
332
+
333
+ this.settings.$menu.classList.add(this.settings.animationCloseStatusClass);
334
+ this.$element.classList.add(this.settings.animationCloseStatusClass);
335
+ this.$element.classList.remove(this.settings.openStatusClass);
336
+ this.$element.setAttribute('aria-expanded', 'false');
337
+
338
+ this.toggleTabIndexOnOpenCard();
339
+ this.toggleWaiAriaForOpenCard();
340
+
341
+ this.animateElement(
342
+ this.settings.$menuWrap,
343
+ { top: '0' },
344
+ { top: '-100%' }
345
+ );
346
+
347
+ setTimeout(() => {
348
+ this.settings.$menu.classList.remove(
349
+ this.settings.openStatusClass,
350
+ this.settings.animationCloseStatusClass
351
+ );
352
+
353
+ this.$element.classList.remove(this.settings.animationCloseStatusClass);
354
+
355
+ this.toggleNoScroll();
356
+ document.dispatchEvent(new Event('madj2k-slidemenu-closed'));
357
+ }, this.settings.animationDuration);
358
+
359
+ return true;
360
+ }
361
+
362
+
363
+ /**
364
+ * Handles keyboard events triggered on an element.
365
+ *
366
+ * @param {KeyboardEvent} e - The keyboard event object containing details of the interaction.
367
+ * @return {void} No return value.
368
+ */
369
+ keyboardEvent(e) {
370
+
371
+ const element = e.target;
372
+
373
+ switch (e.key) {
374
+
375
+ case 'ArrowUp':
376
+ if (element === this.$element) this.close();
377
+ break;
378
+
379
+ case 'ArrowDown':
380
+ if (element === this.$element) {
381
+ e.preventDefault();
382
+ this.open();
383
+ }
384
+ break;
385
+
386
+ case 'Escape':
387
+ e.preventDefault();
388
+ this.close();
389
+ this.focusToggle();
390
+ break;
391
+
392
+ case 'Tab':
393
+ if (!this.settings.$openCard) break;
394
+
395
+ const focusables = Array.from(
396
+ this.settings.$openCard.querySelectorAll(
397
+ 'a:not([tabindex="-1"]), button:not([tabindex="-1"]), input:not([tabindex="-1"]), textarea:not([tabindex="-1"]), select:not([tabindex="-1"])'
398
+ )
399
+ ).filter(el =>
400
+ el.closest(`.${this.settings.menuCardClass}`) === this.settings.$openCard
401
+ );
402
+
403
+ if (!focusables.length) break;
404
+
405
+ const first = focusables[0];
406
+ const last = focusables[focusables.length - 1];
407
+
408
+ if (e.shiftKey && document.activeElement === first) {
409
+ e.preventDefault();
410
+ last.focus();
411
+ } else if (!e.shiftKey && document.activeElement === last) {
412
+ e.preventDefault();
413
+ first.focus();
414
+ }
415
+
416
+ break;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Handles the event to navigate to the previous card in a sliding menu.
422
+ *
423
+ * @param {Event} e The event object triggered by the user interaction.
424
+ * @return {void} This method does not return a value.
425
+ */
426
+ previousCardEvent(e) {
427
+ e.preventDefault();
428
+
429
+ const target = e.currentTarget;
430
+ const controlledCard = document.getElementById(target.getAttribute('aria-controls'));
431
+ const parentCard = document.getElementById(target.dataset.parentCard);
432
+
433
+ if (!controlledCard || !parentCard) return;
434
+
435
+ this.disableTabIndexOnAllCards();
436
+ controlledCard.classList.add(this.settings.animationCloseStatusClass);
437
+
438
+ this.animateElement(
439
+ controlledCard,
440
+ { left: '0' },
441
+ { left: '100%' }
442
+ );
443
+
444
+ setTimeout(() => {
445
+ this.changeOpenCard(parentCard);
446
+ controlledCard.classList.remove(this.settings.animationCloseStatusClass);
447
+ document.dispatchEvent(new Event('madj2k-slidemenu-previous-opened'));
448
+ }, this.settings.animationDuration);
449
+ }
450
+
451
+
452
+ /**
453
+ * Triggers the animation and logic to open the next card in a slide menu.
454
+ * Handles disabling tab indices on all cards, animating the transition,
455
+ * and dispatching a custom event when the next card has fully opened.
456
+ *
457
+ * @param {Event} e The event object triggered by clicking or interacting with the navigation element.
458
+ * @return {void} This method does not return a value.
459
+ */
460
+ nextCardEvent(e) {
461
+ e.preventDefault();
462
+
463
+ const target = e.currentTarget;
464
+ const controlledCard = document.getElementById(target.getAttribute('aria-controls'));
465
+
466
+ if (!controlledCard) return;
467
+
468
+ this.disableTabIndexOnAllCards();
469
+ controlledCard.classList.add(this.settings.animationOpenStatusClass);
470
+
471
+ this.animateElement(
472
+ controlledCard,
473
+ { left: '100%' },
474
+ { left: '0' }
475
+ );
476
+
477
+ setTimeout(() => {
478
+ this.changeOpenCard(controlledCard);
479
+ controlledCard.classList.remove(this.settings.animationOpenStatusClass);
480
+ document.dispatchEvent(new Event('madj2k-slidemenu-next-opened'));
481
+ }, this.settings.animationDuration);
482
+ }
483
+
484
+
485
+ /**
486
+ * Sets the provided card as the currently open card and updates its status class.
487
+ * Removes the open card status class from all other cards in the collection.
488
+ *
489
+ * @param {HTMLElement} card - The card element to set as open.
490
+ * @return {void}
491
+ */
492
+ setOpenCard(card) {
493
+ this.settings.$openCard = card;
494
+ this.settings.$cards.forEach(c =>
495
+ c.classList.remove(this.settings.openCardStatusClass)
496
+ );
497
+ card.classList.add(this.settings.openCardStatusClass);
498
+ }
499
+
500
+ /**
501
+ * Changes the currently open card by performing a series of actions including updating its state,
502
+ * toggling accessibility attributes, and setting focus.
503
+ *
504
+ * @param {Object} card - The card object to be opened.
505
+ * @return {void} This method does not return a value.
506
+ */
507
+ changeOpenCard(card) {
508
+ this.setOpenCard(card);
509
+ this.toggleTabIndexOnOpenCard();
510
+ this.toggleWaiAriaForOpenCard();
511
+ this.focusFirstItemOfOpenCard();
512
+ }
513
+
514
+ /**
515
+ * Focuses the first focusable item (such as links, buttons, inputs, etc.) within the currently open card, if available.
516
+ * Optionally allows a delay before focusing.
517
+ *
518
+ * @param {number} [timeout=0] - The amount of delay in milliseconds before focusing the item.
519
+ * @return {void} Does not return any value.
520
+ */
521
+ focusFirstItemOfOpenCard(timeout = 0) {
522
+ const el = this.settings.$openCard?.querySelector(
523
+ 'a:not([tabindex]),button:not([tabindex]),input:not([tabindex]),textarea:not([tabindex]),select:not([tabindex])'
524
+ );
525
+ if (el) setTimeout(() => el.focus(), timeout);
526
+ }
527
+
528
+ /**
529
+ * Toggles the focus state of an element by applying focus after a specified timeout.
530
+ *
531
+ * @param {number} [timeout=0] - Optional timeout in milliseconds before the element is focused.
532
+ * @return {void} This method does not return a value.
533
+ */
534
+ focusToggle(timeout = 0) {
535
+ setTimeout(() => this.$element.focus(), timeout);
536
+ }
537
+
538
+ /**
539
+ * Toggles the WAI-ARIA attributes for the currently open card.
540
+ * Updates the `aria-expanded` attribute for card toggle elements within the menu
541
+ * to reflect whether the corresponding card is open or closed.
542
+ *
543
+ * @return {void} No return value.
544
+ */
545
+ toggleWaiAriaForOpenCard() {
546
+ this.settings.$menu
547
+ .querySelectorAll(`.${this.settings.nextCardToggleClass}`)
548
+ .forEach(el => el.setAttribute('aria-expanded', 'false'));
549
+
550
+ if (this.$element.classList.contains(this.settings.openStatusClass)) {
551
+ const id = this.settings.$openCard.id;
552
+ const toggle = this.settings.$menu.querySelector(
553
+ `.${this.settings.nextCardToggleClass}[aria-controls="${id}"]`
554
+ );
555
+ if (toggle) toggle.setAttribute('aria-expanded', 'true');
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Toggles the tabindex attributes of focusable elements within an open card based on visibility in the viewport.
561
+ * Ensures that only elements visible within the card have tabindex enabled, while disabling tabindex for all other cards.
562
+ *
563
+ * @return {void} This method does not return a value.
564
+ */
565
+ toggleTabIndexOnOpenCard() {
566
+ this.disableTabIndexOnAllCards();
567
+
568
+ if (!this.$element.classList.contains(this.settings.openStatusClass)) return;
569
+
570
+ const width = window.innerWidth;
571
+ this.settings.$openCard
572
+ .querySelectorAll('a,button,input,textarea,select')
573
+ .forEach(el => {
574
+ const rect = el.getBoundingClientRect();
575
+ if (rect.left > 0 && rect.left <= width) {
576
+ el.removeAttribute('tabindex');
577
+ }
578
+ });
579
+ }
580
+
581
+ /**
582
+ * Disables the tabIndex attribute for all interactive elements
583
+ * (such as links, buttons, inputs, textareas, and selects)
584
+ * inside the menu element specified in the settings object.
585
+ *
586
+ * @return {void} This method does not return a value.
587
+ */
588
+ disableTabIndexOnAllCards() {
589
+ this.settings.$menu
590
+ .querySelectorAll('a,button,input,textarea,select')
591
+ .forEach(el => el.setAttribute('tabindex', '-1'));
592
+ }
593
+
594
+
595
+ /**
596
+ * Handles the event to resize cards by invoking the resizeCards method.
597
+ *
598
+ * @return {void} Does not return any value.
599
+ */
600
+ resizeCardsEvent() {
601
+ this.resizeCards();
602
+ }
603
+
604
+ /**
605
+ * Resizes the height of card elements based on the available space below a reference element.
606
+ *
607
+ * This method calculates the height by subtracting the bottom position of a reference element
608
+ * from the height of the viewport. The calculated height is then applied to each card in the
609
+ * collection of cards.
610
+ *
611
+ * @return {void} Does not return any value. The method updates the DOM elements directly.
612
+ */
613
+ resizeCards() {
614
+ const ref = this.settings.$positionReference || this.$element;
615
+ const rect = ref.getBoundingClientRect();
616
+ const height = window.innerHeight - rect.bottom;
617
+
618
+ this.settings.$cards.forEach(card => {
619
+ card.style.height = `${height}px`;
620
+ });
621
+ }
622
+
623
+
624
+ /**
625
+ * Adjusts the position of all cards in the collection. Sets all cards' `left` style property
626
+ * to "100%" with no transition effect. If a card is currently designated as open,
627
+ * its `left` style property is set to "0".
628
+ *
629
+ * @return {void} This method does not return a value.
630
+ */
631
+ repositionCards() {
632
+ this.settings.$cards.forEach(card => {
633
+ card.style.transition = 'none';
634
+ card.style.left = '100%';
635
+ });
636
+
637
+ if (this.settings.$openCard) {
638
+ this.settings.$openCard.style.left = '0';
639
+ }
640
+
641
+ // ALSO show all parent cards
642
+ let parent = this.settings.$openCard.parentElement;
643
+ while (parent) {
644
+ if (parent.classList?.contains(this.settings.menuCardClass)) {
645
+ parent.style.left = '0';
646
+ }
647
+ parent = parent.parentElement;
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Positions the menu by invoking the positionMenu method.
653
+ *
654
+ * @return {void} Does not return a value.
655
+ */
656
+ positionMenuEvent() {
657
+ this.positionMenu();
658
+ }
659
+
660
+ /**
661
+ * Positions the menu element based on the reference element's position within the viewport.
662
+ *
663
+ * This method calculates the bottom position of the reference element and sets the menu's top style
664
+ * to align it accordingly. If no position reference is specified in the settings, the element itself
665
+ * is used as the reference point.
666
+ *
667
+ * @return {void} Does not return a value.
668
+ */
669
+ positionMenu() {
670
+ const ref = this.settings.$positionReference || this.$element;
671
+ const rect = ref.getBoundingClientRect();
672
+ this.settings.$menu.style.top = `${rect.bottom}px`;
673
+ }
674
+
675
+ /**
676
+ * Animate element to target position
677
+ *
678
+ * @param {HTMLElement} el
679
+ * @param {{ left?: string, top?: string }} from
680
+ * @param {{ left?: string, top?: string }} to
681
+ */
682
+ animateElement(el, from, to) {
683
+
684
+ el.style.transition = 'none';
685
+
686
+ if (from.left !== undefined) el.style.left = from.left;
687
+ if (from.top !== undefined) el.style.top = from.top;
688
+
689
+ // force reflow
690
+ el.offsetHeight;
691
+ el.style.transition = `all ${this.settings.animationDuration}ms ease`;
692
+
693
+ requestAnimationFrame(() => {
694
+ if (to.left !== undefined) el.style.left = to.left;
695
+ if (to.top !== undefined) el.style.top = to.top;
696
+ });
697
+ }
698
+
699
+ /**
700
+ * Toggles the "no-scroll" state for the body element to enable or disable scrolling.
701
+ * When enabling the "no-scroll" state, it stores the current scroll position, applies
702
+ * necessary styles to lock the scroll, and resets the scroll position to the top.
703
+ * When disabling the state, it restores the original scroll position and removes the applied styles.
704
+ *
705
+ * @return {void} This method does not return a value.
706
+ */
707
+ toggleNoScroll() {
708
+
709
+ if (this.settings.scrollHelper) {
710
+ const body = document.body;
711
+ const helper = body.querySelector('.no-scroll-helper');
712
+ const inner = body.querySelector('.no-scroll-helper-inner');
713
+ let noScrollClass ='';
714
+
715
+ if (document.documentElement.scrollHeight > window.innerHeight) {
716
+ noScrollClass = this.settings.openStatusBodyClassOverflow;
717
+ }
718
+
719
+ if (!body.classList.contains(this.settings.openStatusBodyClass)) {
720
+ const scrollTop = -document.documentElement.scrollTop;
721
+ helper.setAttribute('data-scroll-top', scrollTop);
722
+ helper.style.cssText = 'position:relative;overflow:hidden;height:100vh;width:100%';
723
+ inner.style.cssText = `position:absolute;top:${scrollTop}px;height:100%;width:100%`;
724
+ body.classList.add(noScrollClass);
725
+ window.scrollTo({top: 0, behavior: 'instant'});
726
+ } else {
727
+ const scrollTop = parseInt(helper.getAttribute('data-scroll-top') || '0') * -1;
728
+ helper.removeAttribute('style');
729
+ inner.removeAttribute('style');
730
+ body.classList.remove(this.settings.openStatusBodyClassOverflow);
731
+ window.scrollTo({top: scrollTop, behavior: 'instant'});
732
+ }
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Initializes a 'no-scroll-helper' element within the specified content section. This method ensures
738
+ * that the content is wrapped inside helper elements to manage scroll behavior. If the 'no-scroll-helper'
739
+ * element already exists, the method does nothing.
740
+ *
741
+ * @return {void} Does not return any value.
742
+ */
743
+ initNoScrollHelper() {
744
+ if (this.settings.scrollHelper) {
745
+ const body = document.body;
746
+ let helper = body.querySelector('.no-scroll-helper');
747
+ const content = document.querySelector(`.${this.settings.contentSectionClass}`);
748
+
749
+ if (!helper) {
750
+ if (content) {
751
+ content.innerHTML = `<div class="no-scroll-helper"><div class="no-scroll-helper-inner">${content.innerHTML}</div></div>`;
752
+ } else {
753
+ body.innerHTML = `<div class="no-scroll-helper"><div class="no-scroll-helper-inner">${body.innerHTML}</div></div>`;
754
+ }
755
+ }
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Builds an HTML string for a menu structure based on the provided items and templates.
761
+ *
762
+ * @param {Array} items - The array of menu items to process. Each item should be an object with properties such as `hasSubpages` and `children`.
763
+ * @param {Object|null} [parentItem=null] - The parent menu item of the current set of items. Defaults to `null` for the top-level items.
764
+ * @param {number} [level=0] - The current nesting level within the menu. Defaults to `0` for the top level.
765
+ * @return {string} The generated HTML string representing the menu structure.
766
+ */
767
+ buildHtml(items, parentItem = null, level = 0) {
768
+
769
+ let html = '';
770
+
771
+ items.forEach(item => {
772
+ const marker = this.getItemMarker(item, parentItem, level);
773
+ if (item.hasSubpages && item.children?.length) {
774
+ marker.submenu = this.buildHtml(item.children, item, level + 1);
775
+ }
776
+ html += this.replaceHtml(this.settings.menuItemTemplate, marker);
777
+ });
778
+
779
+ if (parentItem) {
780
+ const marker = this.getItemMarker(parentItem, null, level);
781
+ marker.menuItems = html;
782
+ html = this.replaceHtml(this.settings.subMenuWrapTemplate, marker);
783
+ } else {
784
+ html = this.replaceHtml(this.settings.menuWrapTemplate, {
785
+ uid: items[0].data.pid,
786
+ menuItems: html,
787
+ levelClass: 'level-1'
788
+ });
789
+ }
790
+
791
+ return html.replace(/<!--[\s\S]*?-->/g, '');
792
+ }
793
+
794
+ /**
795
+ * Generates a marker object describing various attributes and properties for a given item.
796
+ *
797
+ * @param {Object} item - The item object for which the marker is being generated.
798
+ * @param {Object|null} [parentItem=null] - The parent item object, if available. Defaults to null.
799
+ * @param {number} [level=0] - The hierarchical level of the item within the navigation structure. Defaults to 0.
800
+ * @return {Object} - An object containing various properties and attributes related to the item, such as classes, ARIA attributes, hierarchical level, and metadata.
801
+ */
802
+ getItemMarker(item, parentItem = null, level = 0) {
803
+ return {
804
+ activeClass: item.active ? this.settings.activeStatusClass : '',
805
+ currentClass: item.current ? this.settings.currentStatusClass : '',
806
+ levelClass: `level-${level + 1}`,
807
+ ariaCurrent: item.current ? 'page' : '',
808
+ ariaExpanded: item.current ? 'true' : 'false',
809
+ hasChildrenClass: item.hasSubpages ? this.settings.hasChildrenStatusClass : '',
810
+ hasChildren: !!item.hasSubpages,
811
+ linkTypeClass: item.linkType ? `${this.settings.linkTypeClass}-${item.linkType}` : '',
812
+ isLinkedClass: item.isLinked ? this.settings.isLinkedClass : '',
813
+ uid: item.data.uid,
814
+ titleRaw: item.data.title,
815
+ title: item.title,
816
+ link: item.link,
817
+ target: item.target || '_self',
818
+ parentUid: item.data.pid,
819
+ parentTitle: parentItem?.title || '',
820
+ parentLink: parentItem?.link || '',
821
+ parentTarget: parentItem?.target || '_self',
822
+
823
+ ifIsLinkedStart: item.isLinked ? '' : '<!--',
824
+ ifIsLinkedEnd: item.isLinked ? '' : '-->',
825
+ ifIsNotLinkedStart: item.isLinked ? '<!--' : '',
826
+ ifIsNotLinkedEnd: item.isLinked ? '-->' : '',
827
+
828
+ ifHasChildrenStart: item.hasSubpages ? '' : '<!--',
829
+ ifHasChildrenEnd: item.hasSubpages ? '' : '-->',
830
+ ifHasNoChildrenStart: item.hasSubpages ? '<!--' : '',
831
+ ifHasNoChildrenEnd: item.hasSubpages ? '-->' : '',
832
+ };
833
+ }
834
+
835
+ /**
836
+ * Replaces placeholder tokens in the provided HTML string with corresponding values from the data object.
837
+ *
838
+ * @param {string} html The HTML string containing placeholders in the format %key%.
839
+ * @param {Object} data An object containing key-value pairs where keys correspond to the placeholders in the HTML string.
840
+ * @return {string} The HTML string with all matching placeholders replaced with their corresponding values,
841
+ * or an empty string for placeholders with no matching key in the data object.
842
+ */
843
+ replaceHtml(html, data) {
844
+ return html.replace(/%(\w+)%/g, (m, key) =>
845
+ Object.prototype.hasOwnProperty.call(data, key) ? data[key] : ''
846
+ );
847
+ }
848
+ }