@schukai/monster 4.148.7 → 4.148.8

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.148.7"}
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.148.8"}
@@ -43,7 +43,6 @@ import { positionPopper } from "./util/floating-ui.mjs";
43
43
  import { convertToPixels } from "../../dom/dimension.mjs";
44
44
  import { addErrorAttribute } from "../../dom/error.mjs";
45
45
  import { DeadMansSwitch } from "../../util/deadmansswitch.mjs";
46
- import { Processing } from "../../util/processing.mjs";
47
46
  export { ControlBar };
48
47
 
49
48
  /**
@@ -217,7 +216,7 @@ const EVENT_LAYOUT_CHANGED = "monster-control-bar-layout-changed";
217
216
  * @type {number}
218
217
  */
219
218
  const LAYOUT_CHANGED_EVENT_DELAY = 20;
220
- const LAYOUT_SWITCH_HYSTERESIS = 16;
219
+ const LAYOUT_RESIZE_EPSILON = 0.5;
221
220
 
222
221
  /**
223
222
  * @private
@@ -332,6 +331,8 @@ class ControlBar extends CustomElement {
332
331
  suppressSlotChange: false,
333
332
  suppressMutation: false,
334
333
  suppressResize: false,
334
+ itemInlineSizes: new WeakMap(),
335
+ joinedInlineOffsets: new WeakMap(),
335
336
  };
336
337
 
337
338
  initControlReferences.call(this);
@@ -643,7 +644,7 @@ function initEventHandler() {
643
644
  if (self[layoutStateSymbol]?.suppressResize) {
644
645
  return;
645
646
  }
646
- if (!hasContainerResizeEntry.call(self, entries)) {
647
+ if (!hasLayoutResizeEntry.call(self, entries)) {
647
648
  return;
648
649
  }
649
650
  scheduleLayout.call(self, { measure: true, layout: true });
@@ -721,7 +722,8 @@ function runLayout() {
721
722
  state.needsLayout = false;
722
723
  state.running = true;
723
724
 
724
- new Processing(() => {
725
+ let popperUpdate;
726
+ try {
725
727
  syncEmptyVisibility.call(this);
726
728
 
727
729
  if (needsObserve) {
@@ -741,7 +743,17 @@ function runLayout() {
741
743
 
742
744
  if (needsLayout) {
743
745
  try {
744
- rearrangeItems.call(this);
746
+ let shouldStabilize = rearrangeItems.call(this);
747
+ let stabilizationPass = 0;
748
+ const maximumStabilizationPasses = this.children.length + 1;
749
+ while (
750
+ shouldStabilize &&
751
+ stabilizationPass < maximumStabilizationPasses
752
+ ) {
753
+ calculateControlBarDimensions.call(this);
754
+ shouldStabilize = rearrangeItems.call(this);
755
+ stabilizationPass++;
756
+ }
745
757
  } catch (error) {
746
758
  addErrorAttribute(
747
759
  this,
@@ -750,27 +762,40 @@ function runLayout() {
750
762
  }
751
763
  }
752
764
 
753
- return updatePopper.call(this);
754
- })
755
- .run()
756
- .catch((error) => {
765
+ try {
766
+ popperUpdate = updatePopper.call(this);
767
+ } catch (error) {
757
768
  addErrorAttribute(
758
769
  this,
759
- error?.message ||
760
- "An error occurred while running the control bar layout",
770
+ error?.message || "An error occurred while updating the popper",
771
+ );
772
+ }
773
+ } catch (error) {
774
+ addErrorAttribute(
775
+ this,
776
+ error?.message ||
777
+ "An error occurred while running the control bar layout",
778
+ );
779
+ } finally {
780
+ state.running = false;
781
+ if (state.needsObserve || state.needsMeasure || state.needsLayout) {
782
+ scheduleLayoutFrame.call(this);
783
+ }
784
+ }
785
+
786
+ if (popperUpdate instanceof Promise) {
787
+ popperUpdate.catch((error) => {
788
+ addErrorAttribute(
789
+ this,
790
+ error?.message || "An error occurred while updating the popper",
761
791
  );
762
- })
763
- .finally(() => {
764
- state.running = false;
765
- if (state.needsObserve || state.needsMeasure || state.needsLayout) {
766
- scheduleLayoutFrame.call(this);
767
- }
768
792
  });
793
+ }
769
794
  }
770
795
 
771
796
  /**
772
797
  * @private
773
- * @return {Object}
798
+ * @return {boolean}
774
799
  */
775
800
  function rearrangeItems() {
776
801
  let space = 0;
@@ -825,7 +850,7 @@ function rearrangeItems() {
825
850
  });
826
851
  }
827
852
 
828
- const switchWidth = this[dimensionsSymbol].getVia("data.switchWidth") || 2;
853
+ const switchWidth = this[dimensionsSymbol].getVia("data.switchWidth") || 0;
829
854
 
