@schukai/monster 4.43.4 → 4.43.5

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved.
2
+ * Copyright © schukai GmbH and all contributing authors, 2025. All rights reserved.
3
3
  * Node module: @schukai/monster
4
4
  *
5
5
  * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
@@ -13,20 +13,22 @@
13
13
  */
14
14
 
15
15
  import {
16
- CustomElement,
17
- getSlottedElements,
18
- registerCustomElement,
19
- assembleMethodSymbol,
16
+ CustomElement,
17
+ getSlottedElements,
18
+ registerCustomElement,
19
+ assembleMethodSymbol,
20
20
  } from "../../dom/customelement.mjs";
21
21
  import { DeadMansSwitch } from "../../util/deadmansswitch.mjs";
22
22
  import { SiteNavigationStyleSheet } from "./stylesheet/site-navigation.mjs";
23
23
  import {
24
- computePosition,
25
- autoUpdate,
26
- flip,
27
- shift,
28
- offset,
24
+ computePosition,
25
+ autoUpdate,
26
+ flip,
27
+ shift,
28
+ offset,
29
+ size,
29
30
  } from "@floating-ui/dom";
31
+ import { fireCustomEvent } from "../../dom/events.mjs";
30
32
 
31
33
  export { SiteNavigation };
32
34
 
@@ -39,421 +41,614 @@ const hamburgerButtonSymbol = Symbol("hamburgerButton");
39
41
  const hamburgerNavSymbol = Symbol("hamburgerNav");
40
42
  const instanceSymbol = Symbol("instanceSymbol");
41
43
  const activeSubmenuHiderSymbol = Symbol("activeSubmenuHider");
44
+ const hideHamburgerMenuSymbol = Symbol("hideHamburgerMenu");
45
+ const hamburgerCloseButtonSymbol = Symbol("hamburgerCloseButton");
42
46
 
43
47
  /**
44
- * Responsive site navigation component.
48
+ * A responsive site navigation that automatically moves menu items into a hamburger menu
49
+ * when there isn't enough available space.
45
50
  *
51
+ * @summary An adaptive navigation component that supports hover and click interactions for submenus.
46
52
  * @fragments /fragments/components/navigation/site-navigation/
47
53
  *
48
- * @example /examples/components/navigation/site-navigation-simple
54
+ * @example /examples/components/navigation/site-navigation-simple Simple Navigation
55
+ * @example /examples/components/navigation/site-navigation-with-submenus Navigation with Submenus
56
+ * @example /examples/components/navigation/site-navigation-with-mega-menu Navigation with Mega Menu
49
57
  *
50
- * @since 4.41.0
51
- * @copyright schukai GmbH
52
- * @summary Responsive site navigation with hamburger menu and submenus
58
+ * @issue https://localhost.alvine.dev:8440/development/issues/closed/336.html
59
+ *
60
+ * @fires monster-layout-change - Fired when the layout of menu items changes. The event detail contains `{visibleItems, hiddenItems}`.
61
+ * @fires monster-hamburger-show - Fired when the hamburger menu is shown. The event detail contains `{button, menu}`.
62
+ * @fires monster-hamburger-hide - Fired when the hamburger menu is hidden. The event detail contains `{button, menu}`.
63
+ * @fires monster-submenu-show - Fired when a submenu is shown. The event detail contains `{context, trigger, submenu, level}`.
64
+ * @fires monster-submenu-hide - Fired when a submenu is hidden. The event detail contains `{context, trigger, submenu, level}`.
53
65
  */
