Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 5 additions & 41 deletions packages/ui/components/overlays/src/OverlayController.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
import { _adoptStyleUtils } from './utils/adopt-styles.js';

/**
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
* @typedef {import('@lion/ui/types/overlays.js').OverlayPhase} OverlayPhase
* @typedef {import('@lion/ui/types/overlays.js').ViewportConfig} ViewportConfig
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
* @typedef {import('@popperjs/core').Options} PopperOptions
Expand Down Expand Up @@ -852,46 +852,7 @@ export class OverlayController extends EventTarget {
return;
}

switch (phase) {
case 'before-show':
this.__bodyClientWidth = document.body.clientWidth;
this.__bodyClientHeight = document.body.clientHeight;
this.__bodyMarginRightInline = document.body.style.marginRight;
this.__bodyMarginBottomInline = document.body.style.marginBottom;
break;
case 'show': {
if (window.getComputedStyle) {
const bodyStyle = window.getComputedStyle(document.body);
this.__bodyMarginRight = parseInt(bodyStyle.getPropertyValue('margin-right'), 10);
this.__bodyMarginBottom = parseInt(bodyStyle.getPropertyValue('margin-bottom'), 10);
} else {
this.__bodyMarginRight = 0;
this.__bodyMarginBottom = 0;
}
const scrollbarWidth =
document.body.clientWidth - /** @type {number} */ (this.__bodyClientWidth);
const scrollbarHeight =
document.body.clientHeight - /** @type {number} */ (this.__bodyClientHeight);
const newMarginRight = this.__bodyMarginRight + scrollbarWidth;
const newMarginBottom = this.__bodyMarginBottom + scrollbarHeight;
// @ts-expect-error [external]: CSS not yet typed
if (window.CSS?.number && document.body.attributeStyleMap?.set) {
// @ts-expect-error [external]: types attributeStyleMap + CSS.px not available yet
document.body.attributeStyleMap.set('margin-right', CSS.px(newMarginRight));
// @ts-expect-error [external]: types attributeStyleMap + CSS.px not available yet
document.body.attributeStyleMap.set('margin-bottom', CSS.px(newMarginBottom));
} else {
document.body.style.marginRight = `${newMarginRight}px`;
document.body.style.marginBottom = `${newMarginBottom}px`;
}
break;
}
case 'hide':
document.body.style.marginRight = this.__bodyMarginRightInline || '';
document.body.style.marginBottom = this.__bodyMarginBottomInline || '';
break;
/* no default */
}
this.manager.requestToKeepBodySize({ phase });
}

/**
Expand Down Expand Up @@ -1442,6 +1403,9 @@ export class OverlayController extends EventTarget {

teardown() {
this.__handleOverlayStyles({ phase: 'teardown' });
if (this.isShown) {
this._keepBodySize({ phase: 'teardown' });
}
this._handleFeatures({ phase: 'teardown' });
if (this.#isRegisteredOnManager()) {
this.manager.remove(this);
Expand Down
79 changes: 79 additions & 0 deletions packages/ui/components/overlays/src/OverlaysManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { browserDetection } from '@lion/ui/core.js';
/**
* @typedef {import('lit').CSSResult} CSSResult
* @typedef {import('./OverlayController.js').OverlayController} OverlayController
* @typedef {import('@lion/ui/types/overlays.js').OverlayPhase} OverlayPhase
*/

