@schukai/monster 4.137.4 → 4.137.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.
package/package.json CHANGED
@@ -1 +1 @@
1
- {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.137.4"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.137.5"}
@@ -89,6 +89,7 @@ const layoutApplySymbol = Symbol("layoutApply");
89
89
  */
90
90
  const labelStateSymbol = Symbol("labelState");
91
91
  const layoutModeSymbol = Symbol("layoutMode");
92
+ const layoutMeasurementCacheSymbol = Symbol("layoutMeasurementCache");
92
93
  const lastNavClickTimeSymbol = Symbol("lastNavClickTime");
93
94
  const lastNavClickTargetSymbol = Symbol("lastNavClickTarget");
94
95
 
@@ -919,43 +920,38 @@ function applyPaginationLayout() {
919
920
  return;
920
921
  }
921
922
 
922
- list.classList.add("pagination-no-wrap");
923
- list.setAttribute("data-monster-adaptive-ready", "false");
924
-
925
923
  const prevLink = this.shadowRoot.querySelector(
926
924
  "a[data-monster-role=pagination-prev]",
927
925
  );
928
926
  const nextLink = this.shadowRoot.querySelector(
929
927
  "a[data-monster-role=pagination-next]",
930
928
  );
929
+ const signature = getPaginationLayoutSignature.call(this, list);
930
+ const cache = getPaginationLayoutCache.call(this);
931
931
 
