@schukai/monster 4.145.1 → 4.145.3

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.145.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.145.3"}
@@ -219,6 +219,35 @@ const EVENT_LAYOUT_CHANGED = "monster-control-bar-layout-changed";
219
219
  const LAYOUT_CHANGED_EVENT_DELAY = 20;
220
220
  const LAYOUT_SWITCH_HYSTERESIS = 16;
221
221
 
222
+ /**
223
+ * @private
224
+ * @type {string}
225
+ */
226
+ const CONTAINED_CONTROL_SELECTOR = [
227
+ "monster-input-group",
228
+ "monster-select",
229
+ "monster-button",
230
+ "monster-state-button",
231
+ "monster-message-state-button",
232
+ "monster-action-button",
233
+ "monster-api-button",
234
+ "monster-confirm-button",
235
+ "monster-popper-button",
236
+ ].join(",");
237
+
238
+ /**
239
+ * @private
240
+ * @type {string}
241
+ */
242
+ const VISUAL_BORDER_CONTROL_SELECTOR = [
243
+ "button",
244
+ "input",
245
+ "select",
246
+ "textarea",
247
+ CONTAINED_CONTROL_SELECTOR,
248
+ "[data-monster-role=control]",
249
+ ].join(",");
250
+
222
251
  /**
223
252
  * A control bar control.
224
253
  *
@@ -503,6 +532,12 @@ function initEventHandler() {
503
532
 
504
533
  const mutationCallback = (mutationList) => {
505
534
  if (self[layoutStateSymbol]?.suppressMutation) {
535
+ mutationList = mutationList.filter(
536
+ (mutation) => mutation.attributeName !== "style",
537
+ );
538
+ }
539
+
540
+ if (mutationList.length === 0) {
506
541
  return;
507
542
  }
508
543
 
@@ -1213,24 +1248,36 @@ function isWrapperElement(element) {
1213
1248
  * @return {HTMLElement[]}
1214
1249
  */
1215
1250
  function getContainedControlElements(element) {
1216
- if (!isWrapperElement(element)) {
1251
+ if (element.matches(CONTAINED_CONTROL_SELECTOR)) {
1217
1252
  return [];
1218
1253
  }
1219
1254
 
1220
1255
  return Array.from(
1221
- element.querySelectorAll(
1222
- [
1223
- "monster-input-group",
1224
- "monster-select",
1225
- "monster-button",
1226
- "monster-state-button",
1227
- "monster-message-state-button",
1228
- "monster-action-button",
1229
- "monster-api-button",
1230
- "monster-confirm-button",
1231
- ].join(","),
1232
- ),
1233
- ).filter((control) => control instanceof HTMLElement);
1256
+ element.querySelectorAll(CONTAINED_CONTROL_SELECTOR),
1257
+ ).filter((control) => {
1258
+ return (
1259
+ control instanceof HTMLElement &&
1260
+ isTopLevelContainedControl(element, control)
1261
+ );
1262
+ });
1263
+ }
1264
+
1265
+ /**
1266
+ * @private
1267
+ * @param {HTMLElement} container
1268
+ * @param {HTMLElement} control
1269
+ * @return {boolean}
1270
+ */
1271
+ function isTopLevelContainedControl(container, control) {
1272
+ let parent = control.parentElement;
1273
+ while (parent instanceof HTMLElement && parent !== container) {
1274
+ if (parent.matches(CONTAINED_CONTROL_SELECTOR)) {
1275
+ return false;
1276
+ }
1277
+ parent = parent.parentElement;
1278
+ }
1279
+
1280
+ return true;
1234
1281
  }
1235
1282
 
1236
1283
  /**
@@ -1253,6 +1300,13 @@ function updateJoinedBorders(layout, shouldShowSwitch) {
1253
1300
  const marginTopByElement = new Map();
1254
1301
 
1255
1302
  collectInlineJoinedBorders.call(this, mainItems, marginLeftByElement);
1303
+ for (const item of [...mainItems, ...layout.itemsToMoveToPopper]) {
1304
+ collectInlineJoinedBorders.call(
1305
+ this,
1306
+ getContainedControlElements(item),
1307
+ marginLeftByElement,
1308
+ );
1309
+ }
1256
1310
  collectBlockJoinedBorders.call(
1257
1311
  this,
1258
1312
  layout.itemsToMoveToPopper,
@@ -1392,6 +1446,9 @@ function getJoinedBorderOffsetElements() {
1392
1446
  const elements = Array.from(this.children).filter(
1393
1447
  (element) => element instanceof HTMLElement,
1394
1448
  );
1449
+ for (const element of [...elements]) {
1450
+ elements.push(...getContainedControlElements(element));
1451
+ }
1395
1452
  if (this[popperNavElementSymbol] instanceof HTMLElement) {
1396
1453
  elements.push(this[popperNavElementSymbol]);
1397
1454
  }
@@ -1458,22 +1515,6 @@ function getVisualBorderElement(element, side) {
1458
1515
  return this[switchElementSymbol] || element;
1459
1516
  }
1460
1517
 
1461
- const selector = [
1462
- "button",
1463
- "input",
1464
- "select",
1465
- "textarea",
1466
- "monster-input-group",
1467
- "monster-select",
1468
- "monster-button",
1469
- "monster-state-button",
1470
- "monster-message-state-button",
1471
- "monster-action-button",
1472
- "monster-api-button",
1473
- "monster-confirm-button",
1474
- "monster-popper-button",
1475
- "[data-monster-role=control]",
1476
- ].join(",");
1477
1518
  if (element.shadowRoot instanceof ShadowRoot) {
1478
1519
  const primaryControl = element.shadowRoot.querySelector(
1479
1520
  "[data-monster-role=button],button,input,select,textarea",
@@ -1482,7 +1523,9 @@ function getVisualBorderElement(element, side) {
1482
1523
  return getVisualBorderElement.call(this, primaryControl, side);
1483
1524
  }
1484
1525
 
1485
- const control = element.shadowRoot.querySelector(selector);
1526
+ const control = element.shadowRoot.querySelector(
1527
+ VISUAL_BORDER_CONTROL_SELECTOR,
1528
+ );
1486
1529
  if (control instanceof HTMLElement && control !== element) {
1487
1530
  return getVisualBorderElement.call(this, control, side);
1488
1531
  }
@@ -1490,9 +1533,9 @@ function getVisualBorderElement(element, side) {
1490
1533
  return element;
1491
1534
  }
1492
1535
 
1493
- const candidates = Array.from(element.querySelectorAll(selector)).filter(
1494
- (candidate) => candidate instanceof HTMLElement,
1495
- );
1536
+ const candidates = Array.from(
1537
+ element.querySelectorAll(VISUAL_BORDER_CONTROL_SELECTOR),
1538
+ ).filter((candidate) => candidate instanceof HTMLElement);
1496
1539
  if (candidates.length === 0) {
1497
1540
  return element;
1498
1541
  }
@@ -86,6 +86,12 @@ const controlElementSymbol = Symbol("controlElement");
86
86
  * @type {symbol}
87
87
  */
