@schukai/monster 4.144.0 → 4.145.1

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.144.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.","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"}
@@ -193,6 +193,19 @@ const ATTRIBUTE_OPTION_LAYOUT_STACKED_BREAKPOINT =
193
193
  const ATTRIBUTE_OPTION_LAYOUT_STACKED_BREAKPOINT_CONTAINER =
194
194
  "data-monster-option-layout-stacked-breakpoint-container";
195
195
 
196
+ /**
197
+ * @private
198
+ * @type {string}
199
+ */
200
+ const ATTRIBUTE_OPTION_LAYOUT_HIDE_WHEN_EMPTY =
201
+ "data-monster-option-layout-hide-when-empty";
202
+
203
+ /**
204
+ * @private
205
+ * @type {string}
206
+ */
207
+ const ATTRIBUTE_AUTO_HIDDEN_EMPTY = "data-monster-empty-hidden";
208
+
196
209
  /**
197
210
  * @private
198
211
  * @type {string}
@@ -250,6 +263,7 @@ class ControlBar extends CustomElement {
250
263
  stackedAlignment: undefined,
251
264
  stackedBreakpoint: undefined,
252
265
  stackedBreakpointContainer: undefined,
266
+ hideWhenEmpty: false,
253
267
  },
254
268
  popper: {
255
269
  placement: "left",
@@ -309,7 +323,8 @@ class ControlBar extends CustomElement {
309
323
  path === "layout.alignment" ||
310
324
  path === "layout.stackedAlignment" ||
311
325
  path === "layout.stackedBreakpoint" ||
312
- path === "layout.stackedBreakpointContainer"
326
+ path === "layout.stackedBreakpointContainer" ||
327
+ path === "layout.hideWhenEmpty"
313
328
  ) {
314
329
  syncLayoutState.call(this, {
315
330
  observe: path === "layout.stackedBreakpointContainer",
@@ -366,6 +381,7 @@ class ControlBar extends CustomElement {
366
381
  attributes.push(ATTRIBUTE_OPTION_LAYOUT_STACKED_ALIGNMENT);
367
382
  attributes.push(ATTRIBUTE_OPTION_LAYOUT_STACKED_BREAKPOINT);
368
383
  attributes.push(ATTRIBUTE_OPTION_LAYOUT_STACKED_BREAKPOINT_CONTAINER);
384
+ attributes.push(ATTRIBUTE_OPTION_LAYOUT_HIDE_WHEN_EMPTY);
369
385
  return attributes;
370
386
  }
371
387
 
@@ -572,6 +588,11 @@ function initEventHandler() {
572
588
  syncLayoutState.call(self, { observe: true });
573
589
  };
574
590
 
591
+ self[attributeObserverSymbol][ATTRIBUTE_OPTION_LAYOUT_HIDE_WHEN_EMPTY] =
592
+ () => {
593
+ syncLayoutState.call(self);
594
+ };
595
+
575
596
  self[resizeObserverSymbol] = new ResizeObserver((entries) => {
576
597
  if (self[layoutStateSymbol]?.suppressResize) {
577
598
  return;
@@ -655,6 +676,8 @@ function runLayout() {
655
676
  state.running = true;
656
677
 
657
678
  new Processing(() => {
679
+ syncEmptyVisibility.call(this);
680
+
658
681
  if (needsObserve) {
659
682
  updateResizeObserverObservation.call(this);
660
683
  }
@@ -845,6 +868,97 @@ function rearrangeItems() {
845
868
  if (!shouldShowSwitch) {
846
869
  hide.call(this);
847
870
  }
871
+ syncEmptyVisibility.call(this);
872
+ }
873
+
874
+ /**
875
+ * @private
876
+ * @return {void}
877
+ */
878
+ function syncEmptyVisibility() {
879
+ if (this.getOption("layout.hideWhenEmpty", false) !== true) {
880
+ clearEmptyVisibilityState.call(this);
881
+ return;
882
+ }
883
+
884
+ if (hasVisibleControlItem.call(this)) {
885
+ clearEmptyVisibilityState.call(this);
886
+ return;
887
+ }
888
+
889
+ if (this.hasAttribute(ATTRIBUTE_AUTO_HIDDEN_EMPTY)) {
890
+ return;
891
+ }
892
+
893
+ if (this.hasAttribute("hidden")) {
894
+ return;
895
+ }
896
+
897
+ this.setAttribute("hidden", "");
898
+ this.setAttribute(ATTRIBUTE_AUTO_HIDDEN_EMPTY, "");
899
+ }
900
+
901
+ /**
902
+ * @private
903
+ * @return {void}
904
+ */
905
+ function clearEmptyVisibilityState() {
906
+ if (this.hasAttribute(ATTRIBUTE_AUTO_HIDDEN_EMPTY)) {
907
+ this.removeAttribute(ATTRIBUTE_AUTO_HIDDEN_EMPTY);
908
+ if (this.hasAttribute("hidden")) {
909
+ this.removeAttribute("hidden");
910
+ }
911
+ }
912
+ }
913
+
914
+ /**
915
+ * @private
916
+ * @return {boolean}
917
+ */
918
+ function hasVisibleControlItem() {
919
+ return getControlItems.call(this).some((item) => {
920
+ return isControlItemVisible.call(this, item);
921
+ });
922
+ }
923
+
924
+ /**
925
+ * @private
926
+ * @return {HTMLElement[]}
927
+ */
928
+ function getControlItems() {
929
+ return Array.from(this.children).filter((item) => {
930
+ if (!(item instanceof HTMLElement)) {
931
+ return false;
932
+ }
933
+ return item.slot === "" || item.slot === "popper";
934
+ });
935
+ }
936
+
937
+ /**
938
+ * @private
939
+ * @param {HTMLElement} item
940
+ * @return {boolean}
941
+ */
942
+ function isControlItemVisible(item) {
943
+ if (isControlBarSpacerElement(item) || isElementSelfHidden(item)) {
944
+ return false;
945
+ }
946
+
947
+ const computedStyle = getComputedStyle(item);
948
+ if (computedStyle.opacity === "0") {
949
+ return false;
950
+ }
951
+
952
+ const containedControls = getContainedControlElements(item);
953
+ if (containedControls.length === 0) {
954
+ return true;
955
+ }
956
+
957
+ return containedControls.some((control) => {
958
+ return (
959
+ !isElementSelfHidden(control) && getComputedStyle(control).opacity !== "0"
960
+ );
961
+ });
848
962
  }