932
- const widthFull = applyPaginationMode.call(
933
- this,
934
- list,
935
- prevLink,
936
- nextLink,
937
- "full",
938
- );
939
- const widthNoNumbers = applyPaginationMode.call(
940
- this,
941
- list,
942
- prevLink,
943
- nextLink,
944
- "no-numbers",
945
- );
946
- const widthCompactSummary = applyPaginationMode.call(
947
- this,
948
- list,
949
- prevLink,
950
- nextLink,
951
- "compact-summary",
952
- );
953
- const widthCompact = applyPaginationMode.call(
932
+ if (
933
+ cache.lastAppliedSignature === signature &&
934
+ cache.lastAvailableWidth === availableWidth &&
935
+ cache.lastAppliedMode === this[layoutModeSymbol] &&
936
+ list.getAttribute("data-monster-adaptive-ready") === "true"
937
+ ) {
938
+ return;
939
+ }
940
+
941
+ setClassEnabled(list, "pagination-no-wrap", true);
942
+ setAttributeValue(list, "data-monster-adaptive-ready", "false");
943
+
944
+ const {
945
+ widthFull,
946
+ widthNoNumbers,
947
+ widthCompactSummary,
948
+ widthCompact,
949
+ } = measurePaginationModeWidths.call(
954
950
  this,
955
951
  list,
956
952
  prevLink,
957
953
  nextLink,
958
- "compact",
954
+ signature,
959
955
  );
960
956
 
961
957
  const nextMode = choosePaginationMode.call(this, {
@@ -967,7 +963,10 @@ function applyPaginationLayout() {
967
963
  });
968
964
 
969
965
  applyPaginationMode.call(this, list, prevLink, nextLink, nextMode);
970
- list.setAttribute("data-monster-adaptive-ready", "true");
966
+ setAttributeValue(list, "data-monster-adaptive-ready", "true");
967
+ cache.lastAppliedSignature = signature;
968
+ cache.lastAvailableWidth = availableWidth;
969
+ cache.lastAppliedMode = nextMode;
971
970
  } finally {
972
971
  this[layoutApplySymbol] = false;
973
972
  }
@@ -998,7 +997,7 @@ function setNumberItemsVisible(list, visible) {
998
997
  for (const item of numberItems) {
999
998
  const container = item.parentElement;
1000
999
  if (!container) continue;
1001
- container.style.display = visible ? "" : "none";
1000
+ setStyleValue(container, "display", visible ? "" : "none");
1002
1001
  }
1003
1002
  }
1004
1003
 
@@ -1014,7 +1013,7 @@ function setSummaryVisible(list, visible) {
1014
1013
  if (!summaryItem) {
1015
1014
  return;
1016
1015
  }
1017
- summaryItem.style.display = visible ? "flex" : "none";
1016
+ setStyleValue(summaryItem, "display", visible ? "flex" : "none");
1018
1017
  }
1019
1018
 
1020
1019
  /**
@@ -1047,11 +1046,11 @@ function setCompactLabels(compact, prevLink, nextLink) {
1047
1046
  if (!state) return;
1048
1047
 
1049
1048
  if (compact) {
1050
- if (prevLink) prevLink.innerHTML = compactPrevIcon;
1051
- if (nextLink) nextLink.innerHTML = compactNextIcon;
1049
+ setInnerHTML(prevLink, compactPrevIcon);
1050
+ setInnerHTML(nextLink, compactNextIcon);
1052
1051
  } else {
1053
- if (prevLink) prevLink.innerHTML = state.previous;
1054
- if (nextLink) nextLink.innerHTML = state.next;
1052
+ setInnerHTML(prevLink, state.previous);
1053
+ setInnerHTML(nextLink, state.next);
1055
1054
  }
1056
1055
  }
1057
1056
 
@@ -1161,29 +1160,29 @@ function getEmbeddedAvailableWidth(parentNode) {
1161
1160
  function applyPaginationMode(list, prevLink, nextLink, mode) {
1162
1161
  switch (mode) {
1163
1162
  case "compact":
1164
- list.classList.add("pagination-numbers-hidden");
1165
- list.classList.add("pagination-compact");
1163
+ setClassEnabled(list, "pagination-numbers-hidden", true);
1164
+ setClassEnabled(list, "pagination-compact", true);
1166
1165
  setNumberItemsVisible(list, false);
1167
1166
  setSummaryVisible(list, true);
1168
1167
  setCompactLabels.call(this, true, prevLink, nextLink);
1169
1168
  break;
1170
1169
  case "compact-summary":
1171
- list.classList.add("pagination-numbers-hidden");
1172
- list.classList.add("pagination-compact");
1170
+ setClassEnabled(list, "pagination-numbers-hidden", true);
1171
+ setClassEnabled(list, "pagination-compact", true);
1173
1172
  setNumberItemsVisible(list, false);
1174
1173
  setSummaryVisible(list, true);
1175
1174
  setCompactLabels.call(this, true, prevLink, nextLink);
1176
1175
  break;
1177
1176
  case "no-numbers":
1178
- list.classList.add("pagination-numbers-hidden");
1179
- list.classList.remove("pagination-compact");
1177
+ setClassEnabled(list, "pagination-numbers-hidden", true);
1178
+ setClassEnabled(list, "pagination-compact", false);
1180
1179
  setNumberItemsVisible(list, false);
1181
1180
  setSummaryVisible(list, true);
1182
1181
  setCompactLabels.call(this, false, prevLink, nextLink);
1183
1182
  break;
1184
1183
  default:
1185
- list.classList.remove("pagination-numbers-hidden");
1186
- list.classList.remove("pagination-compact");
1184
+ setClassEnabled(list, "pagination-numbers-hidden", false);
1185
+ setClassEnabled(list, "pagination-compact", false);
1187
1186
  setNumberItemsVisible(list, true);
1188
1187
  setSummaryVisible(list, false);
1189
1188
  setCompactLabels.call(this, false, prevLink, nextLink);
@@ -1193,6 +1192,172 @@ function applyPaginationMode(list, prevLink, nextLink, mode) {
1193
1192
  return list.scrollWidth;
1194
1193
  }
1195
1194
 
1195
+ /**
1196
+ * @private
1197
+ * @param {HTMLElement} list
1198
+ * @param {HTMLElement|null} prevLink
1199
+ * @param {HTMLElement|null} nextLink
1200
+ * @param {string} signature
1201
+ * @return {object}
1202
+ */
1203
+ function measurePaginationModeWidths(list, prevLink, nextLink, signature) {
1204
+ const cache = getPaginationLayoutCache.call(this);
1205
+ if (cache.widthSignature === signature && cache.widths) {
1206
+ return cache.widths;
1207
+ }
1208
+
1209
+ const widths = {
1210
+ widthFull: applyPaginationMode.call(
1211
+ this,
1212
+ list,
1213
+ prevLink,
1214
+ nextLink,
1215
+ "full",
1216
+ ),
1217
+ widthNoNumbers: applyPaginationMode.call(
1218
+ this,
1219
+ list,
1220
+ prevLink,
1221
+ nextLink,
1222
+ "no-numbers",
1223
+ ),
1224
+ widthCompactSummary: applyPaginationMode.call(
1225
+ this,
1226
+ list,
1227
+ prevLink,
1228
+ nextLink,
1229
+ "compact-summary",
1230
+ ),
1231
+ widthCompact: applyPaginationMode.call(
1232
+ this,
1233
+ list,
1234
+ prevLink,
1235
+ nextLink,
1236
+ "compact",
1237
+ ),
1238
+ };
1239
+
1240
+ cache.widthSignature = signature;
1241
+ cache.widths = widths;
1242
+ return widths;
1243
+ }
1244
+
1245
+ /**
1246
+ * @private
1247
+ * @return {object}
1248
+ */
1249
+ function getPaginationLayoutCache() {
1250
+ if (!this[layoutMeasurementCacheSymbol]) {
1251
+ this[layoutMeasurementCacheSymbol] = {
1252
+ lastAppliedMode: null,
1253
+ lastAppliedSignature: null,
1254
+ lastAvailableWidth: null,
1255
+ widthSignature: null,
1256
+ widths: null,
1257
+ };
1258
+ }
1259
+
1260
+ return this[layoutMeasurementCacheSymbol];
1261
+ }
1262
+
1263
+ /**
1264
+ * @private
1265
+ * @param {HTMLElement} list
1266
+ * @return {string}
1267
+ */
1268
+ function getPaginationLayoutSignature(list) {
1269
+ const pagination = this.getOption("pagination", {});
1270
+ const itemSignature = Array.isArray(pagination.items)
1271
+ ? pagination.items
1272
+ .map((item) => {
1273
+ return [
1274
+ item?.class || "",
1275
+ item?.href || "",
1276
+ item?.label || "",
1277
+ item?.no ?? "",
1278
+ ].join(":");
1279
+ })
1280
+ .join("|")
1281
+ : "";
1282
+
1283
+ return [
1284
+ pagination.current ?? "",
1285
+ pagination.pages ?? "",
1286
+ pagination.summary ?? "",
1287
+ pagination.prevClass ?? "",
1288
+ pagination.prevHref ?? "",
1289
+ pagination.prevNo ?? "",
1290
+ pagination.nextClass ?? "",
1291
+ pagination.nextHref ?? "",
1292
+ pagination.nextNo ?? "",
1293
+ itemSignature,
1294
+ list.children.length,
1295
+ ].join("::");
1296
+ }
1297
+
1298
+ /**
1299
+ * @private
1300
+ * @param {HTMLElement|null} element
1301
+ * @param {string} className
1302
+ * @param {boolean} enabled
1303
+ * @return {void}
1304
+ */
1305
+ function setClassEnabled(element, className, enabled) {
1306
+ if (!(element instanceof HTMLElement)) {
1307
+ return;
1308
+ }
1309
+ if (element.classList.contains(className) !== enabled) {
1310
+ element.classList.toggle(className, enabled);
1311
+ }
1312
+ }
1313
+
1314
+ /**
1315
+ * @private
1316
+ * @param {HTMLElement|null} element
1317
+ * @param {string} name
1318
+ * @param {string} value
1319
+ * @return {void}
1320
+ */
1321
+ function setAttributeValue(element, name, value) {
1322
+ if (!(element instanceof HTMLElement)) {
1323
+ return;
1324
+ }
1325
+ if (element.getAttribute(name) !== value) {
1326
+ element.setAttribute(name, value);
1327
+ }
1328
+ }
1329
+
1330
+ /**
1331
+ * @private
1332
+ * @param {HTMLElement|null} element
1333
+ * @param {string} property
1334
+ * @param {string} value
1335
+ * @return {void}
1336
+ */
1337
+ function setStyleValue(element, property, value) {
1338
+ if (!(element instanceof HTMLElement)) {
1339
+ return;
1340
+ }
1341
+ if (element.style[property] !== value) {
1342
+ element.style[property] = value;
1343
+ }
1344
+ }
1345
+
1346
+ /**
1347
+ * @private
1348
+ * @param {HTMLElement|null} element
1349
+ * @param {string} value
1350
+ * @return {void}
1351
+ */
1352
+ function setInnerHTML(element, value) {
1353
+ if (!(element instanceof HTMLElement)) {
1354
+ return;
1355
+ }
1356
+ if (element.innerHTML !== value) {
1357
+ element.innerHTML = value;
1358
+ }
1359
+ }
1360
+
1196
1361
  /**
1197
1362
  * @private
1198
1363
  * @param {object} params
@@ -129,6 +129,14 @@ const switchElementSymbol = Symbol("switchElement");
129
129
  const layoutStateSymbol = Symbol("layoutState");
130
130
  const layoutFrameSymbol = Symbol("layoutFrame");
131
131
  const layoutTokenSymbol = Symbol("layoutToken");
132
+ const observedLayoutNodesSignatureSymbol = Symbol("observedLayoutNodesSignature");
133
+
134
+ /**
135
+ * @private
136
+ * @type {WeakMap<HTMLElement, number>}
137
+ */
138
+ const layoutNodeIds = new WeakMap();
139
+ let layoutNodeId = 0;
132
140
 
133
141
  /**
134
142
  * @private
@@ -209,6 +217,7 @@ class ButtonBar extends CustomElement {
209
217
  needsLayout: true,
210
218
  needsObserve: true,
211
219
  suppressSlotChange: false,
220
+ suppressMutation: false,
212
221
  };
213
222
 
214
223
  initControlReferences.call(this);
@@ -385,6 +394,10 @@ function initEventHandler() {
385
394
  const self = this;
386
395
 
387
396
  const mutationCallback = (mutationList) => {
397
+ if (self[layoutStateSymbol]?.suppressMutation) {
398
+ return;
399
+ }
400
+
388
401
  let needsRecalc = false;
389
402
  for (const mutation of mutationList) {
390
403
  if (mutation.type === "attributes") {
@@ -572,7 +585,6 @@ function runLayout() {
572
585
  * @return {Object}
573
586
  */
574
587
  function rearrangeButtons() {
575
- const state = this[layoutStateSymbol];
576
588
  let space = 0;
577
589
  try {
578
590
  space = this[dimensionsSymbol].getVia("data.space");
@@ -660,28 +672,22 @@ function rearrangeButtons() {
660
672
  const shouldShowSwitch =
661
673
  layout.buttonsToMoveToPopper.length > 0 && hasButtons;
662
674
 
663
- if (state) {
664
- state.suppressSlotChange = true;
665
- }
675
+ suppressLayoutFeedback.call(this);
666
676
 
667
677
  for (const button of layout.buttonsToMoveToPopper) {
668
- button.setAttribute("slot", "popper");
678
+ if (button.getAttribute("slot") !== "popper") {
679
+ button.setAttribute("slot", "popper");
680
+ }
669
681
  }
670
682
 
671
683
  for (const button of layout.visibleButtonsInMainSlot) {
672
- button.removeAttribute("slot");
673
- }
674
-
675
- if (state) {
676
- state.suppressSlotChange = false;
684
+ if (button.hasAttribute("slot")) {
685
+ button.removeAttribute("slot");
686
+ }
677
687
  }
678
688
 
679
- if (shouldShowSwitch) {
680
- this[switchElementSymbol].removeAttribute("hidden");
681
- this[switchElementSymbol].classList.remove("hidden");
682
- } else {
683
- this[switchElementSymbol].setAttribute("hidden", "");
684
- this[switchElementSymbol].classList.add("hidden");
689
+ setSwitchVisible.call(this, shouldShowSwitch);
690
+ if (!shouldShowSwitch) {
685
691
  hide.call(this);
686
692
  }
687
693
  }
@@ -831,34 +837,123 @@ function calculateButtonBarDimensions() {
831
837
  * @private
832
838
  */
833
839
  function updateResizeObserverObservation() {
840
+ const observedNodes = getLayoutObservedNodes.call(this);
841
+ const signature = getLayoutObservedNodesSignature(observedNodes);
842
+ if (this[observedLayoutNodesSignatureSymbol] === signature) {
843
+ return;
844
+ }
845
+ this[observedLayoutNodesSignatureSymbol] = signature;
846
+
834
847
  this[resizeObserverSymbol].disconnect();
835
848
  if (this[mutationObserverSymbol]) {
836
849
  this[mutationObserverSymbol].disconnect();
837
850
  }
838
851
 
839
- const slottedNodes = getSlottedElements.call(this);
840
- slottedNodes.forEach((node) => {
852
+ observedNodes.forEach((node) => {
841
853
  this[resizeObserverSymbol].observe(node);
842
- if (this[mutationObserverSymbol]) {
854
+ if (node !== this.parentElement && this[mutationObserverSymbol]) {
843
855
  this[mutationObserverSymbol].observe(node, {
844
856
  attributes: true,
845
857
  attributeFilter: ["style", "class", "hidden"],
846
858
  });
847
859
  }
848
860
  });
861
+ }
849
862
 
850
- requestAnimationFrame(() => {
851
- let parent = this.parentNode;
852
- while (!(parent instanceof HTMLElement) && parent !== null) {
853
- parent = parent.parentNode;
863
+ /**
864
+ * @private
865
+ * @return {HTMLElement[]}
866
+ */
867
+ function getLayoutObservedNodes() {
868
+ const observedNodes = [];
869
+ const slottedNodes = getSlottedElements.call(this);
870
+ slottedNodes.forEach((node) => {
871
+ if (node instanceof HTMLElement) {
872
+ observedNodes.push(node);
854
873
  }
874
+ });
855
875
 
856
- if (parent instanceof HTMLElement) {
857
- this[resizeObserverSymbol].observe(parent);
858
- }
876
+ let parent = this.parentNode;
877
+ while (!(parent instanceof HTMLElement) && parent !== null) {
878
+ parent = parent.parentNode;
879
+ }
880
+
881
+ if (parent instanceof HTMLElement) {
882
+ observedNodes.push(parent);
883
+ }
884
+
885
+ return observedNodes;
886
+ }
887
+
888
+ /**
889
+ * @private
890
+ * @param {HTMLElement[]} nodes
891
+ * @return {string}
892
+ */
893
+ function getLayoutObservedNodesSignature(nodes) {
894
+ return nodes.map(getLayoutNodeId).join("|");
895
+ }
896
+
897
+ /**
898
+ * @private
899
+ * @param {HTMLElement} node
900
+ * @return {number}
901
+ */
902
+ function getLayoutNodeId(node) {
903
+ let id = layoutNodeIds.get(node);
904
+ if (id === undefined) {
905
+ id = ++layoutNodeId;
906
+ layoutNodeIds.set(node, id);
907
+ }
908
+ return id;
909
+ }
910
+
911
+ /**
912
+ * @private
913
+ * @return {void}
914
+ */
915
+ function suppressLayoutFeedback() {
916
+ const state = this[layoutStateSymbol];
917
+ if (!state) {
918
+ return;
919
+ }
920
+
921
+ state.suppressSlotChange = true;
922
+ state.suppressMutation = true;
923
+ queueMicrotask(() => {
924
+ state.suppressSlotChange = false;
925
+ state.suppressMutation = false;
859
926
  });
860
927
  }
861
928
 
929
+ /**
930
+ * @private
931
+ * @param {boolean} visible
932
+ * @return {void}
933
+ */
934
+ function setSwitchVisible(visible) {
935
+ if (!(this[switchElementSymbol] instanceof HTMLElement)) {
936
+ return;
937
+ }
938
+
939
+ if (visible) {
940
+ if (this[switchElementSymbol].hasAttribute("hidden")) {
941
+ this[switchElementSymbol].removeAttribute("hidden");
942
+ }
943
+ if (this[switchElementSymbol].classList.contains("hidden")) {
944
+ this[switchElementSymbol].classList.remove("hidden");
945
+ }
946
+ return;
947
+ }
948
+
949
+ if (!this[switchElementSymbol].hasAttribute("hidden")) {
950
+ this[switchElementSymbol].setAttribute("hidden", "");
951
+ }
952
+ if (!this[switchElementSymbol].classList.contains("hidden")) {
953
+ this[switchElementSymbol].classList.add("hidden");
954
+ }
955
+ }
956
+
862
957
  /**
863
958
  * @private
864
959
  */
@@ -866,6 +961,7 @@ function disconnectResizeObserver() {
866
961
  if (this[resizeObserverSymbol] instanceof ResizeObserver) {
867
962
  this[resizeObserverSymbol].disconnect();
868
963
  }
964
+ this[observedLayoutNodesSignatureSymbol] = null;
869
965
  }
870
966
 
871
967
  /**
@@ -43,6 +43,12 @@ const instanceSymbol = Symbol("instanceSymbol");
43
43
  const activeSubmenuHiderSymbol = Symbol("activeSubmenuHider");
44
44
  const hideHamburgerMenuSymbol = Symbol("hideHamburgerMenu");
45
45
  const hamburgerCloseButtonSymbol = Symbol("hamburgerCloseButton");
46
+ const layoutFrameSymbol = Symbol("layoutFrame");
47
+ const layoutSignatureSymbol = Symbol("layoutSignature");
48
+ const measurementCacheSymbol = Symbol("measurementCache");
49
+
50
+ const navigationItemIds = new WeakMap();
51
+ let navigationItemId = 0;
46
52
 
47
53
  function getAutoUpdateOptions() {
48
54
  return {
@@ -115,14 +121,16 @@ class SiteNavigation extends CustomElement {
115
121
  connectedCallback() {
116
122
  super.connectedCallback();
117
123
  attachResizeObserver.call(this);
118
- requestAnimationFrame(() => {
119
- populateTabs.call(this);
120
- });
124
+ schedulePopulateTabs.call(this);
121
125
  }
122
126
 
123
127
  disconnectedCallback() {
124
128
  super.disconnectedCallback();
125
129
  detachResizeObserver.call(this);
130
+ if (typeof this[layoutFrameSymbol] === "number") {
131
+ cancelAnimationFrame(this[layoutFrameSymbol]);
132
+ }
133
+ this[layoutFrameSymbol] = null;
126
134
  }
127
135
  }
128
136
 
@@ -300,14 +308,28 @@ function attachResizeObserver() {
300
308
  }
301
309
  }
302
310
  this[timerCallbackSymbol] = new DeadMansSwitch(200, () => {
303
- requestAnimationFrame(() => {
304
- populateTabs.call(this);
305
- });
311
+ schedulePopulateTabs.call(this);
306
312
  });
307
313
  });
308
314
  this[resizeObserverSymbol].observe(this);
309
315
  }
310
316
 
317
+ /**
318
+ * @private
319
+ * @this {SiteNavigation}
320
+ * @return {void}
321
+ */
322
+ function schedulePopulateTabs() {
323
+ if (typeof this[layoutFrameSymbol] === "number") {
324
+ return;
325
+ }
326
+
327
+ this[layoutFrameSymbol] = requestAnimationFrame(() => {
328
+ this[layoutFrameSymbol] = null;
329
+ populateTabs.call(this);
330
+ });
331
+ }
332
+
311
333
  /**
312
334
  * Disconnects and cleans up the ResizeObserver instance.
313
335
  * @private
@@ -595,6 +617,156 @@ function cloneNavItem(item) {
595
617
  return liClone;
596
618
  }
597
619
 
620
+ /**
621
+ * @private
622
+ * @param {HTMLElement} hamburgerButton
623
+ * @return {number}
624
+ */
625
+ function measureHamburgerWidth(hamburgerButton) {
626
+ const originalDisplay = hamburgerButton.style.display;
627
+ const originalVisibility = hamburgerButton.style.visibility;
628
+ setStyleValue(hamburgerButton, "visibility", "hidden");
629
+ setStyleValue(hamburgerButton, "display", "flex");
630
+ const width = Math.ceil(hamburgerButton.getBoundingClientRect().width) || 0;
631
+ setStyleValue(hamburgerButton, "display", originalDisplay);
632
+ setStyleValue(hamburgerButton, "visibility", originalVisibility);
633
+ return width;
634
+ }
635
+
636
+ /**
637
+ * @private
638
+ * @param {HTMLElement[]} sourceItems
639
+ * @param {string} sourceSignature
640
+ * @param {HTMLElement} visibleList
641
+ * @param {HTMLElement} navEl
642
+ * @return {object}
643
+ */
644
+ function getNavigationMeasurements(
645
+ sourceItems,
646
+ sourceSignature,
647
+ visibleList,
648
+ navEl,
649
+ ) {
650
+ const cache = this[measurementCacheSymbol];
651
+ if (cache?.sourceSignature === sourceSignature && cache.items.length > 0) {
652
+ return cache;
653
+ }
654
+
655
+ const originalOverflow = navEl.style.overflow;
656
+ const originalFlexWrap = visibleList.style.flexWrap;
657
+ const originalVisibility = visibleList.style.visibility;
658
+
659
+ setStyleValue(navEl, "overflow", "hidden");
660
+ setStyleValue(visibleList, "flexWrap", "nowrap");
661
+ setStyleValue(visibleList, "visibility", "hidden");
662
+
663
+ const clones = sourceItems.map(cloneNavItem);
664
+ visibleList.replaceChildren(...clones);
665
+
666
+ const items = clones.map((clone) => {
667
+ const submenu = clone.querySelector("ul, div[part='mega-menu']");
668
+ if (submenu instanceof HTMLElement) {
669
+ setStyleValue(submenu, "display", "none");
670
+ }
671
+ return {
672
+ requiredWidth: clone.offsetLeft + clone.offsetWidth,
673
+ width: clone.getBoundingClientRect().width || clone.offsetWidth || 0,
674
+ };
675
+ });
676
+ const gap = parseFloat(getComputedStyle(visibleList).gap || "0") || 0;
677
+
678
+ visibleList.replaceChildren();
679
+ setStyleValue(navEl, "overflow", originalOverflow);
680
+ setStyleValue(visibleList, "flexWrap", originalFlexWrap);
681
+ setStyleValue(visibleList, "visibility", originalVisibility);
682
+
683
+ this[measurementCacheSymbol] = {
684
+ gap,
685
+ items,
686
+ sourceSignature,
687
+ };
688
+ return this[measurementCacheSymbol];
689
+ }
690
+
691
+ /**
692
+ * @private
693
+ * @param {HTMLElement[]} sourceItems
694
+ * @return {string}
695
+ */
696
+ function getSourceItemsSignature(sourceItems) {
697
+ return sourceItems
698
+ .map((item) => {
699
+ return [
700
+ getNavigationItemId(item),
701
+ item.className || "",
702
+ item.textContent || "",
703
+ item.querySelectorAll("li").length,
704
+ ].join(":");
705
+ })
706
+ .join("|");
707
+ }
708
+
709
+ /**
710
+ * @private
711
+ * @param {HTMLElement} item
712
+ * @return {number}
713
+ */
714
+ function getNavigationItemId(item) {
715
+ let id = navigationItemIds.get(item);
716
+ if (id === undefined) {
717
+ id = ++navigationItemId;
718
+ navigationItemIds.set(item, id);
719
+ }
720
+ return id;
721
+ }
722
+
723
+ /**
724
+ * @private
725
+ * @param {object} params
726
+ * @return {string}
727
+ */
728
+ function getLayoutSignature({ fit, navWidth, rest }) {
729
+ return [
730
+ navWidth,
731
+ fit.map(getNavigationItemId).join(","),
732
+ rest.map(getNavigationItemId).join(","),
733
+ ].join("::");
734
+ }
735
+
736
+ /**
737
+ * @private
738
+ * @param {HTMLElement} element
739
+ * @param {string} property
740
+ * @param {string} value
741
+ * @return {void}
742
+ */
743
+ function setStyleValue(element, property, value) {
744
+ if (!(element instanceof HTMLElement)) {
745
+ return;
746
+ }
747
+ if (element.style[property] !== value) {
748
+ element.style[property] = value;
749
+ }
750
+ }
751
+
752
+ /**
753
+ * @private
754
+ * @param {HTMLElement} element
755
+ * @param {HTMLElement[]} children
756
+ * @return {void}
757
+ */
758
+ function replaceChildrenIfChanged(element, children) {
759
+ const currentSignature = Array.from(element.children)
760
+ .map((child) => [child.className || "", child.textContent || ""].join(":"))
761
+ .join("|");
762
+ const nextSignature = children
763
+ .map((child) => [child.className || "", child.textContent || ""].join(":"))
764
+ .join("|");
765
+ if (currentSignature !== nextSignature) {
766
+ element.replaceChildren(...children);
767
+ }
768
+ }
769
+
598
770
  /**
599
771
  * Measures available space and distributes slotted navigation items between
600
772
  * the visible list and the hidden hamburger menu list.
@@ -618,12 +790,12 @@ function populateTabs() {
618
790
  (ul) => ul.parentElement === this,
619
791
  );
620
792
 
621
- visibleList.innerHTML = "";
622
- hiddenList.innerHTML = "";
623
- hamburgerButton.style.display = "none";
624
793
  this.style.visibility = "hidden";
625
794
 
626
795
  if (!topLevelUl) {
796
+ replaceChildrenIfChanged.call(this, visibleList, []);
797
+ replaceChildrenIfChanged.call(this, hiddenList, []);
798
+ setStyleValue(hamburgerButton, "display", "none");
627
799
  this.style.visibility = "visible";
628
800
  return; // Nichts zu tun
629
801
  }
@@ -631,27 +803,29 @@ function populateTabs() {
631
803
  (n) => n.tagName === "LI",
632
804
  );
633
805
  if (sourceItems.length === 0) {
806
+ replaceChildrenIfChanged.call(this, visibleList, []);
807
+ replaceChildrenIfChanged.call(this, hiddenList, []);
808
+ setStyleValue(hamburgerButton, "display", "none");
634
809
  this.style.visibility = "visible";
635
810
  return;
636
811
  }
637
812
 
638
813
  const navWidth = navEl.clientWidth;
639
-
640
- const originalDisplay = hamburgerButton.style.display;
641
- hamburgerButton.style.visibility = "hidden";
642
- hamburgerButton.style.display = "flex";
643
- const hamburgerWidth =
644
- Math.ceil(hamburgerButton.getBoundingClientRect().width) || 0;
645
- hamburgerButton.style.display = originalDisplay;
646
- hamburgerButton.style.visibility = "visible";
647
-
648
- navEl.style.overflow = "hidden";
649
- visibleList.style.flexWrap = "nowrap";
650
- visibleList.style.visibility = "hidden"; // Inhalt der Liste während Manipulation ausblenden
814
+ const sourceSignature = getSourceItemsSignature(sourceItems);
815
+ const hamburgerWidth = measureHamburgerWidth(hamburgerButton);
816
+ const measurements = getNavigationMeasurements.call(
817
+ this,
818
+ sourceItems,
819
+ sourceSignature,
820
+ visibleList,
821
+ navEl,
822
+ );
651
823
 
652
824
  const fit = [];
653
825
  const rest = [];
654
826
  let hasOverflow = false;
827
+ const availableWidth = navWidth - hamburgerWidth;
828
+ const SAFETY_MARGIN = 1;
655
829
 
656
830
  for (let i = 0; i < sourceItems.length; i++) {
657
831
  const item = sourceItems[i];
@@ -661,12 +835,7 @@ function populateTabs() {
661
835
  continue;
662
836
  }
663
837
 
664
- const liClone = cloneNavItem(item);
665
- visibleList.appendChild(liClone);
666
-
667
- const requiredWidth = liClone.offsetLeft + liClone.offsetWidth;
668
- const availableWidth = navWidth - hamburgerWidth;
669
- const SAFETY_MARGIN = 1; // 1px Sicherheitsmarge für Subpixel-Rendering
838
+ const requiredWidth = measurements.items[i]?.requiredWidth || 0;
670
839
 
671
840
  if (requiredWidth > availableWidth + SAFETY_MARGIN) {
672
841
  hasOverflow = true;
@@ -677,49 +846,40 @@ function populateTabs() {
677
846
  }
678
847
 
679
848
  if (fit.length > 0 && rest.length > 0) {
680
- const lastVisibleItem = visibleList.children[fit.length - 1];
681
- const visibleItemsWidth =
682
- lastVisibleItem.offsetLeft + lastVisibleItem.offsetWidth;
683
-
684
- const firstHiddenItemClone = cloneNavItem(rest[0]);
685
- const submenu = firstHiddenItemClone.querySelector(
686
- "ul, div[part='mega-menu']",
687
- );
688
- if (submenu) submenu.style.display = "none";
689
-
690
- visibleList.appendChild(firstHiddenItemClone);
691
- const firstHiddenItemWidth =
692
- firstHiddenItemClone.getBoundingClientRect().width;
693
- visibleList.removeChild(firstHiddenItemClone);
694
-
695
- const gap = parseFloat(getComputedStyle(visibleList).gap || "0") || 0;
696
- if (visibleItemsWidth + gap + firstHiddenItemWidth <= navWidth) {
849
+ const visibleItemsWidth = measurements.items[fit.length - 1]?.requiredWidth || 0;
850
+ const firstHiddenIndex = sourceItems.indexOf(rest[0]);
851
+ const firstHiddenItemWidth = measurements.items[firstHiddenIndex]?.width || 0;
852
+ if (
853
+ visibleItemsWidth + measurements.gap + firstHiddenItemWidth <=
854
+ navWidth
855
+ ) {
697
856
  fit.push(rest.shift());
698
857
  }
699
858
  }
700
859
 
701
- navEl.style.overflow = "";
702
- visibleList.style.flexWrap = "";
703
- visibleList.innerHTML = "";
704
-
705
- if (fit.length) {
706
- const clonedVisible = fit.map(cloneNavItem);
707
- visibleList.append(...clonedVisible);
708
- visibleList
709
- .querySelectorAll(":scope > li")
710
- .forEach((li) => setupSubmenu.call(this, li, "visible", 1));
860
+ const layoutSignature = getLayoutSignature({ fit, navWidth, rest });
861
+ if (this[layoutSignatureSymbol] === layoutSignature) {
862
+ setStyleValue(visibleList, "visibility", "visible");
863
+ this.style.visibility = "visible";
864
+ return;
711
865
  }
866
+ this[layoutSignatureSymbol] = layoutSignature;
712
867
 
713
- if (rest.length) {
714
- const clonedHidden = rest.map(cloneNavItem);
715
- hiddenList.append(...clonedHidden);
716
- hamburgerButton.style.display = "flex";
717
- hiddenList
718
- .querySelectorAll(":scope > li")
719
- .forEach((li) => setupSubmenu.call(this, li, "hidden", 1));
720
- }
868
+ const clonedVisible = fit.map(cloneNavItem);
869
+ const clonedHidden = rest.map(cloneNavItem);
870
+ replaceChildrenIfChanged.call(this, visibleList, clonedVisible);
871
+ replaceChildrenIfChanged.call(this, hiddenList, clonedHidden);
872
+
873
+ visibleList
874
+ .querySelectorAll(":scope > li")
875
+ .forEach((li) => setupSubmenu.call(this, li, "visible", 1));
876
+
877
+ setStyleValue(hamburgerButton, "display", rest.length ? "flex" : "none");
878
+ hiddenList
879
+ .querySelectorAll(":scope > li")
880
+ .forEach((li) => setupSubmenu.call(this, li, "hidden", 1));
721
881
 
722
- visibleList.style.visibility = "visible";
882
+ setStyleValue(visibleList, "visibility", "visible");
723
883
  this.style.visibility = "visible";
724
884
  fireCustomEvent(this, "monster-layout-change", {
725
885
  visibleItems: fit,
@@ -123,6 +123,16 @@ describe('Pagination', function () {
123
123
  expect(list).to.exist;
124
124
 
125
125
  list.setAttribute('data-monster-adaptive-ready', 'true');
126
+ control.refreshLayout();
127
+ list.setAttribute('data-monster-adaptive-ready', 'true');
128
+ let adaptiveReadyResetCount = 0;
129
+ const originalSetAttribute = list.setAttribute.bind(list);
130
+ list.setAttribute = (name, value) => {
131
+ if (name === 'data-monster-adaptive-ready' && value === 'false') {
132
+ adaptiveReadyResetCount++;
133
+ }
134
+ return originalSetAttribute(name, value);
135
+ };
126
136
  control.setPaginationState({currentPage: 2, totalPages: 5});
127
137
 
128
138
  expect(list.getAttribute('data-monster-adaptive-ready')).to.equal('true');
@@ -130,6 +140,7 @@ describe('Pagination', function () {
130
140
  setTimeout(() => {
131
141
  try {
132
142
  expect(list.getAttribute('data-monster-adaptive-ready')).to.equal('true');
143
+ expect(adaptiveReadyResetCount).to.equal(0);
133
144
  done();
134
145
  } catch (e) {
135
146
  done(e);
@@ -1,6 +1,7 @@
1
1
  import * as chai from "chai";
2
2
  import { chaiDom } from "../../../util/chai-dom.mjs";
3
3
  import { initJSDOM } from "../../../util/jsdom.mjs";
4
+ import { ResizeObserverMock } from "../../../util/resize-observer.mjs";
4
5
 
5
6
  let expect = chai.expect;
6
7
  chai.use(chaiDom);
@@ -94,4 +95,92 @@ describe("ButtonBar", function () {
94
95
  }
95
96
  }, 50);
96
97
  });
98
+
99
+ it("should coalesce resize layouts and avoid unchanged layout writes", async function () {
100
+ const OriginalResizeObserver = window.ResizeObserver;
101
+ const originalGlobalResizeObserver = globalThis.ResizeObserver;
102
+ const originalRequestAnimationFrame = window.requestAnimationFrame;
103
+ const originalGlobalRequestAnimationFrame = globalThis.requestAnimationFrame;
104
+
105
+ class TrackingResizeObserver extends ResizeObserverMock {
106
+ static instances = [];
107
+
108
+ constructor(callback) {
109
+ super(callback);
110
+ TrackingResizeObserver.instances.push(this);
111
+ }
112
+ }
113
+
114
+ const scheduledCallbacks = [];
115
+ const flushFrames = async () => {
116
+ while (scheduledCallbacks.length > 0) {
117
+ scheduledCallbacks.shift()();
118
+ await new Promise((resolve) => setTimeout(resolve, 0));
119
+ }
120
+ };
121
+
122
+ try {
123
+ window.ResizeObserver = TrackingResizeObserver;
124
+ globalThis.ResizeObserver = TrackingResizeObserver;
125
+ window.requestAnimationFrame = (callback) => {
126
+ scheduledCallbacks.push(callback);
127
+ return scheduledCallbacks.length;
128
+ };
129
+ globalThis.requestAnimationFrame = window.requestAnimationFrame;
130
+
131
+ const mocks = document.getElementById("mocks");
132
+ mocks.innerHTML = `
133
+ <div id="button-bar-wrapper">
134
+ <monster-button-bar id="overflow-bar"></monster-button-bar>
135
+ </div>
136
+ `;
137
+
138
+ const wrapper = document.getElementById("button-bar-wrapper");
139
+ const bar = document.getElementById("overflow-bar");
140
+
141
+ wrapper.style.boxSizing = "border-box";
142
+ wrapper.style.width = "50px";
143
+ Object.defineProperty(wrapper, "clientWidth", {
144
+ configurable: true,
145
+ value: 50,
146
+ });
147
+
148
+ const switchButton = bar.shadowRoot.querySelector(
149
+ '[data-monster-role="switch"]',
150
+ );
151
+ Object.defineProperty(switchButton, "offsetWidth", {
152
+ configurable: true,
153
+ value: 20,
154
+ });
155
+
156
+ await flushFrames();
157
+
158
+ const resizeObserver = TrackingResizeObserver.instances.find((observer) =>
159
+ observer.elements.includes(wrapper),
160
+ );
161
+ expect(resizeObserver).to.exist;
162
+
163
+ let hiddenWriteCount = 0;
164
+ const originalSetAttribute = switchButton.setAttribute.bind(switchButton);
165
+ switchButton.setAttribute = (name, value) => {
166
+ if (name === "hidden") hiddenWriteCount++;
167
+ return originalSetAttribute(name, value);
168
+ };
169
+
170
+ resizeObserver.triggerResize([]);
171
+ resizeObserver.triggerResize([]);
172
+
173
+ expect(scheduledCallbacks.length).to.equal(1);
174
+ await flushFrames();
175
+
176
+ await new Promise((resolve) => setTimeout(resolve, 0));
177
+ expect(scheduledCallbacks.length).to.equal(0);
178
+ expect(hiddenWriteCount).to.equal(0);
179
+ } finally {
180
+ window.ResizeObserver = OriginalResizeObserver;
181
+ globalThis.ResizeObserver = originalGlobalResizeObserver;
182
+ window.requestAnimationFrame = originalRequestAnimationFrame;
183
+ globalThis.requestAnimationFrame = originalGlobalRequestAnimationFrame;
184
+ }
185
+ });
97
186
  });
@@ -0,0 +1,104 @@
1
+ import * as chai from "chai";
2
+ import { initJSDOM } from "../../../util/jsdom.mjs";
3
+ import { ResizeObserverMock } from "../../../util/resize-observer.mjs";
4
+
5
+ const expect = chai.expect;
6
+
7
+ describe("SiteNavigation", function () {
8
+ let SiteNavigation;
9
+
10
+ before(function (done) {
11
+ initJSDOM()
12
+ .then(() => {
13
+ import("../../../../source/components/navigation/site-navigation.mjs")
14
+ .then((m) => {
15
+ SiteNavigation = m.SiteNavigation;
16
+ done();
17
+ })
18
+ .catch((e) => done(e));
19
+ })
20
+ .catch((e) => done(e));
21
+ });
22
+
23
+ afterEach(() => {
24
+ document.getElementById("mocks").innerHTML = "";
25
+ });
26
+
27
+ it("should register monster-site-navigation", function () {
28
+ expect(document.createElement("monster-site-navigation")).to.be.instanceof(
29
+ SiteNavigation,
30
+ );
31
+ });
32
+
33
+ it("should coalesce resize layout updates", function (done) {
34
+ const OriginalResizeObserver = window.ResizeObserver;
35
+ const originalGlobalResizeObserver = globalThis.ResizeObserver;
36
+ const originalRequestAnimationFrame = window.requestAnimationFrame;
37
+ const originalGlobalRequestAnimationFrame = globalThis.requestAnimationFrame;
38
+
39
+ class TrackingResizeObserver extends ResizeObserverMock {
40
+ static instances = [];
41
+
42
+ constructor(callback) {
43
+ super(callback);
44
+ TrackingResizeObserver.instances.push(this);
45
+ }
46
+ }
47
+
48
+ const scheduledCallbacks = [];
49
+
50
+ try {
51
+ window.ResizeObserver = TrackingResizeObserver;
52
+ globalThis.ResizeObserver = TrackingResizeObserver;
53
+ window.requestAnimationFrame = (callback) => {
54
+ scheduledCallbacks.push(callback);
55
+ return scheduledCallbacks.length;
56
+ };
57
+ globalThis.requestAnimationFrame = window.requestAnimationFrame;
58
+
59
+ const mocks = document.getElementById("mocks");
60
+ mocks.innerHTML = `
61
+ <monster-site-navigation id="nav">
62
+ <ul>
63
+ <li><a href="#one">One</a></li>
64
+ <li><a href="#two">Two</a></li>
65
+ </ul>
66
+ </monster-site-navigation>
67
+ `;
68
+
69
+ const nav = document.getElementById("nav");
70
+ const resizeObserver = TrackingResizeObserver.instances.find((observer) =>
71
+ observer.elements.includes(nav),
72
+ );
73
+ expect(resizeObserver).to.exist;
74
+
75
+ while (scheduledCallbacks.length > 0) {
76
+ scheduledCallbacks.shift()();
77
+ }
78
+ resizeObserver.triggerResize([]);
79
+ resizeObserver.triggerResize([]);
80
+
81
+ setTimeout(() => {
82
+ try {
83
+ expect(scheduledCallbacks.length).to.equal(1);
84
+ scheduledCallbacks.shift()();
85
+ done();
86
+ } catch (e) {
87
+ done(e);
88
+ } finally {
89
+ window.ResizeObserver = OriginalResizeObserver;
90
+ globalThis.ResizeObserver = originalGlobalResizeObserver;
91
+ window.requestAnimationFrame = originalRequestAnimationFrame;
92
+ globalThis.requestAnimationFrame =
93
+ originalGlobalRequestAnimationFrame;
94
+ }
95
+ }, 260);
96
+ } catch (e) {
97
+ window.ResizeObserver = OriginalResizeObserver;
98
+ globalThis.ResizeObserver = originalGlobalResizeObserver;
99
+ window.requestAnimationFrame = originalRequestAnimationFrame;
100
+ globalThis.requestAnimationFrame = originalGlobalRequestAnimationFrame;
101
+ done(e);
102
+ }
103
+ });
104
+ });
@@ -3,6 +3,7 @@ import "./prepare.js";
3
3
  import "../cases/components/layout/tabs.mjs";
4
4
  import "../cases/components/layout/slit-panel.mjs";
5
5
  import "../cases/components/layout/panel.mjs";
6
+ import "../cases/components/navigation/site-navigation.mjs";
6
7
  import "../cases/components/content/viewer.mjs";
7
8
  import "../cases/components/content/image-editor.mjs";
8
9
  import "../cases/components/form/buy-box.mjs";