@schukai/monster 4.152.0 → 4.153.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +24 -1
  2. package/package.json +1 -1
  3. package/source/components/content/viewer/message.mjs +1 -0
  4. package/source/components/form/select.mjs +2 -0
  5. package/source/components/form/util/fetch.mjs +6 -7
  6. package/source/components/state/log.mjs +7 -0
  7. package/source/components/state/thread.mjs +11 -0
  8. package/source/components/time/month-calendar.mjs +80 -30
  9. package/source/dom/resource.mjs +19 -5
  10. package/source/dom/sanitize-html.mjs +34 -29
  11. package/test/cases/components/datatable/drag-scroll.mjs +28 -1
  12. package/test/cases/components/form/button-bar.mjs +9 -6
  13. package/test/cases/components/form/confirm-button.mjs +4 -4
  14. package/test/cases/components/form/control-bar.mjs +73 -72
  15. package/test/cases/components/form/input-group.mjs +3 -1
  16. package/test/cases/components/form/password.mjs +2 -2
  17. package/test/cases/components/form/popper-button.mjs +2 -2
  18. package/test/cases/components/form/select-sort-callback.mjs +57 -0
  19. package/test/cases/components/form/select.mjs +2 -2
  20. package/test/cases/components/form/sheet.mjs +13 -1
  21. package/test/cases/components/layout/popper.mjs +3 -1
  22. package/test/cases/components/layout/tabs.mjs +4 -1
  23. package/test/cases/components/navigation/site-navigation.mjs +12 -26
  24. package/test/cases/components/navigation/table-of-content.mjs +3 -0
  25. package/test/cases/components/state/ticker-lifecycle.mjs +47 -0
  26. package/test/cases/components/time/month-calendar.mjs +100 -0
  27. package/test/cases/data/transformer.mjs +7 -0
  28. package/test/cases/dom/customcontrol.mjs +2 -6
  29. package/test/cases/dom/ready.mjs +3 -3
  30. package/test/cases/dom/resource-availability.mjs +46 -0
  31. package/test/cases/dom/resourcemanager.mjs +12 -6
  32. package/test/cases/dom/sanitize-html.mjs +71 -0
  33. package/test/cases/dom/updater.mjs +1 -1
  34. package/test/cases/util/processing.mjs +3 -3
package/README.md CHANGED
@@ -78,6 +78,30 @@ Voilà!
78
78
  While we strive to work around some browser bugs, generally, we do not use polyfills or feature detection.
79
79
  However, many functions can be supplemented through polyfills, thus enhancing compatibility.
80
80
 
81
+ ### Content Security Policy
82
+
83
+ String expressions starting with `run:` use `new Function()` and require CSP
84
+ permission for string evaluation. Existing configurations remain supported.
85
+ For Select sorting under a policy without `unsafe-eval`, use `call:asc`,
86
+ `call:desc`, or set a function from your application script:
87
+
88
+ ```javascript
89
+ select.setOption("mapping.sort", (a, b, control) =>
90
+ a[1].localeCompare(b[1])
91
+ );
92
+ ```
93
+
94
+ Each comparator argument is a `[value, label]` entry; the third argument is the
95
+ owning Select. A `null` sort keeps the imported order. Configure function values
96
+ through JavaScript, since JSON attributes cannot contain functions.
97
+
98
+ ### Browser verification
99
+
100
+ The [browser test runner](test/web/README.md) builds the current checkout and runs
101
+ Chromium, Firefox and WebKit with normal web security, including CSP and timezone
102
+ regressions. Browser versions are pinned with the Nix environment. This does not
103
+ establish support for older browsers or replace testing Safari on Apple devices.
104
+
81
105
  ## Questions
82
106
 
