@schukai/monster 4.62.0 → 4.64.0

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,1319 @@
1
+ /**
2
+ * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
3
+ * Node module: @schukai/monster
4
+ *
5
+ * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
6
+ * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
7
+ *
8
+ * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
9
+ * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
10
+ * For more information about purchasing a commercial license, please contact Volker Schukai.
11
+ *
12
+ * SPDX-License-Identifier: AGPL-3.0
13
+ */
14
+
15
+ import { instanceSymbol } from "../../constants.mjs";
16
+ import { addAttributeToken } from "../../dom/attributes.mjs";
17
+ import {
18
+ ATTRIBUTE_ERRORMESSAGE,
19
+ ATTRIBUTE_ROLE,
20
+ ATTRIBUTE_UPDATER_INSERT_REFERENCE,
21
+ } from "../../dom/constants.mjs";
22
+ import {
23
+ assembleMethodSymbol,
24
+ CustomElement,
25
+ registerCustomElement,
26
+ } from "../../dom/customelement.mjs";
27
+ import { findTargetElementFromEvent } from "../../dom/events.mjs";
28
+ import { fireCustomEvent } from "../../dom/events.mjs";
29
+ import { isFunction, isString } from "../../types/is.mjs";
30
+ import { ID } from "../../types/id.mjs";
31
+ import { CommonStyleSheet } from "../stylesheet/common.mjs";
32
+ import { TreeMenuStyleSheet } from "./stylesheet/tree-menu.mjs";
33
+ import { ATTRIBUTE_INTEND } from "./../constants.mjs";
34
+
35
+ export { HtmlTreeMenu };
36
+
37
+ /**
38
+ * @private
39
+ * @type {symbol}
40
+ */
41
+ const entryIndexSymbol = Symbol("entryIndex");
42
+
43
+ /**
44
+ * @private
45
+ * @type {symbol}
46
+ */
47
+ const openEntryEventHandlerSymbol = Symbol("openEntryEventHandler");
48
+
49
+ /**
50
+ * HtmlTreeMenu
51
+ *
52
+ * @fragments /fragments/components/tree-menu/html-tree-menu/
53
+ *
54
+ * @example /examples/components/tree-menu/html-tree-menu-simple Basic HTML tree menu
55
+ * @example /examples/components/tree-menu/html-tree-menu-lazy Lazy loading
56
+ *
57
+ * @since 4.62.0
58
+ * @summary A TreeMenu control that builds its entries from nested HTML lists.
59
+ * @fires entries-imported
60
+ */
61
+ class HtmlTreeMenu extends CustomElement {
62
+ /**
63
+ * This method is called by the `instanceof` operator.
64
+ * @return {symbol}
65
+ */
66
+ static get [instanceSymbol]() {
67
+ return Symbol.for("@schukai/monster/components/tree-menu/html@@instance");
68
+ }
69
+
70
+ /**
71
+ * @property {Object} templates Template definitions
72
+ * @property {string} templates.main Main template
73
+ * @property {Object} classes
74
+ * @property {String} classes.control the class for the control element
75
+ * @property {String} classes.label the class for the label element
76
+ * @property {Object} lazy
77
+ * @property {boolean} lazy.enabled enables lazy loading by endpoint
78
+ * @property {string} lazy.attribute="data-monster-endpoint" attribute for the endpoint
79
+ * @property {Object} lazy.fetchOptions fetch options for lazy requests
80
+ * @property {Object} features
81
+ * @property {boolean} features.selectParents=false allow selecting entries with children
82
+ * @property {Object} actions
83
+ * @property {Function} actions.open the action to open an entry (entry, index, event)
84
+ * @property {Function} actions.close the action to close an entry (entry, index, event)
85
+ * @property {Function} actions.select the action to select an entry (entry, index, event)
86
+ * @property {Function} actions.onexpand the action to expand an entry (entry, index, event)
87
+ * @property {Function} actions.oncollapse the action to collapse an entry (entry, index, event)
88
+ * @property {Function} actions.onselect the action to select an entry (entry, index, event)
89
+ * @property {Function} actions.onnavigate the action to navigate (entry, index, event)
90
+ * @property {Function} actions.onlazyload the action before lazy load (entry, index, event)
91
+ * @property {Function} actions.onlazyloaded the action after lazy load (entry, index, event)
92
+ * @property {Function} actions.onlazyerror the action on lazy error (entry, index, event)
93
+ */
94
+ get defaults() {
95
+ return Object.assign({}, super.defaults, {
96
+ classes: {
97
+ control: "monster-theme-primary-1",
98
+ label: "monster-theme-primary-1",
99
+ },
100
+ lazy: {
101
+ enabled: true,
102
+ attribute: "data-monster-endpoint",
103
+ fetchOptions: {
104
+ method: "GET",
105
+ },
106
+ },
107
+ features: {
108
+ selectParents: false,
109
+ },
110
+ templates: {
111
+ main: getTemplate(),
112
+ },
113
+ actions: {
114
+ open: null,
115
+ close: null,
116
+ select: (entry) => {
117
+ console.warn("select action is not defined", entry);
118
+ },
119
+ onexpand: null,
120
+ oncollapse: null,
121
+ onselect: null,
122
+ onnavigate: null,
123
+ onlazyload: null,
124
+ onlazyloaded: null,
125
+ onlazyerror: null,
126
+ },
127
+ entries: [],
128
+ });
129
+ }
130
+
131
+ /**
132
+ * @return {void}
133
+ */
134
+ [assembleMethodSymbol]() {
135
+ super[assembleMethodSymbol]();
136
+ this[entryIndexSymbol] = new Map();
137
+ initEventHandler.call(this);
138
+ importEntries.call(this);
139
+ }
140
+
141
+ /**
142
+ * @return {CSSStyleSheet[]}
143
+ */
144
+ static getCSSStyleSheet() {
145
+ return [CommonStyleSheet, TreeMenuStyleSheet];
146
+ }
147
+
148
+ /**
149
+ * @return {string}
150
+ */
151
+ static getTag() {
152
+ return "monster-html-tree-menu";
153
+ }
154
+
155
+ /**
156
+ * Select an entry by value.
157
+ *
158
+ * @param {string} value
159
+ * @return {void}
160
+ */
161
+ selectEntry(value) {
162
+ this.shadowRoot
163
+ .querySelectorAll("[data-monster-role=entry]")
164
+ .forEach((entry) => {
165
+ entry.classList.remove("selected");
166
+ });
167
+
168
+ value = String(value);
169
+ const index = findEntryIndex.call(this, value);
170
+ if (index === -1) {
171
+ return;
172
+ }
173
+
174
+ const currentNode = this.shadowRoot.querySelector(
175
+ "[data-monster-insert-reference=entries-" + index + "]",
176
+ );
177
+
178
+ if (!currentNode) {
179
+ return;
180
+ }
181
+
182
+ const allowParentSelect = this.getOption("features.selectParents") === true;
183
+ if (currentEntry?.["has-children"] === true && allowParentSelect) {
184
+ applySelection.call(this, currentEntry, Number(index), currentNode);
185
+ return;
186
+ }
187
+
188
+ currentNode.click();
189
+ }
190
+
191
+ /**
192
+ * Find an entry by value.
193
+ *
194
+ * @param {string} value
195
+ * @return {Object|null}
196
+ */
197
+ findEntry(value) {
198
+ const index = findEntryIndex.call(this, String(value));
199
+ if (index === -1) {
200
+ return null;
201
+ }
202
+ return {
203
+ entry: this.getOption("entries." + index),
204
+ index,
205
+ node: getEntryNode.call(this, index),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Open a node by value.
211
+ *
212
+ * @param {string} value
213
+ * @return {void}
214
+ */
215
+ openEntry(value) {
216
+ toggleEntryState.call(this, String(value), "open");
217
+ }
218
+
219
+ /**
220
+ * Expand a node and all its descendants by value.
221
+ *
222
+ * @param {string} value
223
+ * @return {void}
224
+ */
225
+ expandEntry(value) {
226
+ expandEntry.call(this, String(value));
227
+ }
228
+
229
+ /**
230
+ * Collapse a node and all its descendants by value.
231
+ *
232
+ * @param {string} value
233
+ * @return {void}
234
+ */
235
+ collapseEntry(value) {
236
+ collapseEntry.call(this, String(value));
237
+ }
238
+
239
+ /**
240
+ * Close a node by value.
241
+ *
242
+ * @param {string} value
243
+ * @return {void}
244
+ */
245
+ closeEntry(value) {
246
+ toggleEntryState.call(this, String(value), "close");
247
+ }
248
+
249
+ /**
250
+ * Show a node by value.
251
+ *
252
+ * @param {string} value
253
+ * @return {void}
254
+ */
255
+ showEntry(value) {
256
+ setEntryVisibility.call(this, String(value), "visible");
257
+ }
258
+
259
+ /**
260
+ * Hide a node by value.
261
+ *
262
+ * @param {string} value
263
+ * @return {void}
264
+ */
265
+ hideEntry(value) {
266
+ setEntryVisibility.call(this, String(value), "hidden");
267
+ }
268
+
269
+ /**
270
+ * Remove a node by value.
271
+ *
272
+ * @param {string} value
273
+ * @return {void}
274
+ */
275
+ removeEntry(value) {
276
+ removeEntry.call(this, String(value));
277
+ }
278
+
279
+ /**
280
+ * Insert a node.
281
+ *
282
+ * @param {Object} entry
283
+ * @param {string|null} parentValue
284
+ * @return {void}
285
+ */
286
+ insertEntry(entry, parentValue = null) {
287
+ insertEntry.call(this, entry, parentValue);
288
+ }
289
+
290
+ /**
291
+ * Insert a node before a reference entry.
292
+ *
293
+ * @param {Object} entry
294
+ * @param {string} referenceValue
295
+ * @return {void}
296
+ */
297
+ insertEntryBefore(entry, referenceValue) {
298
+ insertEntryAt.call(this, entry, String(referenceValue), "before");
299
+ }
300
+
301
+ /**
302
+ * Insert a node after a reference entry.
303
+ *
304
+ * @param {Object} entry
305
+ * @param {string} referenceValue
306
+ * @return {void}
307
+ */
308
+ insertEntryAfter(entry, referenceValue) {
309
+ insertEntryAt.call(this, entry, String(referenceValue), "after");
310
+ }
311
+ }
312
+
313
+ /**
314
+ * @private
315
+ */
316
+ function initEventHandler() {
317
+ this[openEntryEventHandlerSymbol] = (event) => {
318
+ const container = findTargetElementFromEvent(
319
+ event,
320
+ ATTRIBUTE_ROLE,
321
+ "entry",
322
+ );
323
+
324
+ if (!(container instanceof HTMLElement)) {
325
+ return;
326
+ }
327
+
328
+ const index = container
329
+ .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
330
+ .split("-")
331
+ .pop();
332
+
333
+ const currentEntry = this.getOption("entries." + index);
334
+
335
+ const allowParentSelect = this.getOption("features.selectParents") === true;
336
+ if (currentEntry["has-children"] === false) {
337
+ const href = currentEntry.href;
338
+ const isNavigation = isString(href) && href !== "";
339
+ const doNavigate = getAction.call(this, ["onnavigate", "navigate"]);
340
+
341
+ if (isNavigation) {
342
+ let allowNavigation = true;
343
+ if (isFunction(doNavigate)) {
344
+ const result = doNavigate.call(this, currentEntry, index, event);
345
+ if (result === false) {
346
+ allowNavigation = false;
347
+ }
348
+ }
349
+
350
+ const navEvent = dispatchEntryEvent.call(
351
+ this,
352
+ "monster-html-tree-menu-navigate",
353
+ {
354
+ entry: currentEntry,
355
+ index,
356
+ event,
357
+ },
358
+ );
359
+ if (navEvent.defaultPrevented) {
360
+ allowNavigation = false;
361
+ }
362
+
363
+ if (!allowNavigation) {
364
+ event.preventDefault();
365
+ return;
366
+ }
367
+
368
+ if (isAnchorEvent(event) === false) {
369
+ window.location.assign(href);
370
+ }
371
+ return;
372
+ }
373
+
374
+ applySelection.call(this, currentEntry, Number(index), container, event);
375
+ return;
376
+ }
377
+
378
+ const currentState = this.getOption("entries." + index + ".state");
379
+ const newState = currentState === "close" ? "open" : "close";
380
+ if (newState === "open") {
381
+ const entry = this.getOption("entries." + index);
382
+ if (shouldLazyLoad.call(this, entry)) {
383
+ void ensureEntryLoaded
384
+ .call(this, Number(index), event)
385
+ .then((loaded) => {
386
+ if (loaded) {
387
+ applyEntryState.call(this, Number(index), newState, event);
388
+ }
389
+ });
390
+ return;
391
+ }
392
+ }
393
+
394
+ applyEntryState.call(this, Number(index), newState, event);
395
+
396
+ if (allowParentSelect) {
397
+ applySelection.call(this, currentEntry, Number(index), container, event);
398
+ }
399
+ };
400
+
401
+ const types = this.getOption("toggleEventType", ["click"]);
402
+ for (const [, type] of Object.entries(types)) {
403
+ this.shadowRoot.addEventListener(type, this[openEntryEventHandlerSymbol]);
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Import menu entries from HTML list.
409
+ *
410
+ * @private
411
+ */
412
+ function importEntries() {
413
+ const rootList = this.querySelector("ul,ol");
414
+ if (!(rootList instanceof HTMLElement)) {
415
+ addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "no list found");
416
+ return;
417
+ }
418
+
419
+ rootList.setAttribute("hidden", "");
420
+ rootList.classList.add("hidden");
421
+
422
+ const entries = [];
423
+ buildEntriesFromList.call(this, rootList, 0, false, entries);
424
+ this.setOption("entries", entries);
425
+ rebuildEntryIndex.call(this);
426
+ }
427
+
428
+ /**
429
+ * @private
430
+ * @param {HTMLElement} list
431
+ * @param {number} level
432
+ * @param {boolean} ancestorHidden
433
+ * @param {Array} entries
434
+ */
435
+ function buildEntriesFromList(list, level, ancestorHidden, entries) {
436
+ const lazyConfig = this.getOption("lazy", {});
437
+ const lazyEnabled = lazyConfig?.enabled !== false;
438
+ const lazyAttribute = lazyConfig?.attribute || "";
439
+ const items = Array.from(list.children).filter((node) => node.matches("li"));
440
+
441
+ for (const li of items) {
442
+ const childList = li.querySelector(":scope > ul,:scope > ol");
443
+ const hasChildList =
444
+ childList instanceof HTMLElement &&
445
+ Array.from(childList.children).some((node) => node.matches("li"));
446
+ const endpoint =
447
+ lazyEnabled && isString(lazyAttribute) && lazyAttribute !== ""
448
+ ? li.getAttribute(lazyAttribute)
449
+ : null;
450
+ const hasLazyEndpoint = isString(endpoint) && endpoint !== "";
451
+ const hasChildren = hasChildList || hasLazyEndpoint;
452
+
453
+ const label = getLabelHtml(li, childList);
454
+ const value = getEntryValue(li);
455
+ const href = getEntryHref(li, childList);
456
+ const liHidden = isHiddenElement(li);
457
+ const childHidden = childList ? isHiddenElement(childList) : false;
458
+ let state = "close";
459
+ if (hasChildren && !(childHidden || hasLazyEndpoint)) {
460
+ state = "open";
461
+ }
462
+ const visibility = ancestorHidden || liHidden ? "hidden" : "visible";
463
+
464
+ entries.push({
465
+ value,
466
+ label,
467
+ icon: "",
468
+ intend: level,
469
+ state,
470
+ visibility,
471
+ ["has-children"]: hasChildren,
472
+ href,
473
+ ["lazy-endpoint"]: hasLazyEndpoint ? endpoint : null,
474
+ ["lazy-loaded"]: hasLazyEndpoint ? hasChildList : true,
475
+ ["lazy-loading"]: false,
476
+ });
477
+
478
+ if (hasChildList) {
479
+ buildEntriesFromList.call(
480
+ this,
481
+ childList,
482
+ level + 1,
483
+ ancestorHidden || liHidden || state === "close",
484
+ entries,
485
+ );
486
+ }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * @private
492
+ * @param {HTMLElement} element
493
+ * @return {boolean}
494
+ */
495
+ function isHiddenElement(element) {
496
+ return (
497
+ element.hasAttribute("hidden") ||
498
+ element.classList.contains("hidden") ||
499
+ element.classList.contains("hide") ||
500
+ element.classList.contains("none")
501
+ );
502
+ }
503
+
504
+ /**
505
+ * @private
506
+ * @param {HTMLElement} li
507
+ * @param {HTMLElement|null} childList
508
+ * @return {string}
509
+ */
510
+ function getLabelHtml(li, childList) {
511
+ const clone = li.cloneNode(true);
512
+ if (childList) {
513
+ const nested = clone.querySelector("ul,ol");
514
+ if (nested) {
515
+ nested.remove();
516
+ }
517
+ }
518
+ const html = clone.innerHTML.trim();
519
+ if (html !== "") {
520
+ return html;
521
+ }
522
+ return li.textContent.trim();
523
+ }
524
+
525
+ /**
526
+ * @private
527
+ * @param {HTMLElement} li
528
+ * @param {HTMLElement|null} childList
529
+ * @return {string|null}
530
+ */
531
+ function getEntryHref(li, childList) {
532
+ const clone = li.cloneNode(true);
533
+ if (childList) {
534
+ const nested = clone.querySelector("ul,ol");
535
+ if (nested) {
536
+ nested.remove();
537
+ }
538
+ }
539
+ const anchor = clone.querySelector("a[href]");
540
+ const href = anchor?.getAttribute("href") || li.getAttribute("href");
541
+ if (isString(href) && href !== "") {
542
+ return href;
543
+ }
544
+ return null;
545
+ }
546
+
547
+ /**
548
+ * @private
549
+ * @param {HTMLElement} li
550
+ * @return {string}
551
+ */
552
+ function getEntryValue(li) {
553
+ const value =
554
+ li.getAttribute("data-monster-value") ||
555
+ li.getAttribute("data-value") ||
556
+ li.getAttribute("id");
557
+ if (isString(value) && value !== "") {
558
+ return value;
559
+ }
560
+ return new ID().toString();
561
+ }
562
+
563
+ /**
564
+ * @private
565
+ * @param {string} value
566
+ * @return {number}
567
+ */
568
+ function findEntryIndex(value) {
569
+ if (this[entryIndexSymbol].has(value)) {
570
+ return this[entryIndexSymbol].get(value);
571
+ }
572
+ const entries = this.getOption("entries", []);
573
+ return entries.findIndex((entry) => String(entry.value) === value);
574
+ }
575
+
576
+ /**
577
+ * @private
578
+ */
579
+ function rebuildEntryIndex() {
580
+ this[entryIndexSymbol].clear();
581
+ const entries = this.getOption("entries", []);
582
+ for (let i = 0; i < entries.length; i += 1) {
583
+ this[entryIndexSymbol].set(String(entries[i].value), i);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * @private
589
+ * @param {number} index
590
+ * @return {HTMLElement|null}
591
+ */
592
+ function getEntryNode(index) {
593
+ return this.shadowRoot.querySelector(
594
+ `[data-monster-insert-reference=entries-${index}]`,
595
+ );
596
+ }
597
+
598
+ /**
599
+ * @private
600
+ * @param {Object} entry
601
+ * @return {Object}
602
+ */
603
+ function normalizeEntry(entry) {
604
+ const rawEndpoint =
605
+ entry?.endpoint || entry?.["lazy-endpoint"] || entry?.lazyEndpoint;
606
+ const endpoint =
607
+ isString(rawEndpoint) && rawEndpoint !== "" ? rawEndpoint : null;
608
+ const hasChildren = entry?.["has-children"] === true || endpoint !== null;
609
+ return {
610
+ value: String(entry?.value || new ID().toString()),
611
+ label: entry?.label || "",
612
+ icon: entry?.icon || "",
613
+ href: entry?.href || null,
614
+ intend: 0,
615
+ state: "close",
616
+ visibility: "visible",
617
+ ["has-children"]: hasChildren,
618
+ ["lazy-endpoint"]: endpoint,
619
+ ["lazy-loaded"]: endpoint === null,
620
+ ["lazy-loading"]: false,
621
+ };
622
+ }
623
+
624
+ /**
625
+ * @private
626
+ * @param {Array} entries
627
+ * @param {number} index
628
+ * @return {number}
629
+ */
630
+ function getSubtreeEndIndex(entries, index) {
631
+ const targetIntend = entries[index].intend;
632
+ let end = index + 1;
633
+ while (end < entries.length && entries[end].intend > targetIntend) {
634
+ end += 1;
635
+ }
636
+ return end;
637
+ }
638
+
639
+ /**
640
+ * @private
641
+ * @param {string} value
642
+ * @param {string} state
643
+ */
644
+ function toggleEntryState(value, state) {
645
+ const index = findEntryIndex.call(this, value);
646
+ if (index === -1) {
647
+ return;
648
+ }
649
+ if (state === "open") {
650
+ const entry = this.getOption("entries." + index);
651
+ if (shouldLazyLoad.call(this, entry)) {
652
+ void ensureEntryLoaded.call(this, index).then((loaded) => {
653
+ if (loaded) {
654
+ applyEntryState.call(this, index, state);
655
+ }
656
+ });
657
+ return;
658
+ }
659
+ }
660
+ applyEntryState.call(this, index, state);
661
+ }
662
+
663
+ /**
664
+ * @private
665
+ * @param {number} index
666
+ * @param {string} state
667
+ * @param {Event|undefined} event
668
+ */
669
+ function applyEntryState(index, state, event) {
670
+ const entry = this.getOption("entries." + index);
671
+ if (!entry || entry["has-children"] === false) {
672
+ return;
673
+ }
674
+
675
+ const actionName = state === "open" ? "onexpand" : "oncollapse";
676
+ const doAction = getAction.call(this, [actionName, state]);
677
+ if (isFunction(doAction)) {
678
+ doAction.call(this, entry, index, event);
679
+ }
680
+ const eventName =
681
+ state === "open"
682
+ ? "monster-html-tree-menu-expand"
683
+ : "monster-html-tree-menu-collapse";
684
+ fireCustomEvent(this, eventName, {
685
+ entry,
686
+ index,
687
+ event,
688
+ });
689
+
690
+ this.setOption("entries." + index + ".state", state);
691
+ const newVisibility = state === "open" ? "visible" : "hidden";
692
+
693
+ const entryNode = this.shadowRoot.querySelector(
694
+ "[data-monster-insert-reference=entries-" + index + "]",
695
+ );
696
+
697
+ if (entryNode?.hasAttribute(ATTRIBUTE_INTEND)) {
698
+ const intend = entryNode.getAttribute(ATTRIBUTE_INTEND);
699
+ let ref = entryNode.nextElementSibling;
700
+ const childIntend = parseInt(intend) + 1;
701
+
702
+ const cmp = (a, b) => {
703
+ if (state === "open") {
704
+ return a === b;
705
+ }
706
+
707
+ return a >= b;
708
+ };
709
+
710
+ while (ref?.hasAttribute(ATTRIBUTE_INTEND)) {
711
+ const refIntend = ref.getAttribute(ATTRIBUTE_INTEND);
712
+
713
+ if (!cmp(Number.parseInt(refIntend), childIntend)) {
714
+ if (refIntend === intend) {
715
+ break;
716
+ }
717
+ ref = ref.nextElementSibling;
718
+ continue;
719
+ }
720
+
721
+ const refIndex = ref
722
+ .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
723
+ .split("-")
724
+ .pop();
725
+
726
+ this.setOption("entries." + refIndex + ".visibility", newVisibility);
727
+
728
+ if (state === "close") {
729
+ this.setOption("entries." + refIndex + ".state", "close");
730
+ }
731
+
732
+ ref = ref.nextElementSibling;
733
+ }
734
+ }
735
+ }
736
+
737
+ /**
738
+ * @private
739
+ * @param {string} value
740
+ * @param {string} visibility
741
+ */
742
+ function setEntryVisibility(value, visibility) {
743
+ const index = findEntryIndex.call(this, value);
744
+ if (index === -1) {
745
+ return;
746
+ }
747
+
748
+ const entries = this.getOption("entries", []);
749
+ const target = entries[index];
750
+ if (!target) {
751
+ return;
752
+ }
753
+
754
+ this.setOption("entries." + index + ".visibility", visibility);
755
+ const intend = target.intend;
756
+ for (let i = index + 1; i < entries.length; i += 1) {
757
+ if (entries[i].intend <= intend) {
758
+ break;
759
+ }
760
+ if (visibility === "hidden") {
761
+ this.setOption("entries." + i + ".visibility", "hidden");
762
+ }
763
+ }
764
+ }
765
+
766
+ /**
767
+ * @private
768
+ * @param {string} value
769
+ */
770
+ function expandEntry(value) {
771
+ const index = findEntryIndex.call(this, value);
772
+ if (index === -1) {
773
+ return;
774
+ }
775
+
776
+ const entry = this.getOption("entries." + index);
777
+ if (!entry || entry["has-children"] === false) {
778
+ return;
779
+ }
780
+
781
+ if (shouldLazyLoad.call(this, entry)) {
782
+ void ensureEntryLoaded.call(this, index).then((loaded) => {
783
+ if (loaded) {
784
+ expandEntryByIndex.call(this, index);
785
+ }
786
+ });
787
+ return;
788
+ }
789
+
790
+ expandEntryByIndex.call(this, index);
791
+ }
792
+
793
+ /**
794
+ * @private
795
+ * @param {string} value
796
+ */
797
+ function collapseEntry(value) {
798
+ const index = findEntryIndex.call(this, value);
799
+ if (index === -1) {
800
+ return;
801
+ }
802
+
803
+ const entry = this.getOption("entries." + index);
804
+ if (!entry || entry["has-children"] === false) {
805
+ return;
806
+ }
807
+
808
+ const doAction = getAction.call(this, ["oncollapse", "close"]);
809
+ if (isFunction(doAction)) {
810
+ doAction.call(this, entry, index);
811
+ }
812
+ fireCustomEvent(this, "monster-html-tree-menu-collapse", {
813
+ entry,
814
+ index,
815
+ });
816
+
817
+ this.setOption("entries." + index + ".state", "close");
818
+
819
+ const entries = this.getOption("entries", []);
820
+ const end = getSubtreeEndIndex(entries, index);
821
+ for (let i = index + 1; i < end; i += 1) {
822
+ this.setOption("entries." + i + ".visibility", "hidden");
823
+ if (entries[i]["has-children"]) {
824
+ this.setOption("entries." + i + ".state", "close");
825
+ }
826
+ }
827
+ }
828
+
829
+ /**
830
+ * @private
831
+ * @param {string} value
832
+ */
833
+ function removeEntry(value) {
834
+ const entries = this.getOption("entries", []);
835
+ const index = findEntryIndex.call(this, value);
836
+ if (index === -1) {
837
+ return;
838
+ }
839
+
840
+ const targetIntend = entries[index].intend;
841
+ let parentIndex = -1;
842
+ for (let i = index - 1; i >= 0; i -= 1) {
843
+ if (entries[i].intend < targetIntend) {
844
+ parentIndex = i;
845
+ break;
846
+ }
847
+ }
848
+ const newEntries = [];
849
+ for (let i = 0; i < entries.length; i += 1) {
850
+ if (i === index) {
851
+ continue;
852
+ }
853
+ if (i > index && entries[i].intend > targetIntend) {
854
+ continue;
855
+ }
856
+ newEntries.push(entries[i]);
857
+ }
858
+
859
+ if (parentIndex !== -1) {
860
+ const parentValue = String(entries[parentIndex].value);
861
+ const parentEntryIndex = newEntries.findIndex(
862
+ (entry) => String(entry.value) === parentValue,
863
+ );
864
+ if (parentEntryIndex !== -1) {
865
+ const parentIntend = newEntries[parentEntryIndex].intend;
866
+ const hasChildren = newEntries.some(
867
+ (entry, idx) => idx > parentEntryIndex && entry.intend > parentIntend,
868
+ );
869
+ if (!hasChildren) {
870
+ newEntries[parentEntryIndex] = Object.assign(
871
+ {},
872
+ newEntries[parentEntryIndex],
873
+ {
874
+ ["has-children"]: false,
875
+ state: "close",
876
+ },
877
+ );
878
+ }
879
+ }
880
+ }
881
+
882
+ this.setOption("entries", newEntries);
883
+ rebuildEntryIndex.call(this);
884
+ }
885
+
886
+ /**
887
+ * @private
888
+ * @param {Object} entry
889
+ * @param {string|null} parentValue
890
+ */
891
+ function insertEntry(entry, parentValue) {
892
+ const entries = this.getOption("entries", []);
893
+ const newEntry = normalizeEntry(entry);
894
+
895
+ let insertIndex = entries.length;
896
+ if (isString(parentValue) && parentValue !== "") {
897
+ const parentIndex = findEntryIndex.call(this, parentValue);
898
+ if (parentIndex !== -1) {
899
+ const parent = entries[parentIndex];
900
+ newEntry.intend = parent.intend + 1;
901
+ newEntry.visibility = parent.state === "open" ? "visible" : "hidden";
902
+ entries[parentIndex] = Object.assign({}, parent, {
903
+ ["has-children"]: true,
904
+ });
905
+
906
+ insertIndex = parentIndex + 1;
907
+ while (
908
+ insertIndex < entries.length &&
909
+ entries[insertIndex].intend > parent.intend
910
+ ) {
911
+ insertIndex += 1;
912
+ }
913
+ }
914
+ }
915
+
916
+ const nextEntries = [
917
+ ...entries.slice(0, insertIndex),
918
+ newEntry,
919
+ ...entries.slice(insertIndex),
920
+ ];
921
+ this.setOption("entries", nextEntries);
922
+ rebuildEntryIndex.call(this);
923
+ }
924
+
925
+ /**
926
+ * @private
927
+ * @param {Object} entry
928
+ * @param {string} referenceValue
929
+ * @param {string} position
930
+ */
931
+ function insertEntryAt(entry, referenceValue, position) {
932
+ const entries = this.getOption("entries", []);
933
+ const referenceIndex = findEntryIndex.call(this, referenceValue);
934
+
935
+ if (referenceIndex === -1) {
936
+ insertEntry.call(this, entry, null);
937
+ return;
938
+ }
939
+
940
+ const referenceEntry = entries[referenceIndex];
941
+ const newEntry = normalizeEntry(entry);
942
+ newEntry.intend = referenceEntry.intend;
943
+ newEntry.visibility = referenceEntry.visibility;
944
+
945
+ let parentIndex = -1;
946
+ for (let i = referenceIndex - 1; i >= 0; i -= 1) {
947
+ if (entries[i].intend < referenceEntry.intend) {
948
+ parentIndex = i;
949
+ break;
950
+ }
951
+ }
952
+
953
+ if (parentIndex !== -1) {
954
+ entries[parentIndex] = Object.assign({}, entries[parentIndex], {
955
+ ["has-children"]: true,
956
+ });
957
+ }
958
+
959
+ let insertIndex = referenceIndex;
960
+ if (position === "after") {
961
+ insertIndex = getSubtreeEndIndex(entries, referenceIndex);
962
+ }
963
+
964
+ const nextEntries = [
965
+ ...entries.slice(0, insertIndex),
966
+ newEntry,
967
+ ...entries.slice(insertIndex),
968
+ ];
969
+
970
+ this.setOption("entries", nextEntries);
971
+ rebuildEntryIndex.call(this);
972
+ }
973
+
974
+ /**
975
+ * @private
976
+ * @param {number} index
977
+ */
978
+ function expandEntryByIndex(index) {
979
+ const entry = this.getOption(`entries.${index}`);
980
+ if (!entry || entry["has-children"] === false) {
981
+ return;
982
+ }
983
+
984
+ const doAction = getAction.call(this, ["onexpand", "open"]);
985
+ if (isFunction(doAction)) {
986
+ doAction.call(this, entry, index);
987
+ }
988
+ fireCustomEvent(this, "monster-html-tree-menu-expand", {
989
+ entry,
990
+ index,
991
+ });
992
+
993
+ this.setOption("entries." + index + ".state", "open");
994
+ this.setOption("entries." + index + ".visibility", "visible");
995
+
996
+ const entries = this.getOption("entries", []);
997
+ const end = getSubtreeEndIndex(entries, index);
998
+ for (let i = index + 1; i < end; i += 1) {
999
+ this.setOption("entries." + i + ".visibility", "visible");
1000
+ if (entries[i]["has-children"]) {
1001
+ this.setOption("entries." + i + ".state", "open");
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * @private
1008
+ * @param {Object} entry
1009
+ * @return {boolean}
1010
+ */
1011
+ function shouldLazyLoad(entry) {
1012
+ if (!entry) {
1013
+ return false;
1014
+ }
1015
+ const lazyConfig = this.getOption("lazy", {});
1016
+ if (lazyConfig?.enabled === false) {
1017
+ return false;
1018
+ }
1019
+ const endpoint = entry["lazy-endpoint"];
1020
+ return (
1021
+ isString(endpoint) &&
1022
+ endpoint !== "" &&
1023
+ entry["lazy-loaded"] !== true &&
1024
+ entry["lazy-loading"] !== true
1025
+ );
1026
+ }
1027
+
1028
+ /**
1029
+ * @private
1030
+ * @param {number} index
1031
+ * @param {Event|undefined} event
1032
+ * @return {Promise<boolean>}
1033
+ */
1034
+ async function ensureEntryLoaded(index, event) {
1035
+ const entry = this.getOption(`entries.${index}`);
1036
+ if (!shouldLazyLoad.call(this, entry)) {
1037
+ return true;
1038
+ }
1039
+
1040
+ const beforeLoadAction = getAction.call(this, ["onlazyload"]);
1041
+ if (isFunction(beforeLoadAction)) {
1042
+ beforeLoadAction.call(this, entry, index, event);
1043
+ }
1044
+ fireCustomEvent(this, "monster-html-tree-menu-lazy-load", {
1045
+ entry,
1046
+ index,
1047
+ event,
1048
+ });
1049
+
1050
+ this.setOption(`entries.${index}.lazy-loading`, true);
1051
+
1052
+ const endpoint = entry["lazy-endpoint"];
1053
+ const lazyConfig = this.getOption("lazy", {});
1054
+ const fetchOptions = Object.assign(
1055
+ {
1056
+ method: "GET",
1057
+ },
1058
+ lazyConfig?.fetchOptions || {},
1059
+ );
1060
+
1061
+ let response = null;
1062
+ try {
1063
+ response = await fetch(endpoint, fetchOptions);
1064
+ if (!response.ok) {
1065
+ throw new Error("failed to load lazy entry");
1066
+ }
1067
+ } catch (e) {
1068
+ this.setOption(`entries.${index}.lazy-loading`, false);
1069
+ addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
1070
+ const errorAction = getAction.call(this, ["onlazyerror"]);
1071
+ if (isFunction(errorAction)) {
1072
+ errorAction.call(this, entry, index, event);
1073
+ }
1074
+ fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
1075
+ entry,
1076
+ index,
1077
+ event,
1078
+ });
1079
+ return false;
1080
+ }
1081
+
1082
+ let html = "";
1083
+ try {
1084
+ html = await response.text();
1085
+ } catch (e) {
1086
+ this.setOption(`entries.${index}.lazy-loading`, false);
1087
+ addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
1088
+ const errorAction = getAction.call(this, ["onlazyerror"]);
1089
+ if (isFunction(errorAction)) {
1090
+ errorAction.call(this, entry, index, event);
1091
+ }
1092
+ fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
1093
+ entry,
1094
+ index,
1095
+ event,
1096
+ });
1097
+ return false;
1098
+ }
1099
+
1100
+ const list = parseLazyList(html);
1101
+ if (!list) {
1102
+ this.setOption(`entries.${index}.lazy-loading`, false);
1103
+ addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "lazy entry has no list");
1104
+ const errorAction = getAction.call(this, ["onlazyerror"]);
1105
+ if (isFunction(errorAction)) {
1106
+ errorAction.call(this, entry, index, event);
1107
+ }
1108
+ fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
1109
+ entry,
1110
+ index,
1111
+ event,
1112
+ });
1113
+ return false;
1114
+ }
1115
+
1116
+ const childEntries = [];
1117
+ buildEntriesFromList.call(this, list, entry.intend + 1, false, childEntries);
1118
+
1119
+ const entries = this.getOption("entries", []);
1120
+ const insertIndex = getSubtreeEndIndex(entries, index);
1121
+ const updatedEntry = Object.assign({}, entries[index], {
1122
+ ["lazy-loaded"]: true,
1123
+ ["lazy-loading"]: false,
1124
+ ["has-children"]: childEntries.length > 0,
1125
+ state: childEntries.length > 0 ? "open" : "close",
1126
+ });
1127
+
1128
+ const nextEntries = [
1129
+ ...entries.slice(0, insertIndex),
1130
+ ...childEntries,
1131
+ ...entries.slice(insertIndex),
1132
+ ];
1133
+ nextEntries[index] = updatedEntry;
1134
+
1135
+ this.setOption("entries", nextEntries);
1136
+ rebuildEntryIndex.call(this);
1137
+
1138
+ const loadedEntry = this.getOption(`entries.${index}`);
1139
+ const loadedAction = getAction.call(this, ["onlazyloaded"]);
1140
+ if (isFunction(loadedAction)) {
1141
+ loadedAction.call(this, loadedEntry, index, event);
1142
+ }
1143
+ fireCustomEvent(this, "monster-html-tree-menu-lazy-loaded", {
1144
+ entry: loadedEntry,
1145
+ index,
1146
+ event,
1147
+ });
1148
+
1149
+ return childEntries.length > 0;
1150
+ }
1151
+
1152
+ /**
1153
+ * @private
1154
+ * @param {string} html
1155
+ * @return {HTMLElement|null}
1156
+ */
1157
+ function parseLazyList(html) {
1158
+ if (!isString(html) || html.trim() === "") {
1159
+ return null;
1160
+ }
1161
+
1162
+ const parser = new DOMParser();
1163
+ const doc = parser.parseFromString(html, "text/html");
1164
+ const list = doc.querySelector("ul,ol");
1165
+ if (list) {
1166
+ return list;
1167
+ }
1168
+
1169
+ const items = Array.from(doc.body.children).filter((node) =>
1170
+ node.matches("li"),
1171
+ );
1172
+ if (items.length === 0) {
1173
+ return null;
1174
+ }
1175
+
1176
+ const fallback = document.createElement("ul");
1177
+ for (const item of items) {
1178
+ fallback.appendChild(document.importNode(item, true));
1179
+ }
1180
+ return fallback;
1181
+ }
1182
+
1183
+ /**
1184
+ * @private
1185
+ * @param {string[]} names
1186
+ * @return {Function|null}
1187
+ */
1188
+ function getAction(names) {
1189
+ for (const name of names) {
1190
+ const action = this.getOption(`actions.${name}`);
1191
+ if (isFunction(action)) {
1192
+ return action;
1193
+ }
1194
+ }
1195
+ return null;
1196
+ }
1197
+
1198
+ /**
1199
+ * @private
1200
+ * @param {Event} event
1201
+ * @return {boolean}
1202
+ */
1203
+ function isAnchorEvent(event) {
1204
+ if (!event || typeof event.composedPath !== "function") {
1205
+ return false;
1206
+ }
1207
+ const path = event.composedPath();
1208
+ for (const node of path) {
1209
+ if (node instanceof HTMLAnchorElement && node.hasAttribute("href")) {
1210
+ return true;
1211
+ }
1212
+ }
1213
+ return false;
1214
+ }
1215
+
1216
+ /**
1217
+ * @private
1218
+ * @param {string} type
1219
+ * @param {Object} detail
1220
+ * @return {CustomEvent}
1221
+ */
1222
+ function dispatchEntryEvent(type, detail) {
1223
+ const event = new CustomEvent(type, {
1224
+ bubbles: true,
1225
+ cancelable: true,
1226
+ composed: true,
1227
+ detail,
1228
+ });
1229
+ this.dispatchEvent(event);
1230
+ return event;
1231
+ }
1232
+
1233
+ /**
1234
+ * @private
1235
+ * @param {Object} entry
1236
+ * @param {number} index
1237
+ * @param {HTMLElement} container
1238
+ * @param {Event|undefined} event
1239
+ */
1240
+ function applySelection(entry, index, container, event) {
1241
+ this.shadowRoot
1242
+ .querySelectorAll("[data-monster-role=entry].selected")
1243
+ .forEach((node) => {
1244
+ node.classList.remove("selected");
1245
+ });
1246
+
1247
+ let intend = entry.intend;
1248
+ if (intend > 0) {
1249
+ let ref = container.previousElementSibling;
1250
+ while (ref?.hasAttribute(ATTRIBUTE_INTEND)) {
1251
+ const i = Number.parseInt(ref.getAttribute(ATTRIBUTE_INTEND));
1252
+
1253
+ if (Number.isNaN(i)) {
1254
+ break;
1255
+ }
1256
+
1257
+ if (i < intend) {
1258
+ ref.classList.add("selected");
1259
+ if (i === 0) {
1260
+ break;
1261
+ }
1262
+ intend = i;
1263
+ }
1264
+ ref = ref.previousElementSibling;
1265
+ }
1266
+ }
1267
+
1268
+ container.classList.add("selected");
1269
+
1270
+ const doSelect = getAction.call(this, ["onselect", "select"]);
1271
+ if (isFunction(doSelect)) {
1272
+ doSelect.call(this, entry, index, event);
1273
+ }
1274
+
1275
+ fireCustomEvent(this, "monster-html-tree-menu-select", {
1276
+ entry,
1277
+ index,
1278
+ event,
1279
+ });
1280
+ }
1281
+
1282
+ /**
1283
+ * @private
1284
+ * @return {string}
1285
+ */
1286
+ function getTemplate() {
1287
+ // language=HTML
1288
+ return `
1289
+ <slot></slot>
1290
+
1291
+ <template id="entries">
1292
+ <div data-monster-role="entry"
1293
+ data-monster-attributes="
1294
+ data-monster-intend path:entries.intend,
1295
+ data-monster-state path:entries.state,
1296
+ data-monster-visibility path:entries.visibility,
1297
+ data-monster-filtered path:entries.filtered,
1298
+ data-monster-has-children path:entries.has-children">
1299
+ <div data-monster-role="button"
1300
+ data-monster-attributes="
1301
+ value path:entries.value | tostring
1302
+ " tabindex="0">
1303
+ <div data-monster-role="status-badges"></div>
1304
+ <div data-monster-role="icon" data-monster-replace="path:entries.icon"></div>
1305
+ <div data-monster-replace="path:entries.label"
1306
+ part="entry-label"
1307
+ data-monster-attributes="class static:id"></div>
1308
+ </div>
1309
+ </template>
1310
+
1311
+ <div data-monster-role="control" part="control" data-monster-attributes="class path:classes.control">
1312
+ <div part="entries" data-monster-role="entries"
1313
+ data-monster-insert="entries path:entries"
1314
+ tabindex="-1"></div>
1315
+ </div>
1316
+ `;
1317
+ }
1318
+
1319
+ registerCustomElement(HtmlTreeMenu);