88
88
  const navElementSymbol = Symbol("navElement");
89
+
90
+ /**
91
+ * @private
92
+ * @type {symbol}
93
+ */
94
+ const measurementNavElementSymbol = Symbol("measurementNavElement");
89
95
  /**
90
96
  * @private
91
97
  * @type {symbol}
@@ -154,6 +160,28 @@ const resizeObserverSymbol = Symbol("resizeObserver");
154
160
  */
155
161
  const activeReferenceSymbol = Symbol("activeReference");
156
162
 
163
+ /**
164
+ * @private
165
+ * @type {symbol}
166
+ */
167
+ const rearrangeFrameSymbol = Symbol("rearrangeFrame");
168
+
169
+ /**
170
+ * @private
171
+ * @type {symbol}
172
+ */
173
+ const buttonCollectionsSignatureSymbol = Symbol("buttonCollectionsSignature");
174
+
175
+ /**
176
+ * @private
177
+ * @type {symbol}
178
+ */
179
+ const layoutSignatureHistorySymbol = Symbol("layoutSignatureHistory");
180
+
181
+ const LAYOUT_SWITCH_HYSTERESIS = 16;
182
+ const LAYOUT_OSCILLATION_HISTORY_LIMIT = 4;
183
+ const LAYOUT_OSCILLATION_TIME_WINDOW_MS = 1000;
184
+
157
185
  /**
158
186
  * A Tabs Control
159
187
  *
@@ -553,6 +581,13 @@ class Tabs extends CustomElement {
553
581
 
554
582
  this[popperInstanceSymbol]?.destroy();
555
583
  this[tabListMutationObserverSymbol]?.disconnect();
584
+ this[resizeObserverSymbol]?.disconnect();
585
+ if (this[rearrangeFrameSymbol] !== undefined) {
586
+ getWindow().cancelAnimationFrame(this[rearrangeFrameSymbol]);
587
+ delete this[rearrangeFrameSymbol];
588
+ }
589
+ this[measurementNavElementSymbol]?.remove();
590
+ delete this[measurementNavElementSymbol];
556
591
  }
557
592
  }
558
593
 
@@ -1327,6 +1362,8 @@ function initTabButtons() {
1327
1362
  throw new Error("no shadow-root is defined");
1328
1363
  }
1329
1364
 
1365
+ resetLayoutSignatureHistory.call(this);
1366
+
1330
1367
  let activeReference;
1331
1368
  let invalidActive = false;
1332
1369
  const previousActiveReference = this[activeReferenceSymbol] || null;
@@ -1408,8 +1445,11 @@ function initTabButtons() {
1408
1445
  });
1409
1446
  }
1410
1447
 
1411
- setButtonCollections.call(this, buttons, []);
1448
+ const { standardButtons, popperButtons } =
1449
+ splitButtonsByCurrentPopperReferences.call(this, buttons);
1450
+ setButtonCollections.call(this, standardButtons, popperButtons);
1412
1451
  this.setOption("marker", random());
1452
+ this[dimensionsSymbol].setVia("data.calculated", false);
1413
1453
 
1414
1454
  return adjustButtonVisibility.call(this).then(() => {
1415
1455
  if (
@@ -1491,7 +1531,9 @@ function adjustButtonVisibility() {
1491
1531
  const runIfRendered = () => {
1492
1532
  if (resolved === true) return;
1493
1533
 
1494
- const defCount = self.getOption("buttons.standard").length;
1534
+ const defCount =
1535
+ self.getOption("buttons.standard").length +
1536
+ self.getOption("buttons.popper").length;
1495
1537
  const domCount = self[navElementSymbol].querySelectorAll(
1496
1538
  'button[data-monster-role="button"][data-monster-tab-reference]',
1497
1539
  ).length;
@@ -1529,7 +1571,7 @@ function getDimValue(value) {
1529
1571
  return 0;
1530
1572
  }
1531
1573
 
1532
- const valueAsInt = parseInt(value, 10);
1574
+ const valueAsInt = parseFloat(value);
1533
1575
 
1534
1576
  if (isNaN(valueAsInt)) {
1535
1577
  return 0;
@@ -1554,7 +1596,7 @@ function calcBoxWidth(node) {
1554
1596
  getDimValue(bounding["width"]) +
1555
1597
  getDimValue(dim["border-right-width"]) +
1556
1598
  getDimValue(dim["margin-right"]) +
1557
- getDimValue(dim["padding-left"])
1599
+ getDimValue(dim["padding-right"])
1558
1600
  );
1559
1601
  }
1560
1602
 
@@ -1563,10 +1605,13 @@ function calcBoxWidth(node) {
1563
1605
  * @return {Object}
1564
1606
  */