849
963
 
850
964
  /**
@@ -34,6 +34,8 @@ import {
34
34
  export {
35
35
  applyAdaptiveFloatingElementSize,
36
36
  closePositionedPopper,
37
+ createVisibilityRecoveryConfig,
38
+ getFloatingVisibleRatio,
37
39
  resolveClippingBoundaryElement,
38
40
  resolveParentPopperContentBoundary,
39
41
  isPositionedPopperOpen,
@@ -43,11 +45,13 @@ export {
43
45
 
44
46
  const autoUpdateCleanupMap = new WeakMap();
45
47
  const settlingFrameMap = new WeakMap();
48
+ const visibilityRecoveryMap = new WeakMap();
46
49
  const floatingResizeObserverMap = new WeakMap();
47
50
  const floatingSyncCycleMap = new WeakMap();
48
51
  const floatingAppearanceFrameMap = new WeakMap();
49
52
  const floatingAppearanceTimeoutMap = new WeakMap();
50
53
  const adaptiveScrollHeightDatasetKey = "monsterAdaptiveScrollHeight";
54
+ const MINIMUM_VISIBLE_FLOATING_RATIO = 0.8;
51
55
 
52
56
  /**
53
57
  * @private
@@ -76,6 +80,7 @@ function openPositionedPopper(controlElement, popperElement, options) {
76
80
 
77
81
  stopAutoUpdate(popperElement);
78
82
  cancelFloatingAppearanceFrame(popperElement);
83
+ visibilityRecoveryMap.delete(popperElement);
79
84
  popperElement.dataset.monsterAppearance = "opening";
80
85
  popperElement.style.display = "block";
81
86
  popperElement.style.position = config.strategy;
@@ -187,6 +192,13 @@ function syncFloatingPopover(
187
192
  if (allowSettlingPass) {
188
193
  scheduleSettlingPass(controlElement, popperElement, config);
189
194
  }
195
+
196
+ scheduleVisibilityRecoveryPass(
197
+ controlElement,
198
+ popperElement,
199
+ config,
200
+ syncCycleId,
201
+ );
190
202
  });
191
203
  }
192
204
 
@@ -194,6 +206,7 @@ function closePositionedPopper(popperElement) {
194
206
  cancelFloatingLayout(popperElement);
195
207
  stopAutoUpdate(popperElement);
196
208
  cancelFloatingAppearanceFrame(popperElement);
209
+ visibilityRecoveryMap.delete(popperElement);
197
210
  delete popperElement.dataset.monsterAppearance;
198
211
  popperElement.style.display = "none";
199
212
  popperElement.style.removeProperty("visibility");
@@ -358,6 +371,203 @@ function normalizeShiftOptions(tokens, detectOverflowOptions) {
358
371
  return options;
359
372
  }
360
373
 
374
+ function scheduleVisibilityRecoveryPass(
375
+ controlElement,
376
+ popperElement,
377
+ config,
378
+ syncCycleId,
379
+ ) {
380
+ if (config.visibilityRecovery === false || config.visibilityRecoveryPass) {
381
+ return;
382
+ }
383
+
384
+ if (!isActiveFloatingSyncCycle(popperElement, syncCycleId)) {
385
+ return;
386
+ }
387
+
388
+ const visibleRatio = getFloatingVisibleRatio(popperElement);
389
+ if (visibleRatio >= MINIMUM_VISIBLE_FLOATING_RATIO) {
390
+ visibilityRecoveryMap.delete(popperElement);
391
+ return;
392
+ }
393
+
394
+ if (visibilityRecoveryMap.has(popperElement)) {
395
+ return;
396
+ }
397
+ visibilityRecoveryMap.set(popperElement, true);
398
+
399
+ enqueueFloatingLayout({
400
+ popperElement,
401
+ reason: FLOATING_LAYOUT_REASON.SETTLE | FLOATING_LAYOUT_REASON.VIEWPORT,
402
+ isActive: () => isPositionedPopperOpen(popperElement),
403
+ position: () => {
404
+ runFloatingUpdateHook(popperElement);
405
+ syncFloatingPopover(
406
+ controlElement,
407
+ popperElement,
408
+ createVisibilityRecoveryConfig(config),
409
+ false,
410
+ );
411
+ },
412
+ });
413
+ }
414
+
415
+ function getFloatingVisibleRatio(floatingElement) {
416
+ if (!(floatingElement instanceof HTMLElement)) {
417
+ return 1;
418
+ }
419
+
420
+ const rect = floatingElement.getBoundingClientRect();
421
+ const area = rect.width * rect.height;
422
+ if (!Number.isFinite(area) || area <= 0) {
423
+ return 1;
424
+ }
425
+
426
+ const visibleRect = getVisibleFloatingRect(floatingElement, rect);
427
+ if (!visibleRect) {
428
+ return 0;
429
+ }
430
+
431
+ const visibleArea = visibleRect.width * visibleRect.height;
432
+ return Math.max(0, Math.min(1, visibleArea / area));
433
+ }
434
+
435
+ function getVisibleFloatingRect(floatingElement, rect) {
436
+ let visibleRect = normalizeRect(rect);
437
+ const viewportRect = {
438
+ top: 0,
439
+ left: 0,
440
+ right: window.innerWidth || document.documentElement.clientWidth || 0,
441
+ bottom: window.innerHeight || document.documentElement.clientHeight || 0,
442
+ };
443
+ viewportRect.width = Math.max(0, viewportRect.right - viewportRect.left);
444
+ viewportRect.height = Math.max(0, viewportRect.bottom - viewportRect.top);
445
+
446
+ visibleRect = intersectRects(visibleRect, viewportRect);
447
+ if (!visibleRect) {
448
+ return null;
449
+ }
450
+
451
+ for (const clippingContainer of getFloatingClippingContainers(floatingElement)) {
452
+ visibleRect = intersectRects(
453
+ visibleRect,
454
+ normalizeRect(clippingContainer.getBoundingClientRect()),
455
+ );
456
+ if (!visibleRect) {
457
+ return null;
458
+ }
459
+ }
460
+
461
+ return visibleRect;
462
+ }
463
+
464
+ function getFloatingClippingContainers(element) {
465
+ const result = [];
466
+ let current = getComposedParent(element);
467
+
468
+ while (current) {
469
+ if (
470
+ current instanceof HTMLElement &&
471
+ isClippingContainer(getComputedStyle(current))
472
+ ) {
473
+ result.push(current);
474
+ }
475
+
476
+ current = getComposedParent(current);
477
+ }
478
+
479
+ return result;
480
+ }
481
+
482
+ function normalizeRect(rect) {
483
+ const left = Number.isFinite(rect.left) ? rect.left : rect.x || 0;
484
+ const top = Number.isFinite(rect.top) ? rect.top : rect.y || 0;
485
+ const right = Number.isFinite(rect.right) ? rect.right : left + rect.width;
486
+ const bottom = Number.isFinite(rect.bottom)
487
+ ? rect.bottom
488
+ : top + rect.height;
489
+
490
+ return {
491
+ top,
492
+ left,
493
+ right,
494
+ bottom,
495
+ width: Math.max(0, right - left),
496
+ height: Math.max(0, bottom - top),
497
+ };
498
+ }
499
+
500
+ function intersectRects(firstRect, secondRect) {
501
+ const left = Math.max(firstRect.left, secondRect.left);
502
+ const top = Math.max(firstRect.top, secondRect.top);
503
+ const right = Math.min(firstRect.right, secondRect.right);
504
+ const bottom = Math.min(firstRect.bottom, secondRect.bottom);
505
+
506
+ if (right <= left || bottom <= top) {
507
+ return null;
508
+ }
509
+
510
+ return {
511
+ top,
512
+ left,
513
+ right,
514
+ bottom,
515
+ width: right - left,
516
+ height: bottom - top,
517
+ };
518
+ }
519
+
520
+ function createVisibilityRecoveryConfig(config) {
521
+ return Object.assign({}, config, {
522
+ visibilityRecoveryPass: true,
523
+ middleware: createVisibilityRecoveryMiddleware(config.middleware),
524
+ });
525
+ }
526
+
527
+ function createVisibilityRecoveryMiddleware(middleware) {
528
+ const source = isArray(middleware) ? middleware : [];
529
+ const offsetMiddleware = [];
530
+ const remainingMiddleware = [];
531
+ let hasShift = false;
532
+
533
+ for (const entry of source) {
534
+ const tokenName = getMiddlewareTokenName(entry);
535
+ if (tokenName === "flip" || tokenName === "autoPlacement") {
536
+ continue;
537
+ }
538
+ if (tokenName === "offset") {
539
+ offsetMiddleware.push(entry);
540
+ continue;
541
+ }
542
+ if (tokenName === "shift") {
543
+ hasShift = true;
544
+ }
545
+ remainingMiddleware.push(entry);
546
+ }
547
+
548
+ if (!hasShift) {
549
+ remainingMiddleware.unshift("shift:crossAxis");
550
+ }
551
+
552
+ return [
553
+ ...offsetMiddleware,
554
+ "autoPlacement:top,bottom,right,left",
555
+ ...remainingMiddleware,
556
+ ];
557
+ }
558
+
559
+ function getMiddlewareTokenName(entry) {
560
+ if (isString(entry)) {
561
+ return entry.split(":").shift();
562
+ }
563
+
564
+ if (isObject(entry) && isString(entry.name)) {
565
+ return entry.name;
566
+ }
567
+
568
+ return null;
569
+ }
570
+
361
571
  function createAdaptiveSizeMiddleware(
362
572
  detectOverflowOptions,
363
573
  popperElement,
@@ -6,6 +6,31 @@ import { ResizeObserverMock } from "../../../util/resize-observer.mjs";
6
6
  let expect = chai.expect;
7
7
  chai.use(chaiDom);
8
8
 
9
+ function waitForCondition(check, { timeout = 4000, interval = 25 } = {}) {
10
+ return new Promise((resolve, reject) => {
11
+ const start = Date.now();
12
+ const poll = () => {
13
+ try {
14
+ if (check()) {
15
+ resolve();
16
+ return;
17
+ }
18
+ } catch {}
19
+
20
+ if (Date.now() - start >= timeout) {
21
+ reject(new Error("Timed out waiting for condition"));
22
+ return;
23
+ }
24
+
25
+ setTimeout(poll, interval);
26
+ };
27
+
28
+ poll();
29
+ });
30
+ }
31
+
32
+ const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 120));
33
+
9
34
  const html = `
10
35
  <div id="test1">
11
36
  <monster-button-bar id="bar"></monster-button-bar>
@@ -80,6 +105,117 @@ describe("ButtonBar", function () {
80
105
  }, 50);
81
106
  });
82
107
 
108
+ it("should hide an empty button bar when configured to hide empty bars", async function () {
109
+ const mocks = document.getElementById("mocks");
110
+ mocks.innerHTML = `
111
+ <monster-button-bar
112
+ id="empty-auto-hidden-button-bar"
113
+ data-monster-option-layout-hide-when-empty="true"
114
+ ></monster-button-bar>
115
+ `;
116
+ const bar = document.getElementById("empty-auto-hidden-button-bar");
117
+
118
+ await waitForCondition(() => {
119
+ return (
120
+ bar.hasAttribute("hidden") &&
121
+ bar.hasAttribute("data-monster-empty-hidden")
122
+ );
123
+ });
124
+
125
+ expect(bar.getOption("layout.hideWhenEmpty")).to.equal(true);
126
+ expect(bar.hasAttribute("hidden")).to.be.true;
127
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.true;
128
+ });
129
+
130
+ it("should reveal an auto-hidden button bar when a button becomes visible", async function () {
131
+ const mocks = document.getElementById("mocks");
132
+ mocks.innerHTML = `
133
+ <monster-button-bar
134
+ id="dynamic-auto-hidden-button-bar"
135
+ data-monster-option-layout-hide-when-empty="true"
136
+ >
137
+ <button id="dynamic-auto-hidden-button" hidden>Run</button>
138
+ </monster-button-bar>
139
+ `;
140
+ const bar = document.getElementById("dynamic-auto-hidden-button-bar");
141
+ const button = document.getElementById("dynamic-auto-hidden-button");
142
+
143
+ await waitForLayout();
144
+
145
+ expect(bar.hasAttribute("hidden")).to.be.true;
146
+
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;
152
+ });
153
+
154
+ it("should keep an auto-hidden button bar visible when buttons overflow into the popper", async function () {
155
+ const mocks = document.getElementById("mocks");
156
+ mocks.innerHTML = `
157
+ <div id="overflow-auto-hidden-wrapper">
158
+ <monster-button-bar
159
+ id="overflow-auto-hidden-button-bar"
160
+ data-monster-option-layout-hide-when-empty="true"
161
+ >
162
+ <button id="overflow-auto-hidden-a" type="button">One</button>
163
+ <button id="overflow-auto-hidden-b" type="button">Two</button>
164
+ </monster-button-bar>
165
+ </div>
166
+ `;
167
+ const wrapper = document.getElementById("overflow-auto-hidden-wrapper");
168
+ const bar = document.getElementById("overflow-auto-hidden-button-bar");
169
+ const buttons = [
170
+ document.getElementById("overflow-auto-hidden-a"),
171
+ document.getElementById("overflow-auto-hidden-b"),
172
+ ];
173
+ const switchButton = bar.shadowRoot.querySelector(
174
+ '[data-monster-role="switch"]',
175
+ );
176
+
177
+ wrapper.style.boxSizing = "border-box";
178
+ wrapper.style.width = "30px";
179
+ Object.defineProperty(wrapper, "clientWidth", {
180
+ configurable: true,
181
+ value: 30,
182
+ });
183
+ Object.defineProperty(switchButton, "offsetWidth", {
184
+ configurable: true,
185
+ value: 20,
186
+ });
187
+
188
+ for (const button of buttons) {
189
+ Object.defineProperty(button, "offsetWidth", {
190
+ configurable: true,
191
+ value: 48,
192
+ });
193
+ Object.defineProperty(button, "offsetHeight", {
194
+ configurable: true,
195
+ value: 30,
196
+ });
197
+ button.getBoundingClientRect = () => ({
198
+ width: 48,
199
+ height: 30,
200
+ top: 0,
201
+ right: 48,
202
+ bottom: 30,
203
+ left: 0,
204
+ x: 0,
205
+ y: 0,
206
+ toJSON: () => {},
207
+ });
208
+ }
209
+
210
+ await waitForLayout();
211
+
212
+ expect(buttons.every((button) => button.getAttribute("slot") === "popper"))
213
+ .to.be.true;
214
+ expect(switchButton.hasAttribute("hidden")).to.be.false;
215
+ expect(bar.hasAttribute("hidden")).to.be.false;
216
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.false;
217
+ });
218
+
83
219
  it("should default the button bar layout alignment to left", function () {
84
220
  const bar = document.getElementById("bar");
85
221
  const buttonBar = bar.shadowRoot.querySelector(
@@ -6,6 +6,9 @@ import { ResizeObserverMock } from "../../../util/resize-observer.mjs";
6
6
  const expect = chai.expect;
7
7
  chai.use(chaiDom);
8
8
 
9
+ const waitForLayout = (timeout = 120) =>
10
+ new Promise((resolve) => setTimeout(resolve, timeout));
11
+
9
12
  const html = `
10
13
  <div id="test1">
11
14
  <monster-control-bar id="bar">
@@ -97,6 +100,119 @@ describe("ControlBar", function () {
97
100
  expect(controlBar.style.opacity).to.not.equal("0");
98
101
  });
99
102
 
103
+ it("should keep an empty control bar visible by default", async function () {
104
+ const bar = document.getElementById("bar-right");
105
+
106
+ await waitForLayout();
107
+
108
+ expect(bar.hasAttribute("hidden")).to.be.false;
109
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.false;
110
+ });
111
+
112
+ it("should hide an empty control bar when the empty option is enabled at runtime", async function () {
113
+ const bar = document.getElementById("bar-right");
114
+
115
+ await waitForLayout();
116
+
117
+ bar.setOption("layout.hideWhenEmpty", true);
118
+ await waitForLayout();
119
+
120
+ expect(bar.hasAttribute("hidden")).to.be.true;
121
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.true;
122
+ });
123
+
124
+ it("should hide an empty control bar when configured to hide empty bars", async function () {
125
+ const mocks = document.getElementById("mocks");
126
+ mocks.innerHTML = `
127
+ <monster-control-bar
128
+ id="empty-auto-hidden-bar"
129
+ data-monster-option-layout-hide-when-empty="true"
130
+ ></monster-control-bar>
131
+ `;
132
+ const bar = document.getElementById("empty-auto-hidden-bar");
133
+
134
+ await waitForLayout();
135
+
136
+ expect(bar.getOption("layout.hideWhenEmpty")).to.equal(true);
137
+ expect(bar.hasAttribute("hidden")).to.be.true;
138
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.true;
139
+ });
140
+
141
+ it("should update empty control bar visibility when child controls become visible", async function () {
142
+ const mocks = document.getElementById("mocks");
143
+ mocks.innerHTML = `
144
+ <monster-control-bar
145
+ id="dynamic-auto-hidden-bar"
146
+ data-monster-option-layout-hide-when-empty="true"
147
+ >
148
+ <button id="dynamic-auto-hidden-button" hidden>Run</button>
149
+ </monster-control-bar>
150
+ `;
151
+ const bar = document.getElementById("dynamic-auto-hidden-bar");
152
+ const button = document.getElementById("dynamic-auto-hidden-button");
153
+
154
+ await waitForLayout();
155
+
156
+ expect(bar.hasAttribute("hidden")).to.be.true;
157
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.true;
158
+
159
+ button.removeAttribute("hidden");
160
+ await waitForLayout(360);
161
+
162
+ expect(bar.hasAttribute("hidden")).to.be.false;
163
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.false;
164
+
165
+ button.style.display = "none";
166
+ await waitForLayout(360);
167
+
168
+ expect(bar.hasAttribute("hidden")).to.be.true;
169
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.true;
170
+ });
171
+
172
+ it("should release empty auto-hidden state when the option is disabled", async function () {
173
+ const mocks = document.getElementById("mocks");
174
+ mocks.innerHTML = `
175
+ <monster-control-bar
176
+ id="option-auto-hidden-bar"
177
+ data-monster-option-layout-hide-when-empty="true"
178
+ ></monster-control-bar>
179
+ `;
180
+ const bar = document.getElementById("option-auto-hidden-bar");
181
+
182
+ await waitForLayout();
183
+
184
+ expect(bar.hasAttribute("hidden")).to.be.true;
185
+
186
+ bar.setOption("layout.hideWhenEmpty", false);
187
+ await waitForLayout();
188
+
189
+ expect(bar.hasAttribute("hidden")).to.be.false;
190
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.false;
191
+ });
192
+
193
+ it("should preserve a manual hidden attribute when empty hiding is disabled", async function () {
194
+ const mocks = document.getElementById("mocks");
195
+ mocks.innerHTML = `
196
+ <monster-control-bar
197
+ id="manual-hidden-bar"
198
+ hidden
199
+ data-monster-option-layout-hide-when-empty="true"
200
+ ></monster-control-bar>
201
+ `;
202
+ const bar = document.getElementById("manual-hidden-bar");
203
+
204
+ await waitForLayout();
205
+
206
+ expect(bar.hasAttribute("hidden")).to.be.true;
207
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.false;
208
+
209
+ bar.setOption("layout.hideWhenEmpty", false);
210
+ await waitForLayout();
211
+
212
+ expect(bar.hasAttribute("hidden")).to.be.true;
213
+ expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.false;
214
+ });
215
+
100
216
  it("should allow configuring the control bar layout alignment to right", function (done) {
101
217
  const bar = document.getElementById("bar-right");
102
218
 
@@ -5,6 +5,8 @@ let expect = chai.expect;
5
5
 
6
6
  let resolveClippingBoundaryElement;
7
7
  let applyAdaptiveFloatingElementSize;
8
+ let createVisibilityRecoveryConfig;
9
+ let getFloatingVisibleRatio;
8
10
 
9
11
  describe("form floating-ui boundary resolution", function () {
10
12
  before(function (done) {
@@ -17,6 +19,8 @@ describe("form floating-ui boundary resolution", function () {
17
19
  .then((m) => {
18
20
  resolveClippingBoundaryElement = m.resolveClippingBoundaryElement;
19
21
  applyAdaptiveFloatingElementSize = m.applyAdaptiveFloatingElementSize;
22
+ createVisibilityRecoveryConfig = m.createVisibilityRecoveryConfig;
23
+ getFloatingVisibleRatio = m.getFloatingVisibleRatio;
20
24
  done();
21
25
  })
22
26
  .catch((e) => done(e));
@@ -398,4 +402,78 @@ describe("form floating-ui boundary resolution", function () {
398
402
  expect(content.style.height).to.equal("200px");
399
403
  expect(content.style.maxHeight).to.equal("200px");
400
404
  });
405
+
406
+ it("should calculate the visible popper ratio against clipping containers", function () {
407
+ const mocks = document.getElementById("mocks");
408
+ const wrapper = document.createElement("div");
409
+ const popper = document.createElement("div");
410
+ const originalInnerWidth = window.innerWidth;
411
+ const originalInnerHeight = window.innerHeight;
412
+
413
+ Object.defineProperty(window, "innerWidth", {
414
+ configurable: true,
415
+ value: 200,
416
+ });
417
+ Object.defineProperty(window, "innerHeight", {
418
+ configurable: true,
419
+ value: 200,
420
+ });
421
+
422
+ wrapper.style.overflow = "hidden";
423
+ wrapper.getBoundingClientRect = () => {
424
+ return {
425
+ width: 100,
426
+ height: 100,
427
+ top: 0,
428
+ left: 0,
429
+ right: 100,
430
+ bottom: 100,
431
+ x: 0,
432
+ y: 0,
433
+ };
434
+ };
435
+ popper.getBoundingClientRect = () => {
436
+ return {
437
+ width: 100,
438
+ height: 100,
439
+ top: -50,
440
+ left: 50,
441
+ right: 150,
442
+ bottom: 50,
443
+ x: 50,
444
+ y: -50,
445
+ };
446
+ };
447
+
448
+ wrapper.appendChild(popper);
449
+ mocks.appendChild(wrapper);
450
+
451
+ try {
452
+ expect(getFloatingVisibleRatio(popper)).to.equal(0.25);
453
+ } finally {
454
+ Object.defineProperty(window, "innerWidth", {
455
+ configurable: true,
456
+ value: originalInnerWidth,
457
+ });
458
+ Object.defineProperty(window, "innerHeight", {
459
+ configurable: true,
460
+ value: originalInnerHeight,
461
+ });
462
+ }
463
+ });
464
+
465
+ it("should build a bounded visibility recovery pass with auto placement", function () {
466
+ const recoveryConfig = createVisibilityRecoveryConfig({
467
+ placement: "top",
468
+ middleware: ["flip", "shift", "offset:15", "arrow"],
469
+ });
470
+
471
+ expect(recoveryConfig.visibilityRecoveryPass).to.equal(true);
472
+ expect(recoveryConfig.middleware).to.deep.equal([
473
+ "offset:15",
474
+ "autoPlacement:top,bottom,right,left",
475
+ "shift",
476
+ "arrow",
477
+ ]);
478
+ });
401
479
  });