54
66
  class SiteNavigation extends CustomElement {
55
- /**
56
- * Returns a unique symbol for the instance.
57
- * @returns {symbol}
58
- */
59
- static get [instanceSymbol]() {
60
- return Symbol.for("@schukai/monster/components/navigation/site@@instance");
61
- }
62
-
63
- /**
64
- * Returns the default options for the component.
65
- * @returns {object}
66
- */
67
- get defaults() {
68
- return Object.assign({}, super.defaults, {
69
- templates: { main: getTemplate() },
70
- });
71
- }
72
-
73
- /**
74
- * Assembles the component and initializes controls and event handlers.
75
- * @returns {void}
76
- */
77
- [assembleMethodSymbol]() {
78
- super[assembleMethodSymbol]();
79
- initControlReferences.call(this);
80
- initEventHandler.call(this);
81
- }
82
-
83
- /**
84
- * Returns the CSS stylesheet for the component.
85
- * @returns {Array<CSSStyleSheet>}
86
- */
87
- static getCSSStyleSheet() {
88
- return [SiteNavigationStyleSheet];
89
- }
90
-
91
- /**
92
- * Returns the custom element tag name.
93
- * @returns {string}
94
- */
95
- static getTag() {
96
- return "monster-site-navigation";
97
- }
98
-
99
- /**
100
- * Called when the component is connected to the DOM.
101
- * @returns {void}
102
- */
103
- connectedCallback() {
104
- super.connectedCallback();
105
- attachResizeObserver.call(this);
106
- setTimeout(() => populateTabs.call(this), 0);
107
- }
108
-
109
- /**
110
- * Called when the component is disconnected from the DOM.
111
- * @returns {void}
112
- */
113
- disconnectedCallback() {
114
- super.disconnectedCallback();
115
- detachResizeObserver.call(this);
116
- }
67
+ static get [instanceSymbol]() {
68
+ return Symbol.for("@schukai/monster/components/navigation/site@@instance");
69
+ }
70
+
71
+ /**
72
+ * Configuration options for the SiteNavigation component.
73
+ * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
74
+ *
75
+ * To set these options via an HTML tag, use the `data-monster-options` attribute.
76
+ * The individual configuration values are detailed in the table below.
77
+ *
78
+ * @property {Object} templates - Template definitions.
79
+ * @property {string} templates.main - The main HTML template for the component.
80
+ * @property {string} interactionModel="auto" - Defines the interaction with submenus. Possible values: `auto`, `click`, `hover`. With `auto`, `hover` is used on desktop and `click` is used in the hamburger menu.
81
+ * @property {Object} features - Container for additional feature flags.
82
+ * @property {boolean} features.resetOnClose=true - If `true`, all open submenus within the hamburger menu will be reset when it is closed.
83
+ */
84
+ get defaults() {
85
+ return Object.assign({}, super.defaults, {
86
+ templates: { main: getTemplate() },
87
+ interactionModel: "auto", // 'auto', 'click', 'hover'
88
+ features: {
89
+ resetOnClose: true,
90
+ },
91
+ });
92
+ }
93
+
94
+ [assembleMethodSymbol]() {
95
+ super[assembleMethodSymbol]();
96
+ initControlReferences.call(this);
97
+ initEventHandler.call(this);
98
+ }
99
+
100
+ static getCSSStyleSheet() {
101
+ return [SiteNavigationStyleSheet];
102
+ }
103
+
104
+ static getTag() {
105
+ return "monster-site-navigation";
106
+ }
107
+
108
+ connectedCallback() {
109
+ super.connectedCallback();
110
+ attachResizeObserver.call(this);
111
+ setTimeout(() => populateTabs.call(this), 0);
112
+ }
113
+
114
+ disconnectedCallback() {
115
+ super.disconnectedCallback();
116
+ detachResizeObserver.call(this);
117
+ }
117
118
  }
118
119
 
119
120
  /**
120
- * Initializes references to important control elements.
121
+ * Queries the shadow DOM for essential elements and stores their references.
121
122
  * @private
122
123
  * @this {SiteNavigation}
123
- * @returns {void}
124
124
  */
125
125
  function initControlReferences() {
126
- if (!this.shadowRoot) throw new Error("Component requires a shadowRoot.");
127
- this[navElementSymbol] = this.shadowRoot.querySelector(
128
- '[data-monster-role="navigation"]',
129
- );
130
- this[visibleElementsSymbol] =
131
- this.shadowRoot.querySelector("#visible-elements");
132
- this[hiddenElementsSymbol] =
133
- this.shadowRoot.querySelector("#hidden-elements");
134
- this[hamburgerButtonSymbol] =
135
- this.shadowRoot.querySelector("#hamburger-button");
136
- this[hamburgerNavSymbol] = this.shadowRoot.querySelector(
137
- '[data-monster-role="hamburger-nav"]',
138
- );
126
+ if (!this.shadowRoot) throw new Error("Component requires a shadowRoot.");
127
+ this[navElementSymbol] = this.shadowRoot.querySelector(
128
+ '[data-monster-role="navigation"]',
129
+ );
130
+ this[visibleElementsSymbol] =
131
+ this.shadowRoot.querySelector("#visible-elements");
132
+ this[hiddenElementsSymbol] =
133
+ this.shadowRoot.querySelector("#hidden-elements");
134
+ this[hamburgerButtonSymbol] =
135
+ this.shadowRoot.querySelector("#hamburger-button");
136
+ this[hamburgerNavSymbol] = this.shadowRoot.querySelector(
137
+ '[data-monster-role="hamburger-nav"]',
138
+ );
139
+ this[hamburgerCloseButtonSymbol] = this.shadowRoot.querySelector(
140
+ '[part="hamburger-close-button"]',
141
+ );
139
142
  }
140
143
 
141
144
  /**
142
- * Initializes event handlers for hamburger menu and submenu interactions.
145
+ * Initializes event handlers for the hamburger menu button and its functionality.
143
146
  * @private
144
147
  * @this {SiteNavigation}
145
- * @returns {void}
146
148
  */