83
107
  For questions and commercial support, please contact [Volker Schukai](https://www.schukai.com/).
@@ -99,4 +123,3 @@ Licensed under [AGPL](https://www.gnu.org/licenses/agpl-3.0.de.html). Commercial
99
123
 
100
124
  Detailed changes for each release are documented in the
101
125
  [CHANGELOG](https://gitlab.schukai.com/oss/libraries/javascript/monster/-/blob/master/application/CHANGELOG.md).
102
-
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.","files":["source","test/cases","test/util","CHANGELOG.md"],"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.152.0"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","files":["source","test/cases","test/util","CHANGELOG.md"],"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.153.0"}
@@ -386,6 +386,7 @@ class MessageContent extends CustomElement {
386
386
  switch (contentTransferEncoding) {
387
387
  case "base64":
388
388
  content = atob(content);
389
+ break;
389
390
 
390
391
  case "quoted-printable":
391
392
  content = decodeQuotedPrintable(content);
@@ -1414,6 +1414,8 @@ function importOptionsIntern(data) {
1414
1414
  sort === "no"
1415
1415
  ) {
1416
1416
  // no sorting
1417
+ } else if (typeof sort === "function") {
1418
+ entries.sort((a, b) => sort(a, b, self));
1417
1419
  } else if (isString(sort)) {
1418
1420
  if (sort.startsWith("run:")) {
1419
1421
  const code = sort.replace("run:", "");
@@ -66,8 +66,12 @@ function loadAndAssignContent(element, url, options, filter) {
66
66
  const tempDiv = document.createElement("div");
67
67
  tempDiv.innerHTML = content;
68
68
 
69
- // Extract and execute all <script> elements by appending them to the document head
69
+ // Extract scripts before inserting the fragment, then execute them in head.
70
70
  const scriptElements = tempDiv.querySelectorAll("script");
71
+ for (const script of scriptElements) script.remove();
72
+ // Inline scripts run synchronously when appended. Their fragment must
73
+ // already exist, including any upgraded custom elements inside it.
74
+ validateInstance(element, HTMLElement).innerHTML = tempDiv.innerHTML;
71
75
  scriptElements.forEach((oldScript) => {
72
76
  const newScript = document.createElement("script");
73
77
  if (oldScript.src) newScript.src = oldScript.src;
@@ -76,18 +80,13 @@ function loadAndAssignContent(element, url, options, filter) {
76
80
  if (oldScript.defer) newScript.defer = oldScript.defer;
77
81
  if (oldScript.crossOrigin) newScript.crossOrigin = oldScript.crossOrigin;
78
82
  if (oldScript.integrity) newScript.integrity = oldScript.integrity;
83
+ if (oldScript.nonce) newScript.nonce = oldScript.nonce;
79
84
  if (oldScript.referrerPolicy)
80
85
  newScript.referrerPolicy = oldScript.referrerPolicy;
81
86
  newScript.textContent = oldScript.textContent;
82
87
  document.head.appendChild(newScript);
83
- if (oldScript.parentNode) {
84
- oldScript.parentNode.removeChild(oldScript);
85
- }
86
88
  });
87
89
 
88
- // Assign the processed content to the target element
89
- validateInstance(element, HTMLElement).innerHTML = tempDiv.innerHTML;
90
-
91
90
  // If the element is within a Shadow DOM, use the host as the event target
92
91
  const shadowRoot = findShadowRoot(element);
93
92
  const eventTarget = shadowRoot !== null ? shadowRoot.host : element;
@@ -190,6 +190,7 @@ class Log extends CustomElement {
190
190
  */
191
191
  connectedCallback() {
192
192
  super.connectedCallback();
193
+ initTimeAgoTicker.call(this);
193
194
 
194
195
  const slottedElements = getSlottedElements.call(this);
195
196
  if (slottedElements.size > 0) {
@@ -197,6 +198,12 @@ class Log extends CustomElement {
197
198
  }
198
199
  }
199
200
 
201
+ disconnectedCallback() {
202
+ super.disconnectedCallback();
203
+ clearInterval(this[timeAgoIntervalSymbol]);
204
+ this[timeAgoIntervalSymbol] = undefined;
205
+ }
206
+
200
207
  /**
201
208
  * Clear the log
202
209
  *
@@ -63,6 +63,17 @@ const timeAgoIntervalSymbol = Symbol("timeAgoInterval");
63
63
  * @summary A threaded discussion layout for nested replies, comments and conversation-like content.
64
64
  **/
65
65
  class Thread extends CustomElement {
66
+ connectedCallback() {
67
+ super.connectedCallback();
68
+ initTimeAgoTicker.call(this);
69
+ }
70
+
71
+ disconnectedCallback() {
72
+ super.disconnectedCallback();
73
+ clearInterval(this[timeAgoIntervalSymbol]);
74
+ this[timeAgoIntervalSymbol] = undefined;
75
+ }
76
+
66
77
  /**
67
78
  * @return {void}
68
79
  */
@@ -31,7 +31,7 @@ import { isFunction, isString } from "../../types/is.mjs";
31
31
 
32
32
  import { fireCustomEvent } from "../../dom/events.mjs";
33
33
  import { getLocaleOfDocument } from "../../dom/locale.mjs";
34
- import { addErrorAttribute } from "../../dom/error.mjs";
34
+ import { addErrorAttribute, removeErrorAttribute } from "../../dom/error.mjs";
35
35
  import { MonthCalendarStyleSheet } from "./stylesheet/month-calendar.mjs";
36
36
  import { AccessibilityStyleSheet } from "../stylesheet/accessibility.mjs";
37
37
  import {
@@ -58,6 +58,13 @@ const calendarElementSymbol = Symbol("calendarElement");
58
58
  * @type {symbol}
59
59
  */
60
60
  const calendarBodyElementSymbol = Symbol("calendarBodyElement");
61
+ const calendarInputSymbol = Symbol("calendarInput");
62
+ const calendarFormatSymbol = Symbol("calendarFormat");
63
+ const calendarRefreshSymbol = Symbol("calendarRefresh");
64
+ const lastValidDateSymbol = Symbol("lastValidDate");
65
+ const calendarDateInvalidSymbol = Symbol("calendarDateInvalid");
66
+ const appointmentObserverSymbol = Symbol("appointmentObserver");
67
+ const boundDaysSymbol = Symbol("boundDays");
61
68
 
62
69
  /**
63
70
  * A Calendar
@@ -82,9 +89,17 @@ class MonthCalendar extends CustomElement {
82
89
  [initMethodSymbol]() {
83
90
  super[initMethodSymbol]();
84
91
 
85
- const def = generateCalendarData.call(this);
86
- this.setOption("calendarDays", def.calendarDays);
87
- this.setOption("calendarWeekdays", def.calendarWeekdays);
92
+ refreshCalendar.call(this);
93
+ this.attachObserver(
94
+ new Observer(() => {
95
+ if (this[calendarRefreshSymbol]) return;
96
+ this[calendarRefreshSymbol] = true;
97
+ queueMicrotask(() => {
98
+ this[calendarRefreshSymbol] = false;
99
+ refreshCalendar.call(this);
100
+ });
101
+ }),
102
+ );
88
103
  }
89
104
 
90
105
  /**
@@ -93,6 +108,7 @@ class MonthCalendar extends CustomElement {
93
108
  */
94
109
  [assembleMethodSymbol]() {
95
110
  super[assembleMethodSymbol]();
111
+ refreshCalendar.call(this);
96
112
 
97
113
  setTimeout(() => {
98
114
  initControlReferences.call(this);
@@ -797,35 +813,28 @@ function calcHeaderAndFooterHeight() {
797
813
  * - calendarWeekdays: Array of seven objects, each representing a weekday header.
798
814
  */
799
815
  function generateCalendarData() {
800
- let selectedDate = this.getOption("startDate");
801
- if (!(selectedDate instanceof Date)) {
802
- if (typeof selectedDate === "string") {
803
- try {
804
- selectedDate = new Date(selectedDate);
805
- } catch (e) {
806
- addErrorAttribute(this, "Invalid calendar date");
807
- return { calendarDays, calendarWeekdays };
808
- }
809
- } else {
816
+ let selectedDate = parseCalendarDate(this.getOption("startDate"));
817
+ this[calendarDateInvalidSymbol] = selectedDate === null;
818
+ if (selectedDate) this[lastValidDateSymbol] = selectedDate;
819
+ else selectedDate = this[lastValidDateSymbol] || new Date();
820
+ // Constructors must not add attributes. Report after construction and use
821
+ // the latest state if options changed again during the same turn.
822
+ queueMicrotask(() => {
823
+ if (this[calendarDateInvalidSymbol])
810
824
  addErrorAttribute(this, "Invalid calendar date");
811
- return { calendarDays, calendarWeekdays };
812
- }
813
- }
814
-
825
+ else if (this.hasAttribute(ATTRIBUTE_ERRORMESSAGE))
826
+ removeErrorAttribute(this, "Invalid calendar date");
827
+ });
815
828
  const calendarDays = [];
816
829
  let calendarWeekdays = [];
817
830
 
818
- if (!(selectedDate instanceof Date)) {
819
- addErrorAttribute(this, "Invalid calendar date");
820
- return { calendarDays, calendarWeekdays };
821
- }
822
-
823
- // Get the year and month from the provided date
824
- const year = selectedDate.getFullYear();
831
+ // Get the local month from the provided date.
825
832
  const month = selectedDate.getMonth(); // 0-based index (0 = January)
826
833
 
827
834
  // Create a Date object for the 1st of the given month
828
- const firstDayOfMonth = new Date(year, month, 1);
835
+ const firstDayOfMonth = new Date(selectedDate);
836
+ firstDayOfMonth.setDate(1);
837
+ firstDayOfMonth.setHours(0, 0, 0, 0);
829
838
 
830
839
  // Determine the weekday index of the 1st day, ensuring Monday = 0
831
840
  const weekdayIndex = (firstDayOfMonth.getDay() + 6) % 7;
@@ -888,6 +897,43 @@ function generateCalendarData() {
888
897
  return { calendarDays, calendarWeekdays };
889
898
  }
890
899
 
900
+ function parseCalendarDate(value) {
901
+ if (value instanceof Date)
902
+ return Number.isNaN(value.getTime()) ? null : new Date(value.getTime());
903
+ if (typeof value !== "string") return null;
904
+ const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
905
+ if (parts) {
906
+ const [, year, month, day] = parts.map(Number);
907
+ const date = new Date(0);
908
+ date.setHours(0, 0, 0, 0);
909
+ date.setFullYear(year, month - 1, day);
910
+ return date.getFullYear() === year &&
911
+ date.getMonth() === month - 1 &&
912
+ date.getDate() === day
913
+ ? date
914
+ : null;
915
+ }
916
+ const date = new Date(value);
917
+ return Number.isNaN(date.getTime()) ? null : date;
918
+ }
919
+
920
+ function refreshCalendar() {
921
+ const value = this.getOption("startDate");
922
+ const input = value instanceof Date ? value.getTime() : value;
923
+ const format = this.getOption("locale.weekdayFormat");
924
+ if (
925
+ Object.is(input, this[calendarInputSymbol]) &&
926
+ format === this[calendarFormatSymbol]
927
+ )
928
+ return;
929
+ this[calendarInputSymbol] = input;
930
+ this[calendarFormatSymbol] = format;
931
+ const data = generateCalendarData.call(this);
932
+ this.setOption("calendarDays", data.calendarDays);
933
+ this.setOption("calendarWeekdays", data.calendarWeekdays);
934
+ if (this[calendarElementSymbol]) initEventHandler.call(this);
935
+ }
936
+
891
937
  /**
892
938
  * Generates a map that contains an array of appointments for each day within the calendar range.
893
939
  * Multi-day appointments will appear on each day they span.
@@ -947,15 +993,19 @@ function initEventHandler() {
947
993
  const self = this;
948
994
 
949
995
  setTimeout(() => {
950
- this.attachObserver(
951
- new Observer(() => {
996
+ if (!this[appointmentObserverSymbol]) {
997
+ this[appointmentObserverSymbol] = new Observer(() => {
952
998
  placeAppointments.call(this);
953
- }),
954
- );
999
+ });
1000
+ this.attachObserver(this[appointmentObserverSymbol]);
1001
+ }
1002
+ this[boundDaysSymbol] ??= new WeakSet();
955
1003
 
956
1004
  this[calendarElementSymbol]
957
1005
  .querySelectorAll("[data-monster-role='day-cell']")
958
1006
  .forEach((element) => {
1007
+ if (this[boundDaysSymbol].has(element)) return;
1008
+ this[boundDaysSymbol].add(element);
959
1009
  element.addEventListener("click", (event) => {
960
1010
  const hoveredElement = this.shadowRoot.elementFromPoint(
961
1011
  event.clientX,
@@ -175,17 +175,31 @@ class Resource extends BaseWithOptions {
175
175
  }
176
176
 
177
177
  return new Promise(function (resolve, reject) {
178
+ let settled = false;
179
+ const cleanup = () => {
180
+ settled = true;
181
+ clearTimeout(timeout);
182
+ self[internalStateSymbol].detachObserver(observer);
183
+ };
178
184
  const timeout = setTimeout(() => {
185
+ cleanup();
179
186
  reject("timeout");
180
187
  }, self.getOption("timeout"));
181
188
 
182
- const observer = new Observer(() => {
183
- clearTimeout(timeout);
184
- self[internalStateSymbol].detachObserver(observer);
185
- resolve();
186
- });
189
+ const check = () => {
190
+ if (settled) return;
191
+ const state = self[internalStateSymbol].getSubject();
192
+ if (state.loaded !== true) return;
193
+ cleanup();
194
+ if (state.error !== undefined) reject(state.error);
195
+ else resolve();
196
+ };
197
+ // setSubject updates several properties. Observe its final state and
198
+ // avoid removing observers while their notification list is iterating.
199
+ const observer = new Observer(() => queueMicrotask(check));
187
200
 
188
201
  self[internalStateSymbol].attachObserver(observer);
202
+ queueMicrotask(check);
189
203
  });
190
204
  }
191
205
 
@@ -16,39 +16,44 @@ export function sanitizeHtml(htmlString, options = {}) {
16
16
  );
17
17
 
18
18
  const blockedTags = options.blockedTags || [];
19
-
20
- blockedTags.forEach((tag) => {
21
- doc.querySelectorAll(tag).forEach((el) => el.remove());
22
- });
23
-
24
- // remove dangerous attributes
25
- const dangerousAttr = /^on/i;
26
- const urlAttrs = ["xlink:href", "action", "formaction"];
27
-
28
- doc.querySelectorAll("*").forEach((el) => {
29
- // remove dangerous attributes
30
- [...el.attributes].forEach((attr) => {
31
- const name = attr.name;
32
- const value = attr.value;
33
-
34
- // remove attributes that start with "on" (event handlers)
35
- if (dangerousAttr.test(name)) {
36
- el.removeAttribute(name);
19
+ const animationTags = new Set([
20
+ "animate",
21
+ "set",
22
+ "animatemotion",
23
+ "animatetransform",
24
+ "animatecolor",
25
+ "discard",
26
+ ]);
27
+ const urlAttrs = new Set(["href", "xlink:href", "action", "formaction"]);
28
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: URL schemes must be checked after removing control characters.
29
+ const urlControlCharacters = /[\u0000-\u0020\u007f-\u009f]/g;
30
+
31
+ function sanitizeTree(root) {
32
+ for (const tag of blockedTags) {
33
+ for (const el of root.querySelectorAll(tag)) el.remove();
34
+ }
35
+ for (const el of root.querySelectorAll("*")) {
36
+ // SVG animations can change a safe URL after the attribute check.
37
+ if (animationTags.has(el.localName.toLowerCase())) {
38
+ el.remove();
39
+ continue;
37
40
  }
38
-
39
- // remove URL attributes that start with javascript:, data:, or vbscript:
40
- if (urlAttrs.includes(name)) {
41
- const val = value.trim().toLowerCase();
42
- if (
43
- val.startsWith("javascript:") ||
44
- val.startsWith("data:") ||
45
- val.startsWith("vbscript:")
46
- ) {
41
+ for (const { name, value } of [...el.attributes]) {
42
+ if (/^on/i.test(name)) {
47
43
  el.removeAttribute(name);
44
+ } else if (urlAttrs.has(name.toLowerCase())) {
45
+ const normalized = value.replace(urlControlCharacters, "").toLowerCase();
46
+ if (/^(javascript|data|vbscript):/.test(normalized)) {
47
+ el.removeAttribute(name);
48
+ }
48
49
  }
49
50
  }
50
- });
51
- });
51
+ if (el.localName === "template" && el.content) {
52
+ sanitizeTree(el.content);
53
+ }
54
+ }
55
+ }
56
+ sanitizeTree(doc);
52
57
 
53
58
  return doc.body.innerHTML;
54
59
  }
@@ -6,6 +6,21 @@ import { ResizeObserverMock } from "../../../util/resize-observer.mjs";
6
6
  const expect = chai.expect;
7
7
  chai.use(chaiDom);
8
8
 
9
+ // Supply geometry before the first layout can populate the measurement cache.
10
+ function installColumnBarGeometry(parentWidth) {
11
+ const original = HTMLElement.prototype.getBoundingClientRect;
12
+ HTMLElement.prototype.getBoundingClientRect = function () {
13
+ if (this.localName === "monster-column-bar") return {width:0};
14
+ if (this.getRootNode().host?.localName === "monster-column-bar") {
15
+ const role = this.getAttribute("data-monster-role");
16
+ if (role === "control") return {width:parentWidth()};
17
+ if (role === "settings-button" || this.localName === "li") return {width:20};
18
+ }
19
+ return original.call(this);
20
+ };
21
+ return () => { HTMLElement.prototype.getBoundingClientRect = original; };
22
+ }
23
+
9
24
  function dispatchPointerEvent(target, type, options = {}) {
10
25
  const event = new Event(type, {
11
26
  bubbles: true,
@@ -35,6 +50,9 @@ function dispatchPointerEvent(target, type, options = {}) {
35
50
 
36
51
  function mockScrollableElement(element, { clientWidth, scrollWidth, scrollLeft = 0 }) {
37
52
  let currentScrollLeft = scrollLeft;
53
+ // Synthetic events have no browser-owned active pointer to capture.
54
+ element.setPointerCapture = () => {};
55
+ element.releasePointerCapture = () => {};
38
56
 
39
57
  Object.defineProperty(element, "clientWidth", {
40
58
  configurable: true,
@@ -447,6 +465,7 @@ describe("Datatable drag scroll", function () {
447
465
  });
448
466
 
449
467
  it("defers column bar resize updates to the next animation frame", async function () {
468
+ let restoreGeometry = () => {};
450
469
  const OriginalResizeObserver = window.ResizeObserver;
451
470
  const originalGlobalResizeObserver = globalThis.ResizeObserver;
452
471
  const originalRequestAnimationFrame = window.requestAnimationFrame;
@@ -468,6 +487,7 @@ describe("Datatable drag scroll", function () {
468
487
  const mocks = document.getElementById("mocks");
469
488
  const wrapper = document.createElement("div");
470
489
  let parentWidth = 400;
490
+ restoreGeometry = installColumnBarGeometry(() => parentWidth);
471
491
 
472
492
  Object.defineProperty(wrapper, "getBoundingClientRect", {
473
493
  configurable: true,
@@ -499,6 +519,7 @@ describe("Datatable drag scroll", function () {
499
519
  "[data-monster-role=dots]",
500
520
  );
501
521
  const dots = Array.from(dotsContainer.querySelectorAll("li"));
522
+ dots.forEach(dot => { dot.style.margin = "0"; });
502
523
 
503
524
  expect(control).to.exist;
504
525
  expect(settingsButton).to.exist;
@@ -519,7 +540,7 @@ describe("Datatable drag scroll", function () {
519
540
  });
520
541
  });
521
542
 
522
- await new Promise((resolve) => setTimeout(resolve, 0));
543
+ await new Promise((resolve) => setTimeout(resolve, 30));
523
544
 
524
545
  expect(
525
546
  dotsContainer.querySelector(".dots-overflow-indicator"),
@@ -551,6 +572,7 @@ describe("Datatable drag scroll", function () {
551
572
  dotsContainer.querySelector(".dots-overflow-indicator"),
552
573
  ).to.exist;
553
574
  } finally {
575
+ restoreGeometry();
554
576
  window.ResizeObserver = OriginalResizeObserver;
555
577
  globalThis.ResizeObserver = originalGlobalResizeObserver;
556
578
  window.requestAnimationFrame = originalRequestAnimationFrame;
@@ -559,6 +581,7 @@ describe("Datatable drag scroll", function () {
559
581
  });
560
582
 
561
583
  it("coalesces column bar dot updates and ignores overflow indicator mutations", async function () {
584
+ let restoreGeometry = () => {};
562
585
  const OriginalResizeObserver = window.ResizeObserver;
563
586
  const originalGlobalResizeObserver = globalThis.ResizeObserver;
564
587
  const originalRequestAnimationFrame = window.requestAnimationFrame;
@@ -580,6 +603,7 @@ describe("Datatable drag scroll", function () {
580
603
  const mocks = document.getElementById("mocks");
581
604
  const wrapper = document.createElement("div");
582
605
  let parentWidth = 80;
606
+ restoreGeometry = installColumnBarGeometry(() => parentWidth);
583
607
 
584
608
  Object.defineProperty(wrapper, "getBoundingClientRect", {
585
609
  configurable: true,
@@ -612,6 +636,7 @@ describe("Datatable drag scroll", function () {
612
636
  "[data-monster-role=dots]",
613
637
  );
614
638
  const dots = Array.from(dotsContainer.querySelectorAll("li"));
639
+ dots.forEach(dot => { dot.style.margin = "0"; });
615
640
 
616
641
  Object.defineProperty(control, "getBoundingClientRect", {
617
642
  configurable: true,
@@ -628,6 +653,7 @@ describe("Datatable drag scroll", function () {
628
653
  });
629
654
  });
630
655
 
656
+ await new Promise((resolve) => setTimeout(resolve, 30));
631
657
  return { columnBar, control, dotsContainer };
632
658
  };
633
659
 
@@ -669,6 +695,7 @@ describe("Datatable drag scroll", function () {
669
695
 
670
696
  expect(scheduledCallbacks.length).to.equal(0);
671
697
  } finally {
698
+ restoreGeometry();
672
699
  window.ResizeObserver = OriginalResizeObserver;
673
700
  globalThis.ResizeObserver = originalGlobalResizeObserver;
674
701
  window.requestAnimationFrame = originalRequestAnimationFrame;
@@ -104,6 +104,9 @@ describe("ButtonBar", function () {
104
104
  "Weitere Aktionen",
105
105
  );
106
106
 
107
+ // The empty fixture has no overflow; expose the switch for the focus check.
108
+ switchButton.hidden = false;
109
+ switchButton.classList.remove("hidden");
107
110
  bar.showDialog();
108
111
  expect(switchButton.getAttribute("aria-expanded")).to.equal("true");
109
112
  switchButton.dispatchEvent(
@@ -403,7 +406,7 @@ describe("ButtonBar", function () {
403
406
  it("should allow themes to space main and overflow button groups", function () {
404
407
  const cssText = ButtonBar.getCSSStyleSheet()
405
408
  .flatMap((styleSheet) => Array.from(styleSheet.cssRules))
406
- .map((rule) => rule.cssText)
409
+ .map((rule) => rule.cssText.replace(/="([\w-]+)"/g, "=$1").replace(/>\s+\[/g, ">["))
407
410
  .join("\n");
408
411
 
409
412
  expect(cssText).to.contain("slot[name=popper]");
@@ -424,7 +427,7 @@ describe("ButtonBar", function () {
424
427
  it("should stack a nested button bar inside a parent overflow item", function () {
425
428
  const cssText = ButtonBar.getCSSStyleSheet()
426
429
  .flatMap((styleSheet) => Array.from(styleSheet.cssRules))
427
- .map((rule) => rule.cssText)
430
+ .map((rule) => rule.cssText.replace(/="([\w-]+)"/g, "=$1").replace(/>\s+\[/g, ">["))
428
431
  .join("\n");
429
432
 
430
433
  expect(cssText).to.match(
@@ -517,11 +520,11 @@ describe("ButtonBar", function () {
517
520
 
518
521
  expect(buttons[1].style.marginLeft).to.equal("");
519
522
  expect(buttons[0].style.borderRadius).to.equal("8px");
520
- expect(buttons[0].style.borderTopRightRadius).to.equal("");
521
- expect(buttons[0].style.borderBottomRightRadius).to.equal("");
523
+ expect(buttons[0].style.borderTopRightRadius || buttons[0].style.borderRadius).to.equal("8px");
524
+ expect(buttons[0].style.borderBottomRightRadius || buttons[0].style.borderRadius).to.equal("8px");
522
525
  expect(buttons[1].style.borderRadius).to.equal("8px");
523
- expect(buttons[1].style.borderTopLeftRadius).to.equal("");
524
- expect(buttons[1].style.borderBottomLeftRadius).to.equal("");
526
+ expect(buttons[1].style.borderTopLeftRadius || buttons[1].style.borderRadius).to.equal("8px");
527
+ expect(buttons[1].style.borderBottomLeftRadius || buttons[1].style.borderRadius).to.equal("8px");
525
528
  } finally {
526
529
  window.requestAnimationFrame = originalRequestAnimationFrame;
527
530
  globalThis.requestAnimationFrame = originalGlobalRequestAnimationFrame;
@@ -150,7 +150,7 @@ describe('ConfirmButton', function () {
150
150
 
151
151
  const cssText = ConfirmButton.getCSSStyleSheet()
152
152
  .flatMap((styleSheet) => Array.from(styleSheet.cssRules))
153
- .map((rule) => rule.cssText)
153
+ .map((rule) => rule.cssText.replace(/="([\w-]+)"/g, "=$1").replace(/>\s+\[/g, ">["))
154
154
  .join("\n");
155
155
 
156
156
  expect(cssText).to.contain('div[data-monster-role=decision]');
@@ -162,14 +162,14 @@ describe('ConfirmButton', function () {
162
162
 
163
163
  const cssText = ConfirmButton.getCSSStyleSheet()
164
164
  .flatMap((styleSheet) => Array.from(styleSheet.cssRules))
165
- .map((rule) => rule.cssText)
165
+ .map((rule) => rule.cssText.replace(/="([\w-]+)"/g, "=$1").replace(/>\s+\[/g, ">["))
166
166
  .join("\n");
167
167
 
168
168
  expect(cssText).to.contain('div[data-monster-role=decision]');
169
169
  expect(cssText).to.contain('max-width: 100%');
170
170
  expect(cssText).to.contain('min-width: 0px');
171
171
  expect(cssText).to.contain('flex-wrap: wrap');
172
- expect(cssText).to.contain('div[data-monster-role=decision]>*');
172
+ expect(cssText.replace(/\s*>\s*/g, '>')).to.contain('div[data-monster-role=decision]>*');
173
173
  expect(cssText).to.contain('flex: 1 1 max-content');
174
174
 
175
175
  });
@@ -178,7 +178,7 @@ describe('ConfirmButton', function () {
178
178
 
179
179
  const cssText = ConfirmButton.getCSSStyleSheet()
180
180
  .flatMap((styleSheet) => Array.from(styleSheet.cssRules))
181
- .map((rule) => rule.cssText)
181
+ .map((rule) => rule.cssText.replace(/="([\w-]+)"/g, "=$1").replace(/>\s+\[/g, ">["))
182
182
  .join("\n");
183
183
 
184
184
  expect(cssText).to.contain(':host(monster-confirm-button)');