830
855
  const layoutItems = (availableSpace) => {
831
856
  if (availableSpace < 0) {
@@ -854,67 +879,52 @@ function rearrangeItems() {
854
879
  return { visibleItemsInMainSlot, itemsToMoveToPopper };
855
880
  };
856
881
 
857
- const switchCurrentlyVisible =
858
- this[switchElementSymbol] instanceof HTMLElement &&
859
- !this[switchElementSymbol].hasAttribute("hidden");
860
- let layout;
861
- if (switchCurrentlyVisible) {
862
- layout = layoutItems(space - switchWidth - LAYOUT_SWITCH_HYSTERESIS);
863
- if (layout.itemsToMoveToPopper.length === 0) {
864
- layout = layoutItems(space);
865
- }
866
- } else {
867
- layout = layoutItems(space - LAYOUT_SWITCH_HYSTERESIS);
868
- if (layout.itemsToMoveToPopper.length > 0) {
869
- layout = layoutItems(space - switchWidth);
870
- } else {
871
- layout = layoutItems(space);
872
- }
873
- }
882
+ const totalItemWidth = itemEntries.reduce((sum, entry) => {
883
+ return entry.hidden ? sum : sum + entry.width;
884
+ }, 0);
885
+ const shouldOverflow = totalItemWidth > space;
886
+ let layout = layoutItems(shouldOverflow ? space - switchWidth : space);
874
887
 
875
888
  layout = normalizeSpacerOverflowLayout(
876
889
  layout,
877
890
  itemEntries.map((entry) => entry.element),
878
891
  );
879
- layout = stabilizeOverflowBoundary.call(
880
- this,
881
- layout,
882
- itemEntries,
883
- switchCurrentlyVisible,
884
- );
885
892
 
886
893
  const shouldShowSwitch = layout.itemsToMoveToPopper.length > 0 && hasItems;
887
- updateLastOverflowBoundary.call(
888
- this,
889
- layout,
890
- itemEntries.map((entry) => entry.element),
891
- );
892
894
  const shouldUseStackedLayout =
893
895
  shouldShowSwitch || isStackedBreakpointMatched.call(this);
896
+ let layoutChanged = false;
894
897
 
895
- suppressLayoutFeedback.call(this);
896
898
  setLayoutStackedState.call(this, shouldUseStackedLayout);
897
899
 
898
900
  for (const item of layout.itemsToMoveToPopper) {
899
901
  if (item.getAttribute("slot") !== "popper") {
900
902
  item.setAttribute("slot", "popper");
903
+ layoutChanged = true;
901
904
  }
902
905
  }
903
906
 
904
907
  for (const item of layout.visibleItemsInMainSlot) {
905
908
  if (item.hasAttribute("slot")) {
906
909
  item.removeAttribute("slot");
910
+ layoutChanged = true;
907
911
  }
908
912
  }
909
913
 
910
914
  setSwitchVisible.call(this, shouldShowSwitch);
911
- updateControlSizing.call(this, layout, shouldShowSwitch);
912
- updateJoinedBorders.call(this, layout, shouldShowSwitch);
915
+ layoutChanged =
916
+ updateControlSizing.call(this, layout, shouldShowSwitch) || layoutChanged;
917
+ layoutChanged =
918
+ updateJoinedBorders.call(this, layout, shouldShowSwitch) || layoutChanged;
913
919
  applyLayoutAlignment.call(this);
914
920
  if (!shouldShowSwitch) {
915
921
  hide.call(this);
916
922
  }
917
923
  syncEmptyVisibility.call(this);
924
+ if (layoutChanged) {
925
+ suppressLayoutFeedback.call(this);
926
+ }
927
+ return layoutChanged;
918
928
  }
919
929
 
920
930
  /**
@@ -1007,88 +1017,6 @@ function isControlItemVisible(item) {
1007
1017
  });
1008
1018
  }
1009
1019
 
1010
- /**
1011
- * @private
1012
- * @param {{visibleItemsInMainSlot: HTMLElement[], itemsToMoveToPopper: HTMLElement[]}} layout
1013
- * @param {{element: HTMLElement, hidden: boolean}[]} itemEntries
1014
- * @param {boolean} switchCurrentlyVisible
1015
- * @return {{visibleItemsInMainSlot: HTMLElement[], itemsToMoveToPopper: HTMLElement[]}}
1016
- */
1017
- function stabilizeOverflowBoundary(layout, itemEntries, switchCurrentlyVisible) {
1018
- const state = this[layoutStateSymbol];
1019
- if (!switchCurrentlyVisible || !state) {
1020
- return layout;
1021
- }
1022
-
1023
- const orderedItems = itemEntries.map((entry) => entry.element);
1024
- const nextBoundary = getFirstOverflowIndex(layout, orderedItems);
1025
- const previousBoundary = state.lastOverflowStartIndex;
1026
- if (
1027
- typeof previousBoundary !== "number" ||
1028
- typeof nextBoundary !== "number" ||
1029
- nextBoundary <= previousBoundary ||
1030
- previousBoundary < 0 ||
1031
- previousBoundary >= itemEntries.length
1032
- ) {
1033
- return layout;
1034
- }
1035
-
1036
- const popperItems = new Set();
1037
- const mainItems = new Set();
1038
- for (let index = 0; index < itemEntries.length; index++) {
1039
- const entry = itemEntries[index];
1040
- if (index >= previousBoundary && !entry.hidden) {
1041
- popperItems.add(entry.element);
1042
- } else {
1043
- mainItems.add(entry.element);
1044
- }
1045
- }
1046
-
1047
- return normalizeSpacerOverflowLayout(
1048
- {
1049
- visibleItemsInMainSlot: orderedItems.filter((item) => mainItems.has(item)),
1050
- itemsToMoveToPopper: orderedItems.filter((item) => popperItems.has(item)),
1051
- },
1052
- orderedItems,
1053
- );
1054
- }
1055
-
1056
- /**
1057
- * @private
1058
- * @param {{itemsToMoveToPopper: HTMLElement[]}} layout
1059
- * @param {HTMLElement[]} orderedItems
1060
- * @return {void}
1061
- */
1062
- function updateLastOverflowBoundary(layout, orderedItems) {
1063
- const state = this[layoutStateSymbol];
1064
- if (!state) {
1065
- return;
1066
- }
1067
-
1068
- const boundary = getFirstOverflowIndex(layout, orderedItems);
1069
- if (typeof boundary === "number") {
1070
- state.lastOverflowStartIndex = boundary;
1071
- return;
1072
- }
1073
- delete state.lastOverflowStartIndex;
1074
- }
1075
-
1076
- /**
1077
- * @private
1078
- * @param {{itemsToMoveToPopper: HTMLElement[]}} layout
1079
- * @param {HTMLElement[]} orderedItems
1080
- * @return {number|undefined}
1081
- */
1082
- function getFirstOverflowIndex(layout, orderedItems) {
1083
- const popperItems = new Set(layout.itemsToMoveToPopper);
1084
- for (let index = 0; index < orderedItems.length; index++) {
1085
- if (popperItems.has(orderedItems[index])) {
1086
- return index;
1087
- }
1088
- }
1089
- return undefined;
1090
- }
1091
-
1092
1020
  /**
1093
1021
  * @private
1094
1022
  * @param {{visibleItemsInMainSlot: HTMLElement[], itemsToMoveToPopper: HTMLElement[]}} layout
@@ -1160,15 +1088,15 @@ function calcBoxWidth(node) {
1160
1088
  throw new Error("no computed style");
1161
1089
  }
1162
1090
  const bounding = node.getBoundingClientRect();
1091
+ this[layoutStateSymbol]?.itemInlineSizes?.set(node, bounding.width);
1092
+ const joinedInlineOffset =
1093
+ this[layoutStateSymbol]?.joinedInlineOffsets?.get(node) || 0;
1163
1094
 
1164
1095
  return (
1165
- getComputedCssPixels(dim["border-left-width"]) +
1166
- getComputedCssPixels(dim["padding-left"]) +
1167
1096
  getComputedCssPixels(dim["margin-left"]) +
1168
1097
  bounding["width"] +
1169
- getComputedCssPixels(dim["border-right-width"]) +
1170
- getComputedCssPixels(dim["margin-right"]) +
1171
- getComputedCssPixels(dim["padding-right"])
1098
+ getComputedCssPixels(dim["margin-right"]) -
1099
+ joinedInlineOffset
1172
1100
  );
1173
1101
  }
1174
1102
 
@@ -1197,7 +1125,7 @@ function getComputedCssPixels(value) {
1197
1125
  * @private
1198
1126
  * @param {Object} layout
1199
1127
  * @param {boolean} shouldShowSwitch
1200
- * @return {void}
1128
+ * @return {boolean}
1201
1129
  */
1202
1130
  function updateControlSizing(layout, shouldShowSwitch) {
1203
1131
  const mainItems = [...layout.visibleItemsInMainSlot];
@@ -1206,42 +1134,50 @@ function updateControlSizing(layout, shouldShowSwitch) {
1206
1134
  }
1207
1135
 
1208
1136
  const controlItems = [...mainItems, ...layout.itemsToMoveToPopper];
1137
+ let changed = false;
1209
1138
  for (const element of controlItems) {
1210
- applyControlSizing.call(this, element);
1139
+ changed = applyControlSizing.call(this, element) || changed;
1211
1140
  }
1141
+ return changed;
1212
1142
  }
1213
1143
 
1214
1144
  /**
1215
1145
  * @private
1216
1146
  * @param {HTMLElement} element
1217
- * @return {void}
1147
+ * @return {boolean}
1218
1148
  */
1219
1149
  function applyControlSizing(element) {
1220
- setStylePropertyIfChanged(element, "boxSizing", "border-box");
1221
- setStylePropertyIfChanged(
1222
- element,
1223
- "height",
1224
- "var(--monster-control-bar-height)",
1225
- );
1226
- setStylePropertyIfChanged(
1227
- element,
1228
- "minHeight",
1229
- "var(--monster-control-bar-height)",
1230
- );
1150
+ let changed = setStylePropertyIfChanged(element, "boxSizing", "border-box");
1151
+ changed =
1152
+ setStylePropertyIfChanged(
1153
+ element,
1154
+ "height",
1155
+ "var(--monster-control-bar-height)",
1156
+ ) || changed;
1157
+ changed =
1158
+ setStylePropertyIfChanged(
1159
+ element,
1160
+ "minHeight",
1161
+ "var(--monster-control-bar-height)",
1162
+ ) || changed;
1231
1163
  if (isWrapperElement(element)) {
1232
- setStylePropertyIfChanged(element, "display", "flex");
1233
- setStylePropertyIfChanged(element, "alignItems", "stretch");
1164
+ changed = setStylePropertyIfChanged(element, "display", "flex") || changed;
1165
+ changed =
1166
+ setStylePropertyIfChanged(element, "alignItems", "stretch") || changed;
1234
1167
  }
1235
1168
 
1236
1169
  for (const control of getContainedControlElements(element)) {
1237
- setStylePropertyIfChanged(control, "display", "flex");
1238
- setStylePropertyIfChanged(control, "boxSizing", "border-box");
1239
- setStylePropertyIfChanged(control, "height", "100%");
1240
- setStylePropertyIfChanged(control, "minHeight", "0px");
1170
+ changed = setStylePropertyIfChanged(control, "display", "flex") || changed;
1171
+ changed =
1172
+ setStylePropertyIfChanged(control, "boxSizing", "border-box") || changed;
1173
+ changed = setStylePropertyIfChanged(control, "height", "100%") || changed;
1174
+ changed = setStylePropertyIfChanged(control, "minHeight", "0px") || changed;
1241
1175
  if (control.tagName === "MONSTER-INPUT-GROUP") {
1242
- setStylePropertyIfChanged(control, "flex", "1 1 auto");
1176
+ changed =
1177
+ setStylePropertyIfChanged(control, "flex", "1 1 auto") || changed;
1243
1178
  }
1244
1179
  }
1180
+ return changed;
1245
1181
  }
1246
1182
 
1247
1183
  /**
@@ -1295,7 +1231,7 @@ function isTopLevelContainedControl(container, control) {
1295
1231
  * @private
1296
1232
  * @param {Object} layout
1297
1233
  * @param {boolean} shouldShowSwitch
1298
- * @return {void}
1234
+ * @return {boolean}
1299
1235
  */
1300
1236
  function updateJoinedBorders(layout, shouldShowSwitch) {
1301
1237
  const mainItems = [...layout.visibleItemsInMainSlot];
@@ -1331,7 +1267,11 @@ function updateJoinedBorders(layout, shouldShowSwitch) {
1331
1267
  "bottom",
1332
1268
  "top",
1333
1269
  );
1334
- applyJoinedBorderOffsets.call(this, marginLeftByElement, marginTopByElement);
1270
+ return applyJoinedBorderOffsets.call(
1271
+ this,
1272
+ marginLeftByElement,
1273
+ marginTopByElement,
1274
+ );
1335
1275
  }
1336
1276
 
1337
1277
  /**
@@ -1432,21 +1372,26 @@ function collectBlockJoinedBorders(elements, marginTopByElement) {
1432
1372
  * @private
1433
1373
  * @param {Map<HTMLElement, string>} marginLeftByElement
1434
1374
  * @param {Map<HTMLElement, string>} marginTopByElement
1435
- * @return {void}
1375
+ * @return {boolean}
1436
1376
  */
1437
1377
  function applyJoinedBorderOffsets(marginLeftByElement, marginTopByElement) {
1378
+ let changed = false;
1438
1379
  for (const element of getJoinedBorderOffsetElements.call(this)) {
1439
- setStylePropertyIfChanged(
1380
+ const marginLeft = marginLeftByElement.get(element) || "";
1381
+ changed =
1382
+ setStylePropertyIfChanged(element, "marginLeft", marginLeft) || changed;
1383
+ this[layoutStateSymbol]?.joinedInlineOffsets?.set(
1440
1384
  element,
1441
- "marginLeft",
1442
- marginLeftByElement.get(element) || "",
1443
- );
1444
- setStylePropertyIfChanged(
1445
- element,
1446
- "marginTop",
1447
- marginTopByElement.get(element) || "",
1385
+ getComputedCssPixels(marginLeft),
1448
1386
  );
1387
+ changed =
1388
+ setStylePropertyIfChanged(
1389
+ element,
1390
+ "marginTop",
1391
+ marginTopByElement.get(element) || "",
1392
+ ) || changed;
1449
1393
  }
1394
+ return changed;
1450
1395
  }
1451
1396
 
1452
1397
  /**
@@ -1472,7 +1417,7 @@ function getJoinedBorderOffsetElements() {
1472
1417
  * @param {HTMLElement} element
1473
1418
  * @param {"marginLeft"|"marginTop"} property
1474
1419
  * @param {string} value
1475
- * @return {void}
1420
+ * @return {boolean}
1476
1421
  */
1477
1422
  function setStylePropertyIfChanged(element, property, value) {
1478
1423
  if (property.startsWith("--")) {
@@ -1482,13 +1427,16 @@ function setStylePropertyIfChanged(element, property, value) {
1482
1427
  } else {
1483
1428
  element.style.setProperty(property, value);
1484
1429
  }
1430
+ return true;
1485
1431
  }
1486
- return;
1432
+ return false;
1487
1433
  }
1488
1434
 
1489
1435
  if (element.style[property] !== value) {
1490
1436
  element.style[property] = value;
1437
+ return true;
1491
1438
  }
1439
+ return false;
1492
1440
  }
1493
1441
 
1494
1442
  /**
@@ -1667,10 +1615,13 @@ function calculateControlBarDimensions() {
1667
1615
  }
1668
1616
 
1669
1617
  if (this[switchElementSymbol]) {
1670
- this[dimensionsSymbol].setVia(
1671
- "data.switchWidth",
1672
- this[switchElementSymbol].offsetWidth,
1673
- );
1618
+ let cachedSwitchWidth = 0;
1619
+ try {
1620
+ cachedSwitchWidth =
1621
+ this[dimensionsSymbol].getVia("data.switchWidth") || 0;
1622
+ } catch {}
1623
+ const switchWidth = measureSwitchWidth.call(this, cachedSwitchWidth);
1624
+ this[dimensionsSymbol].setVia("data.switchWidth", switchWidth);
1674
1625
  }
1675
1626
 
1676
1627
  this[dimensionsSymbol].setVia("data.calculated", true);
@@ -1836,17 +1787,101 @@ function suppressLayoutFeedback() {
1836
1787
  * @param {ResizeObserverEntry[]} entries
1837
1788
  * @return {boolean}
1838
1789
  */
1839
- function hasContainerResizeEntry(entries) {
1790
+ function hasLayoutResizeEntry(entries) {
1840
1791
  const stackedBreakpointContainer =
1841
1792
  resolveStackedBreakpointContainer.call(this);
1842
1793
  return entries.some((entry) => {
1843
- return (
1794
+ if (
1844
1795
  entry.target === this.parentElement ||
1845
1796
  entry.target === stackedBreakpointContainer
1797
+ ) {
1798
+ return true;
1799
+ }
1800
+
1801
+ if (
1802
+ !(entry.target instanceof HTMLElement) ||
1803
+ entry.target.parentElement !== this
1804
+ ) {
1805
+ return false;
1806
+ }
1807
+
1808
+ const inlineSize = entry.target.getBoundingClientRect().width;
1809
+ if (!(inlineSize > 0)) {
1810
+ return false;
1811
+ }
1812
+
1813
+ const itemInlineSizes = this[layoutStateSymbol]?.itemInlineSizes;
1814
+ if (!(itemInlineSizes instanceof WeakMap)) {
1815
+ return true;
1816
+ }
1817
+
1818
+ const previousInlineSize = itemInlineSizes.get(entry.target);
1819
+ itemInlineSizes.set(entry.target, inlineSize);
1820
+ return (
1821
+ typeof previousInlineSize !== "number" ||
1822
+ Math.abs(previousInlineSize - inlineSize) > LAYOUT_RESIZE_EPSILON
1846
1823
  );
1847
1824
  });
1848
1825
  }
1849
1826
 
1827
+ /**
1828
+ * Measure the overflow switch without exposing it visually. A switch hidden
1829
+ * with `display: none` has no layout width, so it must be made measurable for
1830
+ * the duration of the synchronous read.
1831
+ *
1832
+ * @private
1833
+ * @param {number} cachedWidth
1834
+ * @return {number}
1835
+ */
1836
+ function measureSwitchWidth(cachedWidth = 0) {
1837
+ const element = this[switchElementSymbol];
1838
+ if (!(element instanceof HTMLElement)) {
1839
+ return 0;
1840
+ }
1841
+
1842
+ const visibleWidth = element.offsetWidth;
1843
+ if (visibleWidth > 0) {
1844
+ return visibleWidth;
1845
+ }
1846
+ if (cachedWidth > 0) {
1847
+ return cachedWidth;
1848
+ }
1849
+
1850
+ const hadHiddenAttribute = element.hasAttribute("hidden");
1851
+ const hadHiddenClass = element.classList.contains("hidden");
1852
+ const previousVisibility = element.style.getPropertyValue("visibility");
1853
+ const previousVisibilityPriority =
1854
+ element.style.getPropertyPriority("visibility");
1855
+
1856
+ element.style.setProperty("visibility", "hidden", "important");
1857
+ if (hadHiddenAttribute) {
1858
+ element.removeAttribute("hidden");
1859
+ }
1860
+ if (hadHiddenClass) {
1861
+ element.classList.remove("hidden");
1862
+ }
1863
+
1864
+ const measuredWidth = element.offsetWidth;
1865
+
1866
+ if (hadHiddenClass) {
1867
+ element.classList.add("hidden");
1868
+ }
1869
+ if (hadHiddenAttribute) {
1870
+ element.setAttribute("hidden", "");
1871
+ }
1872
+ if (previousVisibility === "") {
1873
+ element.style.removeProperty("visibility");
1874
+ } else {
1875
+ element.style.setProperty(
1876
+ "visibility",
1877
+ previousVisibility,
1878
+ previousVisibilityPriority,
1879
+ );
1880
+ }
1881
+
1882
+ return measuredWidth;
1883
+ }
1884
+
1850
1885
  /**
1851
1886
  * @private
1852
1887
  * @param {boolean} visible
@@ -1439,7 +1439,7 @@ describe("ControlBar", function () {
1439
1439
  }
1440
1440
  });
1441
1441
 
1442
- it("should keep the overflow switch stable across the switch-width threshold", async function () {
1442
+ it("should produce the same overflow layout across repeated switch-width threshold crossings", async function () {
1443
1443
  const OriginalResizeObserver = window.ResizeObserver;
1444
1444
  const originalGlobalResizeObserver = globalThis.ResizeObserver;
1445
1445
  const originalRequestAnimationFrame = window.requestAnimationFrame;
@@ -1524,7 +1524,11 @@ describe("ControlBar", function () {
1524
1524
  );
1525
1525
  Object.defineProperty(switchButton, "offsetWidth", {
1526
1526
  configurable: true,
1527
- value: 20,
1527
+ get: () =>
1528
+ switchButton.hasAttribute("hidden") ||
1529
+ switchButton.classList.contains("hidden")
1530
+ ? 0
1531
+ : 20,
1528
1532
  });
1529
1533
 
1530
1534
  await flushFrames();
@@ -1538,25 +1542,25 @@ describe("ControlBar", function () {
1538
1542
  wrapperWidth = 155;
1539
1543
  resizeObserver.triggerResize([{ target: wrapper }]);
1540
1544
  await flushFrames();
1541
- expect(switchButton.hasAttribute("hidden")).to.be.false;
1542
- expect(document.getElementById("threshold-three").getAttribute("slot")).to
1543
- .equal("popper");
1545
+ expect(switchButton.hasAttribute("hidden")).to.be.true;
1546
+ expect(document.getElementById("threshold-three").hasAttribute("slot")).to
1547
+ .be.false;
1544
1548
 
1545
- wrapperWidth = 175;
1549
+ wrapperWidth = 145;
1546
1550
  resizeObserver.triggerResize([{ target: wrapper }]);
1547
1551
  await flushFrames();
1548
1552
  expect(switchButton.hasAttribute("hidden")).to.be.false;
1549
1553
  expect(document.getElementById("threshold-three").getAttribute("slot")).to
1550
1554
  .equal("popper");
1551
1555
 
1552
- wrapperWidth = 190;
1556
+ wrapperWidth = 155;
1553
1557
  resizeObserver.triggerResize([{ target: wrapper }]);
1554
1558
  await flushFrames();
1555
1559
  expect(switchButton.hasAttribute("hidden")).to.be.true;
1556
1560
  expect(document.getElementById("threshold-three").hasAttribute("slot")).to
1557
1561
  .be.false;
1558
1562
 
1559
- wrapperWidth = 160;
1563
+ wrapperWidth = 145;
1560
1564
  resizeObserver.triggerResize([{ target: wrapper }]);
1561
1565
  await flushFrames();
1562
1566
  expect(switchButton.hasAttribute("hidden")).to.be.false;
@@ -1570,6 +1574,201 @@ describe("ControlBar", function () {
1570
1574
  }
1571
1575
  });
1572
1576
 
1577
+ it("should not count rendered padding and borders twice when controls fit", async function () {
1578
+ const originalRequestAnimationFrame = window.requestAnimationFrame;
1579
+ const originalGlobalRequestAnimationFrame = globalThis.requestAnimationFrame;
1580
+ const scheduledCallbacks = [];
1581
+ const flushFrames = async () => {
1582
+ while (scheduledCallbacks.length > 0) {
1583
+ scheduledCallbacks.shift()();
1584
+ await new Promise((resolve) => setTimeout(resolve, 0));
1585
+ }
1586
+ };
1587
+
1588
+ try {
1589
+ window.requestAnimationFrame = (callback) => {
1590
+ scheduledCallbacks.push(callback);
1591
+ return scheduledCallbacks.length;
1592
+ };
1593
+ globalThis.requestAnimationFrame = window.requestAnimationFrame;
1594
+
1595
+ const mocks = document.getElementById("mocks");
1596
+ mocks.innerHTML = `
1597
+ <div id="border-box-wrapper">
1598
+ <monster-control-bar id="border-box-bar">
1599
+ <button id="border-box-one" style="padding: 10px; border: 2px solid">One</button>
1600
+ <button id="border-box-two" style="padding: 10px; border: 2px solid">Two</button>
1601
+ </monster-control-bar>
1602
+ </div>
1603
+ `;
1604
+
1605
+ const wrapper = document.getElementById("border-box-wrapper");
1606
+ const bar = document.getElementById("border-box-bar");
1607
+ const buttons = [
1608
+ document.getElementById("border-box-one"),
1609
+ document.getElementById("border-box-two"),
1610
+ ];
1611
+
1612
+ wrapper.style.boxSizing = "border-box";
1613
+ Object.defineProperty(wrapper, "clientWidth", {
1614
+ configurable: true,
1615
+ value: 130,
1616
+ });
1617
+
1618
+ for (const button of buttons) {
1619
+ Object.defineProperty(button, "offsetWidth", {
1620
+ configurable: true,
1621
+ value: 60,
1622
+ });
1623
+ Object.defineProperty(button, "offsetHeight", {
1624
+ configurable: true,
1625
+ value: 30,
1626
+ });
1627
+ button.getBoundingClientRect = () => ({
1628
+ width: 60,
1629
+ height: 30,
1630
+ top: 0,
1631
+ right: 60,
1632
+ bottom: 30,
1633
+ left: 0,
1634
+ x: 0,
1635
+ y: 0,
1636
+ toJSON: () => {},
1637
+ });
1638
+ }
1639
+
1640
+ const switchButton = bar.shadowRoot.querySelector(
1641
+ '[data-monster-role="switch"]',
1642
+ );
1643
+ Object.defineProperty(switchButton, "offsetWidth", {
1644
+ configurable: true,
1645
+ get: () =>
1646
+ switchButton.hasAttribute("hidden") ||
1647
+ switchButton.classList.contains("hidden")
1648
+ ? 0
1649
+ : 20,
1650
+ });
1651
+
1652
+ await flushFrames();
1653
+
1654
+ expect(switchButton.hasAttribute("hidden")).to.be.true;
1655
+ expect(buttons.every((button) => !button.hasAttribute("slot"))).to.be.true;
1656
+ } finally {
1657
+ window.requestAnimationFrame = originalRequestAnimationFrame;
1658
+ globalThis.requestAnimationFrame = originalGlobalRequestAnimationFrame;
1659
+ }
1660
+ });
1661
+
1662
+ it("should recalculate overflow when a visible control width changes", async function () {
1663
+ const OriginalResizeObserver = window.ResizeObserver;
1664
+ const originalGlobalResizeObserver = globalThis.ResizeObserver;
1665
+ const originalRequestAnimationFrame = window.requestAnimationFrame;
1666
+ const originalGlobalRequestAnimationFrame = globalThis.requestAnimationFrame;
1667
+
1668
+ class TrackingResizeObserver extends ResizeObserverMock {
1669
+ static instances = [];
1670
+
1671
+ constructor(callback) {
1672
+ super(callback);
1673
+ TrackingResizeObserver.instances.push(this);
1674
+ }
1675
+ }
1676
+
1677
+ const scheduledCallbacks = [];
1678
+ const flushFrames = async () => {
1679
+ while (scheduledCallbacks.length > 0) {
1680
+ scheduledCallbacks.shift()();
1681
+ await new Promise((resolve) => setTimeout(resolve, 0));
1682
+ }
1683
+ };
1684
+
1685
+ try {
1686
+ window.ResizeObserver = TrackingResizeObserver;
1687
+ globalThis.ResizeObserver = TrackingResizeObserver;
1688
+ window.requestAnimationFrame = (callback) => {
1689
+ scheduledCallbacks.push(callback);
1690
+ return scheduledCallbacks.length;
1691
+ };
1692
+ globalThis.requestAnimationFrame = window.requestAnimationFrame;
1693
+
1694
+ const mocks = document.getElementById("mocks");
1695
+ mocks.innerHTML = `
1696
+ <div id="item-resize-wrapper">
1697
+ <monster-control-bar id="item-resize-bar">
1698
+ <button id="item-resize-one">One</button>
1699
+ <button id="item-resize-two">Two</button>
1700
+ </monster-control-bar>
1701
+ </div>
1702
+ `;
1703
+
1704
+ const wrapper = document.getElementById("item-resize-wrapper");
1705
+ const bar = document.getElementById("item-resize-bar");
1706
+ const buttons = [
1707
+ document.getElementById("item-resize-one"),
1708
+ document.getElementById("item-resize-two"),
1709
+ ];
1710
+ const widths = [60, 60];
1711
+
1712
+ wrapper.style.boxSizing = "border-box";
1713
+ Object.defineProperty(wrapper, "clientWidth", {
1714
+ configurable: true,
1715
+ value: 160,
1716
+ });
1717
+
1718
+ buttons.forEach((button, index) => {
1719
+ Object.defineProperty(button, "offsetWidth", {
1720
+ configurable: true,
1721
+ get: () => widths[index],
1722
+ });
1723
+ Object.defineProperty(button, "offsetHeight", {
1724
+ configurable: true,
1725
+ value: 30,
1726
+ });
1727
+ button.getBoundingClientRect = () => ({
1728
+ width: widths[index],
1729
+ height: 30,
1730
+ top: 0,
1731
+ right: widths[index],
1732
+ bottom: 30,
1733
+ left: 0,
1734
+ x: 0,
1735
+ y: 0,
1736
+ toJSON: () => {},
1737
+ });
1738
+ });
1739
+
1740
+ const switchButton = bar.shadowRoot.querySelector(
1741
+ '[data-monster-role="switch"]',
1742
+ );
1743
+ Object.defineProperty(switchButton, "offsetWidth", {
1744
+ configurable: true,
1745
+ value: 20,
1746
+ });
1747
+
1748
+ await flushFrames();
1749
+ expect(buttons.every((button) => !button.hasAttribute("slot"))).to.be.true;
1750
+
1751
+ const resizeObserver = TrackingResizeObserver.instances.find((observer) =>
1752
+ observer.elements.includes(wrapper),
1753
+ );
1754
+ expect(resizeObserver).to.exist;
1755
+
1756
+ widths[0] = 110;
1757
+ resizeObserver.triggerResize([{ target: buttons[0] }]);
1758
+ expect(scheduledCallbacks.length).to.equal(1);
1759
+ await flushFrames();
1760
+
1761
+ expect(buttons[0].hasAttribute("slot")).to.be.false;
1762
+ expect(buttons[1].getAttribute("slot")).to.equal("popper");
1763
+ expect(switchButton.hasAttribute("hidden")).to.be.false;
1764
+ } finally {
1765
+ window.ResizeObserver = OriginalResizeObserver;
1766
+ globalThis.ResizeObserver = originalGlobalResizeObserver;
1767
+ window.requestAnimationFrame = originalRequestAnimationFrame;
1768
+ globalThis.requestAnimationFrame = originalGlobalRequestAnimationFrame;
1769
+ }
1770
+ });
1771
+
1573
1772
  it("should move spacers with adjacent overflow controls", async function () {
1574
1773
  const OriginalResizeObserver = window.ResizeObserver;
1575
1774
  const originalGlobalResizeObserver = globalThis.ResizeObserver;
@@ -1787,7 +1986,7 @@ describe("ControlBar", function () {
1787
1986
  }
1788
1987
  });
1789
1988
 
1790
- it("should keep the previous overflow boundary while the switch remains visible", async function () {
1989
+ it("should restore the maximal fitting prefix regardless of resize direction", async function () {
1791
1990
  const OriginalResizeObserver = window.ResizeObserver;
1792
1991
  const originalGlobalResizeObserver = globalThis.ResizeObserver;
1793
1992
  const originalRequestAnimationFrame = window.requestAnimationFrame;
@@ -1890,13 +2089,22 @@ describe("ControlBar", function () {
1890
2089
  wrapperWidth = 190;
1891
2090
  resizeObserver.triggerResize([{ target: wrapper }]);
1892
2091
  await flushFrames();
2092
+ expect(switchButton.hasAttribute("hidden")).to.be.true;
2093
+ expect(document.getElementById("sticky-overflow-wide").hasAttribute("slot"))
2094
+ .to.be.false;
2095
+ expect(document.getElementById("sticky-overflow-small").hasAttribute("slot"))
2096
+ .to.be.false;
2097
+
2098
+ wrapperWidth = 90;
2099
+ resizeObserver.triggerResize([{ target: wrapper }]);
2100
+ await flushFrames();
1893
2101
  expect(switchButton.hasAttribute("hidden")).to.be.false;
1894
2102
  expect(document.getElementById("sticky-overflow-wide").getAttribute("slot"))
1895
2103
  .to.equal("popper");
1896
2104
  expect(document.getElementById("sticky-overflow-small").getAttribute("slot"))
1897
2105
  .to.equal("popper");
1898
2106
 
1899
- wrapperWidth = 220;
2107
+ wrapperWidth = 190;
1900
2108
  resizeObserver.triggerResize([{ target: wrapper }]);
1901
2109
  await flushFrames();
1902
2110
  expect(switchButton.hasAttribute("hidden")).to.be.true;