1565
1607
  function rearrangeButtons() {
1566
- getWindow().requestAnimationFrame(() => {
1567
- const standardButtons = [];
1568
- const popperButtons = [];
1569
- let sum = 0;
1608
+ if (this[rearrangeFrameSymbol] !== undefined) {
1609
+ return;
1610
+ }
1611
+
1612
+ this[rearrangeFrameSymbol] = getWindow().requestAnimationFrame(() => {
1613
+ delete this[rearrangeFrameSymbol];
1614
+
1570
1615
  const space = this[dimensionsSymbol].getVia("data.space");
1571
1616
 
1572
1617
  if (space <= 0) {
@@ -1576,16 +1621,30 @@ function rearrangeButtons() {
1576
1621
  const buttons = this.getOption("buttons.standard").concat(
1577
1622
  this.getOption("buttons.popper"),
1578
1623
  );
1579
- for (const [, button] of buttons.entries()) {
1580
- const ref = button?.reference;
1581
-
1582
- sum += resolveButtonWidth.call(this, ref);
1624
+ const widths = buttons.map((button) =>
1625
+ resolveButtonWidth.call(this, button?.reference),
1626
+ );
1627
+ const switchWidth = resolveSwitchWidth.call(this);
1628
+ const switchCurrentlyVisible =
1629
+ this[switchElementSymbol] instanceof HTMLElement &&
1630
+ !this[switchElementSymbol].classList.contains("hidden");
1631
+ const { standardButtons, popperButtons } = resolveOverflowButtonLayout({
1632
+ buttons,
1633
+ widths,
1634
+ space,
1635
+ switchWidth,
1636
+ switchCurrentlyVisible,
1637
+ });
1583
1638
 
1584
- if (sum > space) {
1585
- popperButtons.push(clone(button));
1586
- } else {
1587
- standardButtons.push(clone(button));
1588
- }
1639
+ if (
1640
+ shouldSuppressOscillatingTabLayout.call(
1641
+ this,
1642
+ space,
1643
+ standardButtons,
1644
+ popperButtons,
1645
+ )
1646
+ ) {
1647
+ return;
1589
1648
  }
1590
1649
 
1591
1650
  setButtonCollections.call(this, standardButtons, popperButtons);
@@ -1594,6 +1653,74 @@ function rearrangeButtons() {
1594
1653
  });
1595
1654
  }
1596
1655
 
1656
+ /**
1657
+ * @private
1658
+ * @param {Object} options
1659
+ * @param {Object[]} options.buttons
1660
+ * @param {number[]} options.widths
1661
+ * @param {number} options.space
1662
+ * @param {number} options.switchWidth
1663
+ * @param {boolean} options.switchCurrentlyVisible
1664
+ * @return {{standardButtons: Object[], popperButtons: Object[]}}
1665
+ */
1666
+ function resolveOverflowButtonLayout({
1667
+ buttons,
1668
+ widths,
1669
+ space,
1670
+ switchWidth,
1671
+ switchCurrentlyVisible,
1672
+ }) {
1673
+ let layout;
1674
+ if (switchCurrentlyVisible) {
1675
+ layout = fitButtonsIntoSpace(
1676
+ buttons,
1677
+ widths,
1678
+ space - switchWidth - LAYOUT_SWITCH_HYSTERESIS,
1679
+ );
1680
+ if (layout.popperButtons.length === 0) {
1681
+ layout = fitButtonsIntoSpace(buttons, widths, space);
1682
+ }
1683
+ return layout;
1684
+ }
1685
+
1686
+ layout = fitButtonsIntoSpace(
1687
+ buttons,
1688
+ widths,
1689
+ space - LAYOUT_SWITCH_HYSTERESIS,
1690
+ );
1691
+ if (layout.popperButtons.length > 0) {
1692
+ return fitButtonsIntoSpace(buttons, widths, space - switchWidth);
1693
+ }
1694
+
1695
+ return fitButtonsIntoSpace(buttons, widths, space);
1696
+ }
1697
+
1698
+ /**
1699
+ * @private
1700
+ * @param {Object[]} buttons
1701
+ * @param {number[]} widths
1702
+ * @param {number} availableSpace
1703
+ * @return {{standardButtons: Object[], popperButtons: Object[]}}
1704
+ */
1705
+ function fitButtonsIntoSpace(buttons, widths, availableSpace) {
1706
+ let sum = 0;
1707
+ const standardButtons = [];
1708
+ const popperButtons = [];
1709
+ const boundedSpace = Math.max(0, availableSpace);
1710
+
1711
+ for (const [index, button] of buttons.entries()) {
1712
+ const width = widths[index];
1713
+ if (popperButtons.length > 0 || sum + width > boundedSpace) {
1714
+ popperButtons.push(clone(button));
1715
+ } else {
1716
+ sum += width;
1717
+ standardButtons.push(clone(button));
1718
+ }
1719
+ }
1720
+
1721
+ return { standardButtons, popperButtons };
1722
+ }
1723
+
1597
1724
  /**
1598
1725
  * @private
1599
1726
  */
@@ -1625,11 +1752,131 @@ function resolveButtonWidth(ref) {
1625
1752
  return 0;
1626
1753
  }
1627
1754
 
1628
- width = calcBoxWidth.call(this, element);
1629
- this[dimensionsSymbol].setVia(`data.button.${ref}`, width);
1755
+ width = measureButtonWidth.call(this, element);
1756
+ if (width > 0) {
1757
+ this[dimensionsSymbol].setVia(`data.button.${ref}`, width);
1758
+ }
1759
+ return width;
1760
+ }
1761
+
1762
+ /**
1763
+ * @private
1764
+ * @param {boolean} force
1765
+ * @return {number}
1766
+ */
1767
+ function resolveSwitchWidth(force = false) {
1768
+ let width = this[dimensionsSymbol].getVia("data.switchWidth", 0);
1769
+ if (force !== true && width > 0) {
1770
+ return width;
1771
+ }
1772
+
1773
+ if (!(this[switchElementSymbol] instanceof HTMLElement)) {
1774
+ return 0;
1775
+ }
1776
+
1777
+ width = measureElementWidthInNavigation.call(this, this[switchElementSymbol], {
1778
+ removeHiddenClass: true,
1779
+ });
1780
+ this[dimensionsSymbol].setVia("data.switchWidth", width);
1781
+ return width;
1782
+ }
1783
+
1784
+ /**
1785
+ * @private
1786
+ * @param {HTMLButtonElement} element
1787
+ * @return {number}
1788
+ */
1789
+ function measureButtonWidth(element) {
1790
+ if (
1791
+ element.closest(`[${ATTRIBUTE_ROLE}=popper-nav]`) instanceof HTMLElement
1792
+ ) {
1793
+ return measureButtonWidthInNavigation.call(this, element);
1794
+ }
1795
+
1796
+ const width = calcBoxWidth.call(this, element);
1797
+ if (width > 0) {
1798
+ return width;
1799
+ }
1800
+
1801
+ return measureButtonWidthInNavigation.call(this, element);
1802
+ }
1803
+
1804
+ /**
1805
+ * @private
1806
+ * @param {HTMLButtonElement} element
1807
+ * @return {number}
1808
+ */
1809
+ function measureButtonWidthInNavigation(element) {
1810
+ return measureElementWidthInNavigation.call(this, element);
1811
+ }
1812
+
1813
+ /**
1814
+ * @private
1815
+ * @param {HTMLElement} element
1816
+ * @param {{removeHiddenClass?: boolean}} options
1817
+ * @return {number}
1818
+ */
1819
+ function measureElementWidthInNavigation(element, options = {}) {
1820
+ const measurementNav = ensureMeasurementNav.call(this);
1821
+ const cloneElement = element.cloneNode(true);
1822
+ prepareMeasurementElement(cloneElement, options);
1823
+
1824
+ measurementNav.appendChild(cloneElement);
1825
+ const width = calcBoxWidth.call(this, cloneElement);
1826
+ cloneElement.remove();
1827
+
1630
1828
  return width;
1631
1829
  }
1632
1830
 
1831
+ /**
1832
+ * @private
1833
+ * @param {HTMLElement} element
1834
+ * @param {{removeHiddenClass?: boolean}} options
1835
+ * @return {void}
1836
+ */
1837
+ function prepareMeasurementElement(element, options = {}) {
1838
+ element.setAttribute("aria-hidden", "true");
1839
+ element.setAttribute("tabindex", "-1");
1840
+ element.removeAttribute("data-monster-tab-reference");
1841
+ element.removeAttribute("id");
1842
+ element.dataset.monsterMeasurement = "true";
1843
+
1844
+ if (options.removeHiddenClass === true) {
1845
+ element.classList.remove("hidden");
1846
+ }
1847
+
1848
+ element.style.position = "";
1849
+ element.style.visibility = "";
1850
+ element.style.pointerEvents = "";
1851
+ element.style.inset = "";
1852
+ }
1853
+
1854
+ /**
1855
+ * @private
1856
+ * @return {HTMLElement}
1857
+ */
1858
+ function ensureMeasurementNav() {
1859
+ if (this[measurementNavElementSymbol] instanceof HTMLElement) {
1860
+ return this[measurementNavElementSymbol];
1861
+ }
1862
+
1863
+ const nav = getDocument().createElement("nav");
1864
+ nav.setAttribute(ATTRIBUTE_ROLE, "nav");
1865
+ nav.setAttribute("aria-hidden", "true");
1866
+ nav.dataset.monsterMeasurement = "true";
1867
+ nav.style.position = "absolute";
1868
+ nav.style.visibility = "hidden";
1869
+ nav.style.pointerEvents = "none";
1870
+ nav.style.inset = "0 auto auto 0";
1871
+ nav.style.width = "max-content";
1872
+ nav.style.height = "auto";
1873
+ nav.style.overflow = "visible";
1874
+
1875
+ this[controlElementSymbol].appendChild(nav);
1876
+ this[measurementNavElementSymbol] = nav;
1877
+ return nav;
1878
+ }
1879
+
1633
1880
  /**
1634
1881
  * Keep the main navigation and popper navigation models in sync with one update.
1635
1882
  *
@@ -1641,12 +1888,86 @@ function resolveButtonWidth(ref) {
1641
1888
  * @param {Object[]} popperButtons
1642
1889
  */
1643
1890
  function setButtonCollections(standardButtons, popperButtons) {
1891
+ const signature = getButtonCollectionsSignature(standardButtons, popperButtons);
1892
+ if (this[buttonCollectionsSignatureSymbol] === signature) {
1893
+ return;
1894
+ }
1895
+
1896
+ const currentSignature = getButtonCollectionsSignature(
1897
+ this.getOption("buttons.standard", []),
1898
+ this.getOption("buttons.popper", []),
1899
+ );
1900
+ if (currentSignature === signature) {
1901
+ this[buttonCollectionsSignatureSymbol] = signature;
1902
+ return;
1903
+ }
1904
+
1905
+ this[buttonCollectionsSignatureSymbol] = signature;
1644
1906
  this.setOption("buttons", {
1645
1907
  standard: clone(standardButtons),
1646
1908
  popper: clone(popperButtons),
1647
1909
  });
1648
1910
  }
1649
1911
 
1912
+ /**
1913
+ * @private
1914
+ * @param {Object[]} standardButtons
1915
+ * @param {Object[]} popperButtons
1916
+ * @return {string}
1917
+ */
1918
+ function getButtonCollectionsSignature(standardButtons, popperButtons) {
1919
+ return JSON.stringify({
1920
+ standard: standardButtons.map(getButtonSignature),
1921
+ popper: popperButtons.map(getButtonSignature),
1922
+ });
1923
+ }
1924
+
1925
+ /**
1926
+ * @private
1927
+ * @param {Object} button
1928
+ * @return {Object}
1929
+ */
1930
+ function getButtonSignature(button) {
1931
+ return {
1932
+ reference: button?.reference,
1933
+ name: button?.name,
1934
+ tab: button?.tab,
1935
+ label: button?.label,
1936
+ error: button?.error,
1937
+ state: button?.state,
1938
+ class: button?.class,
1939
+ disabled: button?.disabled,
1940
+ title: button?.title,
1941
+ "aria-label": button?.["aria-label"],
1942
+ remove: button?.remove,
1943
+ kind: button?.kind,
1944
+ priority: button?.priority,
1945
+ group: button?.group,
1946
+ };
1947
+ }
1948
+
1949
+ /**
1950
+ * @private
1951
+ * @param {Object[]} buttons
1952
+ * @return {{standardButtons: Object[], popperButtons: Object[]}}
1953
+ */
1954
+ function splitButtonsByCurrentPopperReferences(buttons) {
1955
+ const popperReferences = new Set(
1956
+ this.getOption("buttons.popper", []).map((button) => button.reference),
1957
+ );
1958
+ const standardButtons = [];
1959
+ const popperButtons = [];
1960
+ for (const button of buttons) {
1961
+ if (popperReferences.has(button.reference)) {
1962
+ popperButtons.push(button);
1963
+ } else {
1964
+ standardButtons.push(button);
1965
+ }
1966
+ }
1967
+
1968
+ return { standardButtons, popperButtons };
1969
+ }
1970
+
1650
1971
  /**
1651
1972
  * @private
1652
1973
  * @return {Object}
@@ -1676,10 +1997,10 @@ function calculateNavigationButtonsDimensions() {
1676
1997
  const element = findButtonElement.call(this, ref);
1677
1998
  if (!(element instanceof HTMLButtonElement)) continue;
1678
1999
 
1679
- this[dimensionsSymbol].setVia(
1680
- `data.button.${ref}`,
1681
- calcBoxWidth.call(this, element),
1682
- );
2000
+ const width = measureButtonWidth.call(this, element);
2001
+ if (width > 0) {
2002
+ this[dimensionsSymbol].setVia(`data.button.${ref}`, width);
2003
+ }
1683
2004
  button["class"] = new TokenList(button["class"])
1684
2005
  .remove("invisible")
1685
2006
  .toString();
@@ -1692,13 +2013,81 @@ function calculateNavigationButtonsDimensions() {
1692
2013
  slot.classList.remove("invisible");
1693
2014
  }
1694
2015
 
1695
- setButtonCollections.call(this, buttons, []);
2016
+ const { standardButtons, popperButtons } =
2017
+ splitButtonsByCurrentPopperReferences.call(this, buttons);
2018
+
2019
+ resolveSwitchWidth.call(this, true);
2020
+ setButtonCollections.call(this, standardButtons, popperButtons);
1696
2021
 
1697
2022
  getWindow().requestAnimationFrame(() => {
1698
2023
  this[dimensionsSymbol].setVia("data.calculated", true);
1699
2024
  });
1700
2025
  }
1701
2026
 
2027
+ /**
2028
+ * @private
2029
+ * @param {number} space
2030
+ * @param {Object[]} standardButtons
2031
+ * @param {Object[]} popperButtons
2032
+ * @return {boolean}
2033
+ */
2034
+ function shouldSuppressOscillatingTabLayout(
2035
+ space,
2036
+ standardButtons,
2037
+ popperButtons,
2038
+ ) {
2039
+ const signature = [
2040
+ Math.round(space),
2041
+ standardButtons.map((button) => button.reference).join(","),
2042
+ popperButtons.map((button) => button.reference).join(","),
2043
+ ].join("|");
2044
+
2045
+ const history = this[layoutSignatureHistorySymbol] || [];
2046
+ history.push({
2047
+ signature,
2048
+ time: performanceNow(),
2049
+ });
2050
+ while (history.length > LAYOUT_OSCILLATION_HISTORY_LIMIT) {
2051
+ history.shift();
2052
+ }
2053
+ this[layoutSignatureHistorySymbol] = history;
2054
+
2055
+ if (history.length < LAYOUT_OSCILLATION_HISTORY_LIMIT) {
2056
+ return false;
2057
+ }
2058
+
2059
+ const [first, second, third, fourth] = history;
2060
+ if (fourth.time - first.time > LAYOUT_OSCILLATION_TIME_WINDOW_MS) {
2061
+ return false;
2062
+ }
2063
+
2064
+ return (
2065
+ first.signature !== second.signature &&
2066
+ first.signature === third.signature &&
2067
+ second.signature === fourth.signature
2068
+ );
2069
+ }
2070
+
2071
+ /**
2072
+ * @private
2073
+ * @return {void}
2074
+ */
2075
+ function resetLayoutSignatureHistory() {
2076
+ delete this[layoutSignatureHistorySymbol];
2077
+ }
2078
+
2079
+ /**
2080
+ * @private
2081
+ * @return {number}
2082
+ */
2083
+ function performanceNow() {
2084
+ const globalObject = getGlobal();
2085
+ if (globalObject?.performance?.now instanceof Function) {
2086
+ return globalObject.performance.now();
2087
+ }
2088
+ return Date.now();
2089
+ }
2090
+
1702
2091
  /**
1703
2092
  * @private
1704
2093
  * @param {string} ref
@@ -140,15 +140,17 @@ describe("ButtonBar", function () {
140
140
  const bar = document.getElementById("dynamic-auto-hidden-button-bar");
141
141
  const button = document.getElementById("dynamic-auto-hidden-button");
142
142
 
143
- await waitForLayout();
144
-
145
- expect(bar.hasAttribute("hidden")).to.be.true;
143
+ await waitForCondition(() => {
144
+ return bar.hasAttribute("hidden");
145
+ });
146
146
 
147
147
  button.removeAttribute("hidden");
148
- await waitForLayout();
149
-
150
- expect(bar.hasAttribute("hidden")).to.be.false;
151
- expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.false;
148
+ await waitForCondition(() => {
149
+ return (
150
+ !bar.hasAttribute("hidden") &&
151
+ !bar.hasAttribute("data-monster-empty-hidden")
152
+ );
153
+ });
152
154
  });
153
155
 
154
156
  it("should keep an auto-hidden button bar visible when buttons overflow into the popper", async function () {
@@ -633,6 +633,123 @@ describe("ControlBar", function () {
633
633
  }
634
634
  });
635
635
 
636
+ it("should size and join popper buttons inside custom control wrappers", async function () {
637
+ const originalRequestAnimationFrame = window.requestAnimationFrame;
638
+ const originalGlobalRequestAnimationFrame = globalThis.requestAnimationFrame;
639
+
640
+ const scheduledCallbacks = [];
641
+ const flushFrames = async () => {
642
+ while (scheduledCallbacks.length > 0) {
643
+ scheduledCallbacks.shift()();
644
+ await new Promise((resolve) => setTimeout(resolve, 0));
645
+ }
646
+ };
647
+
648
+ try {
649
+ window.requestAnimationFrame = (callback) => {
650
+ scheduledCallbacks.push(callback);
651
+ return scheduledCallbacks.length;
652
+ };
653
+ globalThis.requestAnimationFrame = window.requestAnimationFrame;
654
+
655
+ const mocks = document.getElementById("mocks");
656
+ mocks.innerHTML = `
657
+ <div id="custom-wrapper-border-bar-wrapper">
658
+ <monster-control-bar id="custom-wrapper-border-bar">
659
+ <nucleus-workflow-transition-control id="custom-wrapper-workflow">
660
+ <monster-popper-button id="custom-wrapper-release">
661
+ <span slot="button">Release order</span>
662
+ </monster-popper-button>
663
+ <monster-popper-button id="custom-wrapper-cancel">
664
+ <span slot="button">Cancel order</span>
665
+ </monster-popper-button>
666
+ </nucleus-workflow-transition-control>
667
+ <nucleus-follow-ups-action id="custom-wrapper-follow-up">
668
+ <monster-popper-button id="custom-wrapper-follow-up-button">
669
+ <span slot="button">Create follow-up</span>
670
+ <monster-message-state-button id="custom-wrapper-follow-up-submit">
671
+ Create follow-up
672
+ </monster-message-state-button>
673
+ </monster-popper-button>
674
+ </nucleus-follow-ups-action>
675
+ </monster-control-bar>
676
+ </div>
677
+ `;
678
+
679
+ const wrapper = document.getElementById(
680
+ "custom-wrapper-border-bar-wrapper",
681
+ );
682
+ const workflow = document.getElementById("custom-wrapper-workflow");
683
+ const followUp = document.getElementById("custom-wrapper-follow-up");
684
+ const release = document.getElementById("custom-wrapper-release");
685
+ const cancel = document.getElementById("custom-wrapper-cancel");
686
+ const followUpButton = document.getElementById(
687
+ "custom-wrapper-follow-up-button",
688
+ );
689
+ const followUpSubmit = document.getElementById(
690
+ "custom-wrapper-follow-up-submit",
691
+ );
692
+
693
+ wrapper.style.boxSizing = "border-box";
694
+ wrapper.style.width = "500px";
695
+ Object.defineProperty(wrapper, "clientWidth", {
696
+ configurable: true,
697
+ value: 500,
698
+ });
699
+
700
+ for (const control of [workflow, followUp]) {
701
+ Object.defineProperty(control, "offsetWidth", {
702
+ configurable: true,
703
+ value: 160,
704
+ });
705
+ Object.defineProperty(control, "offsetHeight", {
706
+ configurable: true,
707
+ value: 30,
708
+ });
709
+ control.getBoundingClientRect = () => ({
710
+ width: 160,
711
+ height: 30,
712
+ top: 0,
713
+ right: 160,
714
+ bottom: 30,
715
+ left: 0,
716
+ x: 0,
717
+ y: 0,
718
+ toJSON: () => {},
719
+ });
720
+ }
721
+
722
+ const releaseInnerButton = release.shadowRoot?.querySelector(
723
+ '[data-monster-role="button"]',
724
+ );
725
+ const cancelInnerButton = cancel.shadowRoot?.querySelector(
726
+ '[data-monster-role="button"]',
727
+ );
728
+ const followUpInnerButton = followUpButton.shadowRoot?.querySelector(
729
+ '[data-monster-role="button"]',
730
+ );
731
+
732
+ releaseInnerButton.style.borderRightWidth = "3px";
733
+ cancelInnerButton.style.borderLeftWidth = "3px";
734
+ cancelInnerButton.style.borderRightWidth = "2px";
735
+ followUpInnerButton.style.borderLeftWidth = "2px";
736
+
737
+ await flushFrames();
738
+ await new Promise((resolve) => setTimeout(resolve, 0));
739
+ await new Promise((resolve) => setTimeout(resolve, 0));
740
+
741
+ expect(release.style.height).to.equal("100%");
742
+ expect(cancel.style.height).to.equal("100%");
743
+ expect(followUpButton.style.height).to.equal("100%");
744
+ expect(cancel.style.marginLeft).to.equal("-3px");
745
+ expect(followUp.style.marginLeft).to.equal("-2px");
746
+ expect(followUpSubmit.style.height).to.equal("");
747
+ } finally {
748
+ window.requestAnimationFrame = originalRequestAnimationFrame;
749
+ globalThis.requestAnimationFrame = originalGlobalRequestAnimationFrame;
750
+ }
751
+ });
752
+
636
753
  it("should join borders between message state and confirm buttons", async function () {
637
754
  const originalRequestAnimationFrame = window.requestAnimationFrame;
638
755
  const originalGlobalRequestAnimationFrame = globalThis.requestAnimationFrame;
@@ -292,6 +292,52 @@ describe('Select', function () {
292
292
  expect(mutateCount).to.equal(2);
293
293
  });
294
294
 
295
+ it('should suppress repeated safe floating layout oscillation', async function () {
296
+ const popper = document.createElement('div');
297
+ const rects = [
298
+ {x: 0, y: 0, width: 100, height: 40},
299
+ {x: 0, y: 0, width: 120, height: 40},
300
+ {x: 0, y: 0, width: 100, height: 40},
301
+ {x: 0, y: 0, width: 120, height: 40},
302
+ {x: 0, y: 0, width: 100, height: 40},
303
+ ];
304
+ let mutateCount = 0;
305
+
306
+ popper.getBoundingClientRect = () => {
307
+ const rect = rects[Math.min(mutateCount - 1, rects.length - 1)];
308
+ return {
309
+ ...rect,
310
+ top: rect.y,
311
+ left: rect.x,
312
+ right: rect.x + rect.width,
313
+ bottom: rect.y + rect.height,
314
+ };
315
+ };
316
+
317
+ const mutate = () => {
318
+ mutateCount += 1;
319
+ enqueueFloatingLayout({
320
+ popperElement: popper,
321
+ reason: FLOATING_LAYOUT_REASON.SETTLE,
322
+ mutate,
323
+ });
324
+ };
325
+
326
+ enqueueFloatingLayout({
327
+ popperElement: popper,
328
+ reason: FLOATING_LAYOUT_REASON.RESIZE,
329
+ mutate,
330
+ });
331
+
332
+ await flushFloatingLayoutQueueForTests();
333
+ await flushFloatingLayoutQueueForTests();
334
+ await flushFloatingLayoutQueueForTests();
335
+ await flushFloatingLayoutQueueForTests();
336
+ await flushFloatingLayoutQueueForTests();
337
+
338
+ expect(mutateCount).to.equal(4);
339
+ });
340
+
295
341
  it('should flush floating layout queue jobs when requestAnimationFrame stalls', async function () {
296
342
  const originalRequestAnimationFrame = global.requestAnimationFrame;
297
343
  const originalCancelAnimationFrame = global.cancelAnimationFrame;
@@ -207,6 +207,48 @@ function waitForCondition(check, {timeout = 4000, interval = 10} = {}) {
207
207
  });
208
208
  }
209
209
 
210
+ function createRect({width, height = 40, x = 0, y = 0}) {
211
+ return {
212
+ width,
213
+ height,
214
+ top: y,
215
+ left: x,
216
+ right: x + width,
217
+ bottom: y + height,
218
+ x,
219
+ y,
220
+ };
221
+ }
222
+
223
+ function installOverflowTabGeometryMock({navWidth = 480, switchWidth = 44} = {}) {
224
+ const originalGetBoundingClientRect =
225
+ global.HTMLElement.prototype.getBoundingClientRect;
226
+
227
+ global.HTMLElement.prototype.getBoundingClientRect = function () {
228
+ const role = this.getAttribute?.('data-monster-role');
229
+ const label = this.textContent?.trim() || '';
230
+
231
+ if (role === 'nav') {
232
+ return createRect({width: navWidth});
233
+ }
234
+
235
+ if (role === 'button') {
236
+ return createRect({width: Math.max(70, label.length * 11)});
237
+ }
238
+
239
+ if (role === 'switch') {
240
+ return createRect({width: switchWidth});
241
+ }
242
+
243
+ return originalGetBoundingClientRect.call(this);
244
+ };
245
+
246
+ return () => {
247
+ global.HTMLElement.prototype.getBoundingClientRect =
248
+ originalGetBoundingClientRect;
249
+ };
250
+ }
251
+
210
252
  describe('Tabs', function () {
211
253
 
212
254
  before(function (done) {
@@ -398,44 +440,7 @@ describe('Tabs', function () {
398
440
  window.ResizeObserver = OriginalResizeObserver;
399
441
  global.ResizeObserver = originalGlobalResizeObserver;
400
442
  };
401
- const originalGetBoundingClientRect =
402
- global.HTMLElement.prototype.getBoundingClientRect;
403
- restoreBoundingClientRect = () => {
404
- global.HTMLElement.prototype.getBoundingClientRect =
405
- originalGetBoundingClientRect;
406
- };
407
- global.HTMLElement.prototype.getBoundingClientRect = function () {
408
- const role = this.getAttribute?.('data-monster-role');
409
- const label = this.textContent?.trim() || '';
410
-
411
- if (role === 'nav') {
412
- return {
413
- width: 480,
414
- height: 40,
415
- top: 0,
416
- left: 0,
417
- right: 480,
418
- bottom: 40,
419
- x: 0,
420
- y: 0,
421
- };
422
- }
423
-
424
- if (role === 'button') {
425
- return {
426
- width: Math.max(70, label.length * 11),
427
- height: 40,
428
- top: 0,
429
- left: 0,
430
- right: 0,
431
- bottom: 40,
432
- x: 0,
433
- y: 0,
434
- };
435
- }
436
-
437
- return originalGetBoundingClientRect.call(this);
438
- };
443
+ restoreBoundingClientRect = installOverflowTabGeometryMock();
439
444
 
440
445
  mocks.innerHTML = htmlOverflow;
441
446
 
@@ -456,8 +461,10 @@ describe('Tabs', function () {
456
461
  switchButton.classList.contains('hidden') === false
457
462
  );
458
463
  }).then(() => {
464
+ let tabs;
465
+ let originalSetOption;
459
466
  try {
460
- const tabs = document.getElementById('overflow-tabs');
467
+ tabs = document.getElementById('overflow-tabs');
461
468
  expect(tabs).is.instanceof(Tabs);
462
469
  const switchButton = tabs.shadowRoot.querySelector(
463
470
  '[data-monster-role="switch"]',
@@ -475,29 +482,43 @@ describe('Tabs', function () {
475
482
  (observer) => observer.elements.includes(tabs.shadowRoot.querySelector('[data-monster-role="nav"]')),
476
483
  );
477
484
  expect(resizeObserver).to.not.equal(undefined);
485
+
486
+ originalSetOption = tabs.setOption.bind(tabs);
487
+ let resetAllTabsDuringResize = false;
488
+ tabs.setOption = function (path, value) {
489
+ if (
490
+ path === 'buttons' &&
491
+ value?.standard?.length === 11 &&
492
+ value?.popper?.length === 0
493
+ ) {
494
+ resetAllTabsDuringResize = true;
495
+ }
496
+ return originalSetOption(path, value);
497
+ };
498
+
478
499
  resizeObserver.triggerResize([]);
479
500
 
480
501
  return waitForCondition(() => {
481
- return (
482
- TrackingResizeObserver.callbackCount > 0 &&
483
- tabs.getOption('buttons.standard').length === 11 &&
484
- tabs.getOption('buttons.popper').length === 0
485
- );
502
+ return TrackingResizeObserver.callbackCount > 0;
486
503
  }).then(() => {
487
- return waitForCondition(() => {
488
- standardButtons = tabs.getOption('buttons.standard');
489
- popperButtons = tabs.getOption('buttons.popper');
490
- return (
491
- standardButtons.length > 0 &&
492
- popperButtons.length > 0 &&
493
- standardButtons.length + popperButtons.length === 11 &&
494
- new Set(
495
- standardButtons
496
- .concat(popperButtons)
497
- .map((button) => button.reference),
498
- ).size === 11
499
- );
500
- });
504
+ return new Promise((resolve) => setTimeout(resolve, 260));
505
+ }).then(() => {
506
+ return new Promise((resolve) => requestAnimationFrame(resolve));
507
+ }).then(() => {
508
+ tabs.setOption = originalSetOption;
509
+ expect(resetAllTabsDuringResize).to.equal(false);
510
+ standardButtons = tabs.getOption('buttons.standard');
511
+ popperButtons = tabs.getOption('buttons.popper');
512
+ expect(standardButtons.length).to.be.greaterThan(0);
513
+ expect(popperButtons.length).to.be.greaterThan(0);
514
+ expect(standardButtons.length + popperButtons.length).to.equal(11);
515
+ expect(
516
+ new Set(
517
+ standardButtons
518
+ .concat(popperButtons)
519
+ .map((button) => button.reference),
520
+ ).size,
521
+ ).to.equal(11);
501
522
  }).then(() => {
502
523
  restoreBoundingClientRect();
503
524
  restoreBoundingClientRect = null;
@@ -506,6 +527,9 @@ describe('Tabs', function () {
506
527
  done();
507
528
  });
508
529
  } catch (e) {
530
+ if (tabs && originalSetOption) {
531
+ tabs.setOption = originalSetOption;
532
+ }
509
533
  restoreBoundingClientRect();
510
534
  restoreBoundingClientRect = null;
511
535
  restoreResizeObserver();
@@ -513,6 +537,13 @@ describe('Tabs', function () {
513
537
  return done(e);
514
538
  }
515
539
  }).catch((e) => {
540
+ const tabs = document.getElementById('overflow-tabs');
541
+ if (
542
+ tabs &&
543
+ Object.prototype.hasOwnProperty.call(tabs, 'setOption')
544
+ ) {
545
+ delete tabs.setOption;
546
+ }
516
547
  if (restoreBoundingClientRect instanceof Function) {
517
548
  restoreBoundingClientRect();
518
549
  restoreBoundingClientRect = null;
@@ -525,6 +556,86 @@ describe('Tabs', function () {
525
556
  });
526
557
  });
527
558
 
559
+ it('should preserve the overflow split and recalculate widths while rebuilding tab buttons', async function () {
560
+ this.timeout(5000);
561
+
562
+ const mocks = document.getElementById('mocks');
563
+ restoreBoundingClientRect = installOverflowTabGeometryMock();
564
+
565
+ let tabs;
566
+ let originalSetOption;
567
+
568
+ try {
569
+ mocks.innerHTML = htmlOverflow;
570
+
571
+ await waitForCondition(() => {
572
+ tabs = document.getElementById('overflow-tabs');
573
+ const standardButtons = tabs?.getOption?.('buttons.standard') || [];
574
+ const popperButtons = tabs?.getOption?.('buttons.popper') || [];
575
+
576
+ return (
577
+ tabs instanceof Tabs &&
578
+ standardButtons.length > 0 &&
579
+ popperButtons.length > 0 &&
580
+ standardButtons.length + popperButtons.length === 11
581
+ );
582
+ });
583
+
584
+ const initialPopperCount = tabs.getOption('buttons.popper').length;
585
+ const standardReference = tabs.getOption('buttons.standard.0.reference');
586
+ const panel = document.getElementById(standardReference);
587
+ expect(panel).to.not.equal(null);
588
+
589
+ originalSetOption = tabs.setOption.bind(tabs);
590
+ let resetAllTabsDuringRebuild = false;
591
+ tabs.setOption = function (path, value) {
592
+ if (
593
+ path === 'buttons' &&
594
+ value?.standard?.length === 11 &&
595
+ value?.popper?.length === 0
596
+ ) {
597
+ resetAllTabsDuringRebuild = true;
598
+ }
599
+ return originalSetOption(path, value);
600
+ };
601
+
602
+ panel.setAttribute(
603
+ 'data-monster-button-label',
604
+ 'Aktualisierte sehr lange Tab Beschriftung mit viel mehr Platzbedarf',
605
+ );
606
+
607
+ await waitForCondition(() => {
608
+ return tabs
609
+ .getOption('buttons.standard', [])
610
+ .concat(tabs.getOption('buttons.popper', []))
611
+ .some((button) => {
612
+ return (
613
+ button.reference === standardReference &&
614
+ button.label.includes('Aktualisierte')
615
+ );
616
+ });
617
+ });
618
+ await waitForCondition(() => {
619
+ return tabs.getOption('buttons.popper').length > initialPopperCount;
620
+ });
621
+
622
+ tabs.setOption = originalSetOption;
623
+ expect(resetAllTabsDuringRebuild).to.equal(false);
624
+ expect(tabs.getOption('buttons.popper').length).to.be.greaterThan(0);
625
+ expect(
626
+ tabs
627
+ .getOption('buttons.standard')
628
+ .concat(tabs.getOption('buttons.popper')).length,
629
+ ).to.equal(11);
630
+ } finally {
631
+ if (tabs && originalSetOption) {
632
+ tabs.setOption = originalSetOption;
633
+ }
634
+ restoreBoundingClientRect();
635
+ restoreBoundingClientRect = null;
636
+ }
637
+ });
638
+
528
639
  it('should ignore unavailable panels when creating tabs and buttons', function (done) {
529
640
  let mocks = document.getElementById('mocks');
530
641
  mocks.innerHTML = htmlAvailability;