147
149
  function initEventHandler() {
148
- if (!this.shadowRoot) throw new Error("Component requires a shadowRoot.");
149
-
150
- const hamburgerButton = this[hamburgerButtonSymbol];
151
- const hamburgerNav = this[hamburgerNavSymbol];
152
- let cleanup;
153
-
154
- if (!hamburgerButton || !hamburgerNav) return;
155
-
156
- const showMenu = () => {
157
- hamburgerNav.style.display = "block";
158
- cleanup = autoUpdate(hamburgerButton, hamburgerNav, () => {
159
- computePosition(hamburgerButton, hamburgerNav, {
160
- placement: "bottom-end",
161
- middleware: [offset(8), flip(), shift({ padding: 8 })],
162
- }).then(({ x, y }) => {
163
- Object.assign(hamburgerNav.style, {
164
- left: `${x}px`,
165
- top: `${y}px`,
166
- });
167
- });
168
- });
169
- setTimeout(() => document.addEventListener("click", handleOutsideClick), 0);
170
- };
171
-
172
- const hideMenu = () => {
173
- hamburgerNav.style.display = "none";
174
- if (cleanup) {
175
- cleanup();
176
- }
177
- document.removeEventListener("click", handleOutsideClick);
178
- };
179
-
180
- const handleOutsideClick = (event) => {
181
- if (
182
- !hamburgerButton.contains(event.target) &&
183
- !hamburgerNav.contains(event.target)
184
- ) {
185
- hideMenu();
186
- }
187
- };
188
-
189
- hamburgerButton.addEventListener("click", (event) => {
190
- event.stopPropagation();
191
- const isVisible = hamburgerNav.style.display === "block";
192
- if (isVisible) {
193
- hideMenu();
194
- } else {
195
- showMenu();
196
- }
197
- });
150
+ if (!this.shadowRoot) throw new Error("Component requires a shadowRoot.");
151
+
152
+ const hamburgerButton = this[hamburgerButtonSymbol];
153
+ const hamburgerNav = this[hamburgerNavSymbol];
154
+ const hamburgerCloseButton = this[hamburgerCloseButtonSymbol];
155
+ let cleanup;
156
+
157
+ if (!hamburgerButton || !hamburgerNav || !hamburgerCloseButton) return;
158
+
159
+ const handleOutsideClick = (event) => {
160
+ if (
161
+ !hamburgerButton.contains(event.target) &&
162
+ !hamburgerNav.contains(event.target)
163
+ ) {
164
+ hideMenu();
165
+ }
166
+ };
167
+
168
+ const hideMenu = () => {
169
+ hamburgerNav.style.display = "none";
170
+ document.body.classList.remove("monster-navigation-open");
171
+
172
+ fireCustomEvent(this, "monster-hamburger-hide", {
173
+ button: hamburgerButton,
174
+ menu: hamburgerNav,
175
+ });
176
+
177
+ if (this.getOption("features.resetOnClose") === true) {
178
+ this[hiddenElementsSymbol]
179
+ .querySelectorAll(".is-open")
180
+ .forEach((submenu) => submenu.classList.remove("is-open"));
181
+ }
182
+
183
+ if (cleanup) {
184
+ cleanup();
185
+ cleanup = undefined;
186
+ }
187
+ document.removeEventListener("click", handleOutsideClick);
188
+ };
189
+
190
+ this[hideHamburgerMenuSymbol] = hideMenu;
191
+
192
+ const showMenu = () => {
193
+ this[activeSubmenuHiderSymbol]?.();
194
+ hamburgerNav.style.display = "block";
195
+ document.body.classList.add("monster-navigation-open");
196
+
197
+ fireCustomEvent(this, "monster-hamburger-show", {
198
+ button: hamburgerButton,
199
+ menu: hamburgerNav,
200
+ });
201
+
202
+ cleanup = autoUpdate(hamburgerButton, hamburgerNav, () => {
203
+ // Desktop view where the hamburger menu is a dropdown
204
+ if (window.innerWidth > 768) {
205
+ computePosition(hamburgerButton, hamburgerNav, {
206
+ placement: "bottom-end",
207
+ strategy: "fixed",
208
+ middleware: [
209
+ offset(8),
210
+ flip(),
211
+ shift({ padding: 8 }),
212
+ size({
213
+ apply: ({ availableHeight, elements }) => {
214
+ Object.assign(elements.floating.style, {
215
+ maxHeight: `${availableHeight}px`,
216
+ overflowY: "auto",
217
+ });
218
+ },
219
+ padding: 8,
220
+ }),
221
+ ],
222
+ }).then(({ x, y, strategy }) => {
223
+ Object.assign(hamburgerNav.style, {
224
+ position: strategy,
225
+ left: `${x}px`,
226
+ top: `${y}px`,
227
+ });
228
+ });
229
+ } else {
230
+ // Mobile view (fullscreen overlay), position is handled by CSS
231
+ Object.assign(hamburgerNav.style, { position: "", left: "", top: "" });
232
+ }
233
+ });
234
+ setTimeout(() => document.addEventListener("click", handleOutsideClick), 0);
235
+ };
236
+
237
+ hamburgerButton.addEventListener("click", (event) => {
238
+ event.stopPropagation();
239
+ const isVisible = hamburgerNav.style.display === "block";
240
+ if (isVisible) {
241
+ hideMenu();
242
+ } else {
243
+ showMenu();
244
+ }
245
+ });
246
+
247
+ hamburgerCloseButton.addEventListener("click", (event) => {
248
+ event.stopPropagation();
249
+ hideMenu();
250
+ });
198
251
  }