import { overlayDocumentStyle } from './overlayDocumentStyle.js';
Expand Down Expand Up @@ -53,6 +54,20 @@ export class OverlaysManager {
* @private
*/
this.__blockingMap = new WeakMap();
/** @private */
this.__preventScrollCount = 0;
/** @private */
this.__bodyClientWidth = undefined;
/** @private */
this.__bodyClientHeight = undefined;
/** @private */
this.__bodyMarginRightInline = undefined;
/** @private */
this.__bodyMarginBottomInline = undefined;
/** @private */
this.__bodyMarginRight = undefined;
/** @private */
this.__bodyMarginBottom = undefined;

if (!OverlaysManager.__globalStyleNode) {
OverlaysManager.__globalStyleNode = OverlaysManager.__createGlobalStyleNode();
Expand Down Expand Up @@ -119,6 +134,13 @@ export class OverlaysManager {
this.__list = [];
this.__shownList = [];
this.__siblingsInert = false;
this.__preventScrollCount = 0;
this.__bodyClientWidth = undefined;
this.__bodyClientHeight = undefined;
this.__bodyMarginRightInline = undefined;
this.__bodyMarginBottomInline = undefined;
this.__bodyMarginRight = undefined;
this.__bodyMarginBottom = undefined;

if (OverlaysManager.__globalStyleNode) {
document.head.removeChild(
Expand Down Expand Up @@ -169,6 +191,63 @@ export class OverlaysManager {

/** PreventsScroll */

/**
* @param {{ phase: OverlayPhase }} config
*/
requestToKeepBodySize({ phase }) {
switch (phase) {
case 'before-show':
if (this.__preventScrollCount === 0) {
this.__bodyClientWidth = document.body.clientWidth;
this.__bodyClientHeight = document.body.clientHeight;
this.__bodyMarginRightInline = document.body.style.marginRight;
this.__bodyMarginBottomInline = document.body.style.marginBottom;
}
this.__preventScrollCount += 1;
break;
case 'show': {
if (this.__preventScrollCount === 1) {
if (window.getComputedStyle) {
const bodyStyle = window.getComputedStyle(document.body);
this.__bodyMarginRight = parseInt(bodyStyle.getPropertyValue('margin-right'), 10);
this.__bodyMarginBottom = parseInt(bodyStyle.getPropertyValue('margin-bottom'), 10);
} else {
this.__bodyMarginRight = 0;
this.__bodyMarginBottom = 0;
}
const scrollbarWidth =
document.body.clientWidth - /** @type {number} */ (this.__bodyClientWidth);
const scrollbarHeight =
document.body.clientHeight - /** @type {number} */ (this.__bodyClientHeight);
const newMarginRight = this.__bodyMarginRight + scrollbarWidth;
const newMarginBottom = this.__bodyMarginBottom + scrollbarHeight;
// @ts-expect-error [external]: CSS not yet typed
if (window.CSS?.number && document.body.attributeStyleMap?.set) {
// @ts-expect-error [external]: types attributeStyleMap + CSS.px not available yet
document.body.attributeStyleMap.set('margin-right', CSS.px(newMarginRight));
// @ts-expect-error [external]: types attributeStyleMap + CSS.px not available yet
document.body.attributeStyleMap.set('margin-bottom', CSS.px(newMarginBottom));
} else {
document.body.style.marginRight = `${newMarginRight}px`;
document.body.style.marginBottom = `${newMarginBottom}px`;
}
}
break;
}
case 'hide':
case 'teardown':
if (this.__preventScrollCount > 0) {
this.__preventScrollCount -= 1;
if (this.__preventScrollCount === 0) {
document.body.style.marginRight = this.__bodyMarginRightInline || '';
document.body.style.marginBottom = this.__bodyMarginBottomInline || '';
}
}
break;
/* no default */
}
}

// eslint-disable-next-line class-methods-use-this
requestToPreventScroll() {
const { isIOS, isMacSafari } = browserDetection;
Expand Down
113 changes: 109 additions & 4 deletions packages/ui/components/overlays/test/OverlayController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,109 @@ describe('OverlayController', () => {
await ctrl1.hide();
expect(Array.from(document.body.classList)).to.contain('overlays-scroll-lock');
});

it('does not accumulate body margins when nested overlays have preventsScroll', async () => {
const ctrl0 = new OverlayController({
...withGlobalTestConfig(),
preventsScroll: true,
});
const ctrl1 = new OverlayController({
...withGlobalTestConfig(),
preventsScroll: true,
});

const originalMarginRight = document.body.style.marginRight;

await ctrl0.show();
const marginAfterFirst = document.body.style.marginRight;

await ctrl1.show();
const marginAfterSecond = document.body.style.marginRight;

// The margin should NOT increase further when second overlay opens
expect(marginAfterSecond).to.equal(marginAfterFirst);

await ctrl1.hide();
// After hiding second, first still prevents scroll — margin stays
expect(document.body.style.marginRight).to.equal(marginAfterFirst);

await ctrl0.hide();
// After hiding all, original margin is restored
expect(document.body.style.marginRight).to.equal(originalMarginRight);
});

it('restores body margin when overlay with preventsScroll is torn down while shown', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
preventsScroll: true,
});

const originalMarginRight = document.body.style.marginRight;

await ctrl.show();
const marginAfterShow = document.body.style.marginRight;
// Margin should be set after show
expect(marginAfterShow).to.not.equal(originalMarginRight);

ctrl.teardown();
// Margin should be restored after teardown
expect(document.body.style.marginRight).to.equal(originalMarginRight);
});

it('does not break body margin when teardown() is called on a hidden overlay', async () => {
const ctrl0 = new OverlayController({
...withGlobalTestConfig(),
preventsScroll: true,
});
const ctrl1 = new OverlayController({
...withGlobalTestConfig(),
preventsScroll: true,
});

const originalMarginRight = document.body.style.marginRight;

// Show first overlay
await ctrl0.show();
const marginAfterFirst = document.body.style.marginRight;
expect(marginAfterFirst).to.not.equal(originalMarginRight);

// Teardown second overlay that was never shown
ctrl1.teardown();
// Margin should still be set by first overlay
expect(document.body.style.marginRight).to.equal(marginAfterFirst);

// Hide first overlay
await ctrl0.hide();
// Now margin should be restored
expect(document.body.style.marginRight).to.equal(originalMarginRight);
});

it('does not break body margin when updateConfig() is called on a hidden overlay', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
preventsScroll: true,
});

const originalMarginRight = document.body.style.marginRight;

// updateConfig calls teardown internally
ctrl.updateConfig({
...withGlobalTestConfig(),
preventsScroll: true,
});

// Margin should not be affected
expect(document.body.style.marginRight).to.equal(originalMarginRight);

// Show after updateConfig
await ctrl.show();
const marginAfterShow = document.body.style.marginRight;
expect(marginAfterShow).to.not.equal(originalMarginRight);

// Hide should restore
await ctrl.hide();
expect(document.body.style.marginRight).to.equal(originalMarginRight);
});
});