199
252
 
200
253
  /**
201
- * Attaches a ResizeObserver to re-calculate tabs when the component's size changes.
254
+ * Attaches a ResizeObserver to the main navigation element to recalculate
255
+ * tab distribution on size changes. A DeadMansSwitch is used for debouncing.
202
256
  * @private
203
257
  * @this {SiteNavigation}
204
- * @returns {void}
205
258
  */
206
259
  function attachResizeObserver() {
207
- this[resizeObserverSymbol] = new ResizeObserver(() => {
208
- if (this[timerCallbackSymbol] instanceof DeadMansSwitch) {
209
- try {
210
- this[timerCallbackSymbol].touch();
211
- return;
212
- } catch (e) {
213
- delete this[timerCallbackSymbol];
214
- }
215
- }
216
- this[timerCallbackSymbol] = new DeadMansSwitch(200, () =>
217
- populateTabs.call(this),
218
- );
219
- });
220
- this[resizeObserverSymbol].observe(this[navElementSymbol]);
260
+ this[resizeObserverSymbol] = new ResizeObserver(() => {
261
+ if (this[timerCallbackSymbol] instanceof DeadMansSwitch) {
262
+ try {
263
+ this[timerCallbackSymbol].touch();
264
+ return;
265
+ } catch (e) {
266
+ delete this[timerCallbackSymbol];
267
+ }
268
+ }
269
+ this[timerCallbackSymbol] = new DeadMansSwitch(200, () =>
270
+ populateTabs.call(this),
271
+ );
272
+ });
273
+ this[resizeObserverSymbol].observe(this[navElementSymbol]);
221
274
  }
222
275
 
223
276
  /**
224
- * Detaches the ResizeObserver when the component is removed from the DOM.
277
+ * Disconnects and cleans up the ResizeObserver instance.
225
278
  * @private
226
279
  * @this {SiteNavigation}
227
- * @returns {void}
228
280
  */
229
281
  function detachResizeObserver() {
230
- if (this[resizeObserverSymbol] instanceof ResizeObserver) {
231
- this[resizeObserverSymbol].disconnect();
232
- delete this[resizeObserverSymbol];
233
- }
282
+ if (this[resizeObserverSymbol] instanceof ResizeObserver) {
283
+ this[resizeObserverSymbol].disconnect();
284
+ delete this[resizeObserverSymbol];
285
+ }
234
286
  }
235
287
 
236
288
  /**
237
- * Sets up a submenu based on its context (visible nav or hidden hamburger menu).
289
+ * Sets up interaction logic (hover or click) for a submenu.
290
+ * This function is called recursively for nested submenus.
238
291
  * @private
239
292
  * @this {SiteNavigation}
240
- * @param {HTMLElement} parentLi The list item that contains the submenu.
241
- * @param {'visible' | 'hidden'} context The context in which the submenu appears.
242
- * @returns {void}
293
+ * @param {HTMLLIElement} parentLi The list item containing the submenu.
294
+ * @param {'visible'|'hidden'} context The context (main nav or hamburger).
295
+ * @param {number} level The nesting level of the submenu (starts at 1).
296
+ */
297
+ function setupSubmenu(parentLi, context = "visible", level = 1) {
298
+ const submenu = parentLi.querySelector(
299
+ ":scope > ul, :scope > div[part='mega-menu']",
300
+ );
301
+ if (!submenu) return;
302
+
303
+ if (submenu.tagName === "UL") {
304
+ submenu.setAttribute("part", "submenu");
305
+ }
306
+
307
+ const interaction = this.getOption("interactionModel", "auto");
308
+ const useHover =
309
+ interaction === "hover" ||
310
+ (interaction === "auto" && context === "visible");
311
+
312
+ if (useHover) {
313
+ const component = this;
314
+ let cleanup;
315
+ let hideTimeout;
316
+
317
+ const immediateHide = () => {
318
+ submenu.style.display = "none";
319
+ submenu
320
+ .querySelectorAll(
321
+ "ul[style*='display: block'], div[part='mega-menu'][style*='display: block']",
322
+ )
323
+ .forEach((sub) => {
324
+ sub.style.display = "none";
325
+ });
326
+
327
+ fireCustomEvent(this, "monster-submenu-hide", {
328
+ context,
329
+ trigger: parentLi,
330
+ submenu,
331
+ level,
332
+ });
333
+
334
+ if (cleanup) {
335
+ cleanup();
336
+ cleanup = null;
337
+ }
338
+
339
+ if (
340
+ level === 1 &&
341
+ component[activeSubmenuHiderSymbol] === immediateHide
342
+ ) {
343
+ component[activeSubmenuHiderSymbol] = null;
344
+ }
345
+ };
346
+
347
+ const show = () => {
348
+ component[hideHamburgerMenuSymbol]?.();
349
+ clearTimeout(hideTimeout);
350
+
351
+ if (level === 1) {
352
+ if (
353
+ component[activeSubmenuHiderSymbol] &&
354
+ component[activeSubmenuHiderSymbol] !== immediateHide
355
+ ) {
356
+ component[activeSubmenuHiderSymbol]();
357
+ }
358
+ component[activeSubmenuHiderSymbol] = immediateHide;
359
+ } else {
360
+ [...parentLi.parentElement.children]
361
+ .filter((li) => li !== parentLi)
362
+ .forEach((sibling) => {
363
+ const siblingSubmenu = sibling.querySelector(
364
+ ":scope > ul, :scope > div[part='mega-menu']",
365
+ );
366
+ if (siblingSubmenu) {
367
+ siblingSubmenu.style.display = "none";
368
+ }
369
+ });
370
+ }
371
+
372
+ submenu.style.display = "block";
373
+ fireCustomEvent(this, "monster-submenu-show", {
374
+ context,
375
+ trigger: parentLi,
376
+ submenu,
377
+ level,
378
+ });
379
+
380
+ if (level === 1 && !cleanup) {
381
+ cleanup = autoUpdate(parentLi, submenu, () => {
382
+ computePosition(parentLi, submenu, {
383
+ placement: "bottom-start",
384
+ strategy: "fixed",
385
+ middleware: [
386
+ offset(8),
387
+ flip({ fallbackPlacements: ["top-start"] }),
388
+ shift({ padding: 8 }),
389
+ size({
390
+ apply: ({ availableHeight, elements }) => {
391
+ Object.assign(elements.floating.style, {
392
+ maxHeight: `${availableHeight}px`,
393
+ overflowY: "auto",
394
+ });
395
+ },
396
+ padding: 8,
397
+ }),
398
+ ],
399
+ }).then(({ x, y, strategy }) => {
400
+ Object.assign(submenu.style, {
401
+ position: strategy,
402
+ left: `${x}px`,
403
+ top: `${y}px`,
404
+ });
405
+ });
406
+ });
407
+ }
408
+ };
409
+
410
+ const hideWithDelay = () => {
411
+ hideTimeout = setTimeout(immediateHide, 200);
412
+ };
413
+
414
+ parentLi.addEventListener("mouseenter", show);
415
+ parentLi.addEventListener("mouseleave", hideWithDelay);
416
+ submenu.addEventListener("mouseenter", () => clearTimeout(hideTimeout));
417
+ submenu.addEventListener("mouseleave", hideWithDelay);
418
+ } else {
419
+ // Click behavior
420
+ const anchor = parentLi.querySelector(":scope > a");
421
+ if (anchor) {
422
+ anchor.addEventListener("click", (event) => {
423
+ event.preventDefault();
424
+ event.stopPropagation();
425
+
426
+ if (!submenu.classList.contains("is-open")) {
427
+ [...parentLi.parentElement.children]
428
+ .filter((li) => li !== parentLi)
429
+ .forEach((sibling) => {
430
+ const siblingSubmenu = sibling.querySelector(
431
+ ":scope > ul, :scope > div[part='mega-menu']",
432
+ );
433
+ if (siblingSubmenu) {
434
+ siblingSubmenu.classList.remove("is-open");
435
+ }
436
+ });
437
+ }
438
+
439
+ const isOpen = submenu.classList.toggle("is-open");
440
+ const eventName = isOpen
441
+ ? "monster-submenu-show"
442
+ : "monster-submenu-hide";
443
+ fireCustomEvent(this, eventName, {
444
+ context,
445
+ trigger: parentLi,
446
+ submenu,
447
+ level,
448
+ });
449
+ });
450
+ }
451
+ }
452
+
453
+ // Recursive call for nested lists
454
+ if (submenu.tagName === "UL") {
455
+ submenu
456
+ .querySelectorAll(":scope > li")
457
+ .forEach((li) => setupSubmenu.call(this, li, context, level + 1));
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Creates a clone of a navigation list item, setting appropriate part attributes
463
+ * for styling and handling active states.
464
+ * @private
465
+ * @param {HTMLLIElement} item The original list item to clone.
466
+ * @returns {HTMLLIElement} The cloned and configured list item.
243
467
  */
244
- function setupSubmenu(parentLi, context = "visible") {
245
- const submenu = parentLi.querySelector("ul");
246
- if (!submenu) return;
247
-
248
- submenu.setAttribute("part", "submenu");
249
-
250
- if (context === "visible") {
251
- const component = this;
252
- let cleanup;
253
- let hideTimeout;
254
-
255
- const immediateHide = () => {
256
- submenu.style.display = "none";
257
- if (cleanup) {
258
- cleanup();
259
- cleanup = null;
260
- }
261
- if (component[activeSubmenuHiderSymbol] === immediateHide) {
262
- component[activeSubmenuHiderSymbol] = null;
263
- }
264
- };
265
-
266
- const show = () => {
267
- clearTimeout(hideTimeout);
268
- if (
269
- component[activeSubmenuHiderSymbol] &&
270
- component[activeSubmenuHiderSymbol] !== immediateHide
271
- ) {
272
- component[activeSubmenuHiderSymbol]();
273
- }
274
- submenu.style.display = "block";
275
- if (!cleanup) {
276
- cleanup = autoUpdate(parentLi, submenu, () => {
277
- computePosition(parentLi, submenu, {
278
- placement: "bottom-start",
279
- middleware: [offset(8), flip(), shift({ padding: 8 })],
280
- }).then(({ x, y }) => {
281
- Object.assign(submenu.style, { left: `${x}px`, top: `${y}px` });
282
- });
283
- });
284
- }
285
- component[activeSubmenuHiderSymbol] = immediateHide;
286
- };
287
-
288
- const hideWithDelay = () => {
289
- hideTimeout = setTimeout(immediateHide, 200);
290
- };
291
-
292
- parentLi.addEventListener("mouseenter", show);
293
- parentLi.addEventListener("mouseleave", hideWithDelay);
294
- submenu.addEventListener("mouseenter", () => clearTimeout(hideTimeout));
295
- submenu.addEventListener("mouseleave", hideWithDelay);
296
- } else {
297
- submenu.style.display = "none";
298
- const anchor = parentLi.querySelector("a");
299
- if (anchor) {
300
- anchor.addEventListener("click", (event) => {
301
- event.preventDefault();
302
- event.stopPropagation();
303
- const isVisible = submenu.style.display === "block";
304
- submenu.style.display = isVisible ? "none" : "block";
305
- });
306
- }
307
- }
468
+ function cloneNavItem(item) {
469
+ const liClone = item.cloneNode(true);
470
+ const aClone = liClone.querySelector("a");
471
+ let navItemPart = "nav-item";
472
+ let navLinkPart = "nav-link";
473
+
474
+ if (item.classList.contains("active")) {
475
+ navItemPart += " nav-item-active";
476
+ if (aClone) navLinkPart += " nav-link-active";
477
+ }
478
+
479
+ liClone.setAttribute("part", navItemPart);
480
+ if (aClone) aClone.setAttribute("part", navLinkPart);
481
+
482
+ return liClone;
308
483
  }
309
484
 
310
485
  /**
311
- * Calculates which navigation items fit in the visible area and moves the rest
312
- * to a hidden "hamburger" menu.
486
+ * Measures available space and distributes slotted navigation items between
487
+ * the visible list and the hidden hamburger menu list.
313
488
  * @private
314
489
  * @this {SiteNavigation}
315
- * @returns {void}
316
490
  */
317
491
  function populateTabs() {
318
- const visibleList = this[visibleElementsSymbol];
319
- const hiddenList = this[hiddenElementsSymbol];
320
- const hamburgerButton = this[hamburgerButtonSymbol];
321
- const hamburgerNav = this[hamburgerNavSymbol];
322
-
323
- const topLevelUl = [...getSlottedElements.call(this, "ul")].find(
324
- (ul) => ul.parentElement === this,
325
- );
326
-
327
- if (!topLevelUl) {
328
- visibleList.style.visibility = "visible";
329
- hamburgerButton.style.display = "none";
330
- return;
331
- }
332
-
333
- const sourceItems = Array.from(topLevelUl.children).filter(
334
- (item) => item.tagName === "LI",
335
- );
336
-
337
- visibleList.style.visibility = "hidden";
338
- hamburgerButton.style.display = "none";
339
- hamburgerNav.style.display = "none";
340
- visibleList.innerHTML = "";
341
- hiddenList.innerHTML = "";
342
-
343
- const originalDisplay = hamburgerButton.style.display;
344
- hamburgerButton.style.visibility = "hidden";
345
- hamburgerButton.style.display = "flex";
346
- const hamburgerWidth = hamburgerButton.offsetWidth;
347
- hamburgerButton.style.display = originalDisplay;
348
- hamburgerButton.style.visibility = "visible";
349
-
350
- const navWidth = this[navElementSymbol].clientWidth;
351
- if (navWidth === 0) {
352
- visibleList.style.visibility = "visible";
353
- return;
354
- }
355
-
356
- const measurementList = visibleList.cloneNode(true);
357
- Object.assign(measurementList.style, {
358
- position: "absolute",
359
- left: "0",
360
- top: "0",
361
- width: "auto",
362
- visibility: "hidden",
363
- });
364
- this.shadowRoot.appendChild(measurementList);
365
-
366
- const itemsToMove = [];
367
- const availableWidthForTabs = navWidth - hamburgerWidth;
368
-
369
- for (let i = 0; i < sourceItems.length; i++) {
370
- const item = sourceItems[i];
371
- const itemClone = item.cloneNode(true);
372
- const submenu = itemClone.querySelector("ul");
373
- if (submenu) submenu.style.display = "none";
374
-
375
- measurementList.appendChild(itemClone);
376
-
377
- const isLastItem = i === sourceItems.length - 1;
378
- const effectiveMaxWidth = isLastItem ? navWidth : availableWidthForTabs;
379
-
380
- if (measurementList.scrollWidth > effectiveMaxWidth) {
381
- break;
382
- } else {
383
- itemsToMove.push(item);
384
- }
385
- }
386
- this.shadowRoot.removeChild(measurementList);
387
-
388
- const visibleItems = itemsToMove;
389
- const hiddenItems = sourceItems.slice(visibleItems.length);
390
-
391
- if (visibleItems.length > 0) {
392
- const clonedVisibleItems = visibleItems.map((item) => {
393
- const liClone = item.cloneNode(true);
394
- const aClone = liClone.querySelector("a");
395
-
396
- liClone.setAttribute("part", "nav-item");
397
- if (aClone) aClone.setAttribute("part", "nav-link");
398
-
399
- if (item.classList.contains("active")) {
400
- liClone.setAttribute("part", "nav-item nav-item-active");
401
- if (aClone) aClone.setAttribute("part", "nav-link nav-link-active");
402
- }
403
- return liClone;
404
- });
405
- visibleList.append(...clonedVisibleItems);
406
- visibleList
407
- .querySelectorAll("li")
408
- .forEach((li) => setupSubmenu.call(this, li, "visible"));
409
- }
410
-
411
- if (hiddenItems.length > 0) {
412
- const clonedHiddenItems = hiddenItems.map((item) => {
413
- const liClone = item.cloneNode(true);
414
- const aClone = liClone.querySelector("a");
415
-
416
- liClone.setAttribute("part", "nav-item");
417
- if (aClone) aClone.setAttribute("part", "nav-link");
418
- if (item.classList.contains("active")) {
419
- liClone.setAttribute("part", "nav-item nav-item-active");
420
- if (aClone) aClone.setAttribute("part", "nav-link nav-link-active");
421
- }
422
- return liClone;
423
- });
424
- hiddenList.append(...clonedHiddenItems);
425
- hamburgerButton.style.display = "flex";
426
- hiddenList
427
- .querySelectorAll("li")
428
- .forEach((li) => setupSubmenu.call(this, li, "hidden"));
429
- }
430
-
431
- visibleList.style.visibility = "visible";
492
+ const visibleList = this[visibleElementsSymbol];
493
+ const hiddenList = this[hiddenElementsSymbol];
494
+ const hamburgerButton = this[hamburgerButtonSymbol];
495
+ const hamburgerNav = this[hamburgerNavSymbol];
496
+ const topLevelUl = [...getSlottedElements.call(this, "ul")].find(
497
+ (ul) => ul.parentElement === this,
498
+ );
499
+
500
+ if (!topLevelUl) {
501
+ visibleList.style.visibility = "visible";
502
+ hamburgerButton.style.display = "none";
503
+ return;
504
+ }
505
+
506
+ const sourceItems = Array.from(topLevelUl.children).filter(
507
+ (item) => item.tagName === "LI",
508
+ );
509
+
510
+ visibleList.style.visibility = "hidden";
511
+ hamburgerButton.style.display = "none";
512
+ hamburgerNav.style.display = "none";
513
+ visibleList.innerHTML = "";
514
+ hiddenList.innerHTML = "";
515
+
516
+ const originalDisplay = hamburgerButton.style.display;
517
+ hamburgerButton.style.visibility = "hidden";
518
+ hamburgerButton.style.display = "flex";
519
+ const hamburgerWidth = hamburgerButton.offsetWidth;
520
+ hamburgerButton.style.display = originalDisplay;
521
+ hamburgerButton.style.visibility = "visible";
522
+
523
+ const navWidth = this[navElementSymbol].clientWidth;
524
+ if (navWidth === 0) {
525
+ visibleList.style.visibility = "visible";
526
+ return;
527
+ }
528
+
529
+ const measurementList = visibleList.cloneNode(true);
530
+ Object.assign(measurementList.style, {
531
+ position: "absolute",
532
+ left: "0",
533
+ top: "0",
534
+ width: "auto",
535
+ visibility: "hidden",
536
+ });
537
+ this.shadowRoot.appendChild(measurementList);
538
+
539
+ const itemsToMove = [];
540
+ const availableWidthForTabs = navWidth - hamburgerWidth;
541
+
542
+ for (let i = 0; i < sourceItems.length; i++) {
543
+ const item = sourceItems[i];
544
+ const itemClone = item.cloneNode(true);
545
+ const submenu = itemClone.querySelector("ul, div[part='mega-menu']");
546
+ if (submenu) submenu.style.display = "none";
547
+
548
+ measurementList.appendChild(itemClone);
549
+ const isLastItem = i === sourceItems.length - 1;
550
+ const effectiveMaxWidth = isLastItem ? navWidth : availableWidthForTabs;
551
+
552
+ if (measurementList.scrollWidth > effectiveMaxWidth) {
553
+ break;
554
+ } else {
555
+ itemsToMove.push(item);
556
+ }
557
+ }
558
+ this.shadowRoot.removeChild(measurementList);
559
+
560
+ const visibleItems = itemsToMove;
561
+ const hiddenItems = sourceItems.slice(visibleItems.length);
562
+
563
+ if (visibleItems.length > 0) {
564
+ const clonedVisibleItems = visibleItems.map(cloneNavItem);
565
+ visibleList.append(...clonedVisibleItems);
566
+ visibleList
567
+ .querySelectorAll(":scope > li")
568
+ .forEach((li) => setupSubmenu.call(this, li, "visible", 1));
569
+ }
570
+
571
+ if (hiddenItems.length > 0) {
572
+ const clonedHiddenItems = hiddenItems.map(cloneNavItem);
573
+ hiddenList.append(...clonedHiddenItems);
574
+ hamburgerButton.style.display = "flex";
575
+ hiddenList
576
+ .querySelectorAll(":scope > li")
577
+ .forEach((li) => setupSubmenu.call(this, li, "hidden", 1));
578
+ }
579
+
580
+ visibleList.style.visibility = "visible";
581
+ fireCustomEvent(this, "monster-layout-change", { visibleItems, hiddenItems });
582
+ }
583
+
584
+ /**
585
+ * A simple template literal tag function for clarity.
586
+ * @private
587
+ * @param {TemplateStringsArray} strings
588
+ * @returns {string} The combined string.
589
+ */
590
+ function html(strings) {
591
+ return strings.join("");
432
592
  }
433
593
 
434
594
  /**
435
595
  * Returns the HTML template for the component's shadow DOM.
436
596
  * @private
437
- * @returns {string}
597
+ * @returns {string} The HTML template string.
438
598
  */
439
599
  function getTemplate() {
440
- return `
441
- <div data-monster-role="control" part="control">
442
- <nav data-monster-role="navigation" role="navigation" part="nav">
443
- <ul id="visible-elements" part="visible-list"></ul>
444
- </nav>
445
- <div data-monster-role="hamburger-container" part="hamburger-container">
446
- <button id="hamburger-button" part="hamburger-button" aria-label="More navigation items">
447
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
448
- <path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5"/>
600
+ return html`<div data-monster-role="control" part="control">
601
+ <nav data-monster-role="navigation" role="navigation" part="nav">
602
+ <ul id="visible-elements" part="visible-list"></ul>
603
+ </nav>
604
+ <div data-monster-role="hamburger-container" part="hamburger-container">
605
+ <button
606
+ id="hamburger-button"
607
+ part="hamburger-button"
608
+ aria-label="More navigation items"
609
+ >
610
+ <svg
611
+ xmlns="http://www.w3.org/2000/svg"
612
+ width="16"
613
+ height="16"
614
+ fill="currentColor"
615
+ viewBox="0 0 16 16"
616
+ >
617
+ <path
618
+ fill-rule="evenodd"
619
+ d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5"
620
+ />
621
+ </svg>
622
+ </button>
623
+ <nav
624
+ data-monster-role="hamburger-nav"
625
+ role="navigation"
626
+ part="hamburger-nav"
627
+ style="display: none;"
628
+ >
629
+ <div part="hamburger-header">
630
+ <button part="hamburger-close-button" aria-label="Close navigation">
631
+ <svg
632
+ xmlns="http://www.w3.org/2000/svg"
633
+ width="24"
634
+ height="24"
635
+ viewBox="0 0 24 24"
636
+ fill="none"
637
+ stroke="currentColor"
638
+ stroke-width="2"
639
+ stroke-linecap="round"
640
+ stroke-linejoin="round"
641
+ >
642
+ <line x1="18" y1="6" x2="6" y2="18"></line>
643
+ <line x1="6" y1="6" x2="18" y2="18"></line>
449
644
  </svg>
450
645
  </button>
451
- <nav data-monster-role="hamburger-nav" role="navigation" part="hamburger-nav" style="display: none;">
452
- <ul id="hidden-elements" part="hidden-list"></ul>
453
- </nav>
454
- </div>
455
- <slot class="hidden-slot" style="display: none;"></slot>
456
- </div>`;
646
+ </div>
647
+ <ul id="hidden-elements" part="hidden-list"></ul>
648
+ </nav>
649
+ </div>
650
+ <slot class="hidden-slot" style="display: none;"></slot>
651
+ </div>`;
457
652
  }
458
653
 
459
654
  registerCustomElement(SiteNavigation);