describe('hasBackdrop', () => {
Expand Down Expand Up @@ -2122,15 +2225,17 @@ describe('OverlayController', () => {
it('should not run with scroll prevention', async () => {
await overlayControllerNoPrevent.show();

expect(overlayControllerNoPrevent.__bodyMarginRightInline).to.equal(undefined);
expect(overlayControllerNoPrevent.__bodyMarginRight).to.equal(undefined);
expect(overlayControllerNoPrevent.manager.__bodyMarginRightInline).to.equal(undefined);
expect(overlayControllerNoPrevent.manager.__bodyMarginRight).to.equal(undefined);
});

it('should run with scroll prevention', async () => {
await overlayControllerPreventsScroll.show();

expect(overlayControllerPreventsScroll.__bodyMarginRightInline).to.not.equal(undefined);
expect(overlayControllerPreventsScroll.__bodyMarginRight).to.not.equal(undefined);
expect(overlayControllerPreventsScroll.manager.__bodyMarginRightInline).to.not.equal(
undefined,
);
expect(overlayControllerPreventsScroll.manager.__bodyMarginRight).to.not.equal(undefined);
});
});
});
10 changes: 10 additions & 0 deletions packages/ui/components/overlays/types/OverlayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ export type ViewportPlacement =
| 'bottom-left'
| 'left';

export type OverlayPhase =
| 'setup'
| 'init'
| 'teardown'
| 'before-show'
| 'show'
| 'hide'
| 'add'
| 'remove';


export interface ViewportConfig {
placement: ViewportPlacement;
Expand Down
1 change: 1 addition & 0 deletions packages/ui/exports/types/overlays.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { OverlayConfig } from '../../components/overlays/types/OverlayConfig.js';
export { OverlayPhase } from '../../components/overlays/types/OverlayConfig.js';
export { ViewportConfig } from '../../components/overlays/types/OverlayConfig.js';
export { ViewportPlacement } from '../../components/overlays/types/OverlayConfig.js';
export { DefineOverlayConfig } from '../../components/overlays/types/OverlayMixinTypes.js';
Expand Down