diff --git a/.changeset/eight-impalas-joke.md b/.changeset/eight-impalas-joke.md
new file mode 100644
index 000000000..be437742d
--- /dev/null
+++ b/.changeset/eight-impalas-joke.md
@@ -0,0 +1,5 @@
+---
+'@lion/ui': patch
+---
+
+[overlays]: fix adoptStyles fallback and make testable
diff --git a/packages/ui/components/overlays/src/OverlayController.js b/packages/ui/components/overlays/src/OverlayController.js
index c5fb88224..3640a0443 100644
--- a/packages/ui/components/overlays/src/OverlayController.js
+++ b/packages/ui/components/overlays/src/OverlayController.js
@@ -1,7 +1,7 @@
-import { adoptStyles } from 'lit';
import { overlays } from './singleton.js';
import { containFocus } from './utils/contain-focus.js';
import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
+import { _adoptStyleUtils } from './utils/adopt-styles.js';
/**
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
@@ -13,22 +13,6 @@ import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
*/
-const rootNodeStylesMap = new WeakSet();
-
-/**
- * Returns element that adopts stylesheet
- * @param {Element} shadowOrBodyEl
- * @returns {ShadowRoot}
- */
-function getRootNodeOrBodyElThatAdoptsStylesheet(shadowOrBodyEl) {
- const rootNode = /** @type {* & DocumentOrShadowRoot} */ (shadowOrBodyEl.getRootNode());
- if (rootNode === document) {
- // @ts-ignore
- return document.body;
- }
- return rootNode;
-}
-
/**
* From:
* - wrappingDialogNodeL1: ``
@@ -536,16 +520,20 @@ export class OverlayController extends EventTarget {
OverlayController.popperModule = preloadPopper();
}
}
- this.__initOverlayStyles();
+ this.__handleOverlayStyles({ phase: 'init' });
this._handleFeatures({ phase: 'init' });
}
- __initOverlayStyles() {
- const rootNode = getRootNodeOrBodyElThatAdoptsStylesheet(this.contentWrapperNode);
- if (!rootNodeStylesMap.has(rootNode)) {
- // TODO: ideally we should also support a teardown
- adoptStyles(rootNode, [...(rootNode.adoptedStyleSheets || []), overlayShadowDomStyle]);
- rootNodeStylesMap.add(rootNode);
+ /**
+ * @param {{ phase: OverlayPhase }} config
+ * @private
+ */
+ __handleOverlayStyles({ phase }) {
+ const rootNode = /** @type {ShadowRoot} */ (this.contentWrapperNode?.getRootNode());
+ if (phase === 'init') {
+ _adoptStyleUtils.adoptStyle(rootNode, overlayShadowDomStyle);
+ } else if (phase === 'teardown') {
+ _adoptStyleUtils.adoptStyle(rootNode, overlayShadowDomStyle, { teardown: true });
}
}
@@ -1284,6 +1272,7 @@ export class OverlayController extends EventTarget {
}
teardown() {
+ this.__handleOverlayStyles({ phase: 'teardown' });
this._handleFeatures({ phase: 'teardown' });
}
diff --git a/packages/ui/components/overlays/src/utils/adopt-styles.js b/packages/ui/components/overlays/src/utils/adopt-styles.js
new file mode 100644
index 000000000..bf151ebb5
--- /dev/null
+++ b/packages/ui/components/overlays/src/utils/adopt-styles.js
@@ -0,0 +1,136 @@
+// See: https://github.com/ing-bank/lion/issues/1880
+
+/**
+ * @typedef {import('lit').CSSResult|CSSStyleSheet} AdoptableStyle
+ * @typedef {(renderRoot:DocumentOrShadowRoot, style: AdoptableStyle, opts?: {teardown?: boolean}) => void} AdoptStyleFn
+ * @typedef {(renderRoot:DocumentOrShadowRoot, styles: AdoptableStyle[], opts?: {teardown?: boolean}) => void} AdoptStylesFn
+ */
+
+// Shared protected object that can be spied/mocked in tests
+export const _adoptStyleUtils = {
+ // Mocking Document.prototype.adoptedStyleSheets seemed impossible
+ supportsAdoptingStyleSheets:
+ window.ShadowRoot &&
+ // @ts-ignore
+ (window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow) &&
+ 'adoptedStyleSheets' in Document.prototype &&
+ 'replace' in CSSStyleSheet.prototype,
+ /** @type {AdoptStyleFn} */
+ // @ts-ignore
+ adoptStyle: undefined,
+ /** @type {AdoptStylesFn} */
+ // @ts-ignore
+ adoptStyles: undefined,
+};
+
+const styleCache = new Map();
+
+/**
+ * @param {CSSStyleSheet} cssStyleSheet
+ */
+export function serializeConstructableStylesheet(cssStyleSheet) {
+ return Array.from(cssStyleSheet.cssRules)
+ .map(r => r.cssText)
+ .join('');
+}
+
+/**
+ * @param {DocumentOrShadowRoot} renderRoot
+ * @param {AdoptableStyle} style
+ * @param {{teardown?: boolean}} opts
+ */
+function adoptStyleWhenAdoptedStylesheetsNotSupported(
+ renderRoot,
+ style,
+ { teardown = false } = {},
+) {
+ const adoptRoot = /** @type {ShadowRoot|Document['body']} */ (
+ renderRoot === document ? document.body : renderRoot
+ );
+ // @ts-ignore
+ const styleText = style.cssText || serializeConstructableStylesheet(style);
+
+ if (!teardown) {
+ const styleEl = document.createElement('style');
+ // keep notation, so it's not renamed in minification/build
+ // eslint-disable-next-line dot-notation
+ const nonce = window['litNonce'];
+ if (nonce !== undefined) {
+ styleEl.setAttribute('nonce', nonce);
+ }
+ styleEl.textContent = styleText;
+ adoptRoot.appendChild(styleEl);
+ } else {
+ const foundStyleEls = Array.from(adoptRoot.querySelectorAll('style'));
+
+ for (const foundStyleEl of foundStyleEls) {
+ if (foundStyleEl.textContent === styleText) {
+ foundStyleEl.remove();
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * @param {DocumentOrShadowRoot} renderRoot
+ * @param {AdoptableStyle} style
+ * @param {{teardown?: boolean}} opts
+ */
+function handleCache(renderRoot, style, { teardown = false } = {}) {
+ let haltFurtherExecution = false;
+ if (!styleCache.has(renderRoot)) {
+ styleCache.set(renderRoot, []);
+ }
+ const addedStylesForRoot = styleCache.get(renderRoot);
+ const foundStyle = addedStylesForRoot.find(
+ (/** @type {import("lit").CSSResultOrNative} */ addedStyle) => style === addedStyle,
+ );
+
+ if (foundStyle && teardown) {
+ addedStylesForRoot.splice(addedStylesForRoot.indexOf(style), 1);
+ } else if (!foundStyle && !teardown) {
+ addedStylesForRoot.push(style);
+ } else if ((foundStyle && !teardown) || (!foundStyle && teardown)) {
+ // Already removed or added. We're done
+ haltFurtherExecution = true;
+ }
+
+ return { haltFurtherExecution };
+}
+
+/** @type {AdoptStyleFn} */
+export function adoptStyle(renderRoot, style, { teardown = false } = {}) {
+ const { haltFurtherExecution } = handleCache(renderRoot, style, { teardown });
+ if (haltFurtherExecution) {
+ return;
+ }
+
+ if (!_adoptStyleUtils.supportsAdoptingStyleSheets) {
+ adoptStyleWhenAdoptedStylesheetsNotSupported(renderRoot, style, { teardown });
+ return;
+ }
+
+ const sheet = style instanceof CSSStyleSheet ? style : style.styleSheet;
+ if (!sheet) {
+ throw new Error(`Please provide a CSSResultOrNative style`);
+ }
+
+ if (!teardown) {
+ // @ts-ignore
+ // eslint-disable-next-line no-param-reassign
+ renderRoot.adoptedStyleSheets.push(sheet);
+ } else if (renderRoot.adoptedStyleSheets.includes(sheet)) {
+ renderRoot.adoptedStyleSheets.splice(renderRoot.adoptedStyleSheets.indexOf(sheet), 1);
+ }
+}
+
+/** @type {AdoptStylesFn} */
+export function adoptStyles(renderRoot, styles, { teardown = false } = {}) {
+ for (const style of styles) {
+ _adoptStyleUtils.adoptStyle(renderRoot, style, { teardown });
+ }
+}
+
+_adoptStyleUtils.adoptStyle = adoptStyle;
+_adoptStyleUtils.adoptStyles = adoptStyles;
diff --git a/packages/ui/components/overlays/test-helpers/createShadowHost.js b/packages/ui/components/overlays/test-helpers/createShadowHost.js
new file mode 100644
index 000000000..b4bce9ae7
--- /dev/null
+++ b/packages/ui/components/overlays/test-helpers/createShadowHost.js
@@ -0,0 +1,16 @@
+/**
+ * Useful in tests when no need for wc
+ */
+export function createShadowHost() {
+ const shadowHost = document.createElement('div');
+ shadowHost.attachShadow({ mode: 'open' });
+ /** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = ``;
+ document.body.appendChild(shadowHost);
+
+ return {
+ shadowHost,
+ cleanupShadowHost: () => {
+ document.body.removeChild(shadowHost);
+ },
+ };
+}
diff --git a/packages/ui/components/overlays/test/utils-tests/local-positioning-helpers.js b/packages/ui/components/overlays/test-helpers/normalizeTransformStyle.js
similarity index 100%
rename from packages/ui/components/overlays/test/utils-tests/local-positioning-helpers.js
rename to packages/ui/components/overlays/test-helpers/normalizeTransformStyle.js
diff --git a/packages/ui/components/overlays/test/OverlayController.test.js b/packages/ui/components/overlays/test/OverlayController.test.js
index d201d4598..5a594aaa7 100644
--- a/packages/ui/components/overlays/test/OverlayController.test.js
+++ b/packages/ui/components/overlays/test/OverlayController.test.js
@@ -11,9 +11,10 @@ import {
import sinon from 'sinon';
import { OverlayController, overlays } from '@lion/ui/overlays.js';
import { mimicClick } from '@lion/ui/overlays-test-helpers.js';
-import { overlayShadowDomStyle } from '../src/overlayShadowDomStyle.js';
import { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.js';
+import { _adoptStyleUtils } from '../src/utils/adopt-styles.js';
+import { createShadowHost } from '../test-helpers/createShadowHost.js';
/**
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
@@ -22,20 +23,6 @@ import { simulateTab } from '../src/utils/simulate-tab.js';
const wrappingDialogNodeStyle = 'display: none; z-index: 9999;';
-/**
- * Returns element that adopts stylesheet
- * @param {Element} shadowOrBodyEl
- * @returns {ShadowRoot}
- */
-function getRootNodeOrBodyElThatAdoptsStylesheet(shadowOrBodyEl) {
- const rootNode = /** @type {* & DocumentOrShadowRoot} */ (shadowOrBodyEl.getRootNode());
- if (rootNode === document) {
- // @ts-ignore
- return document.body;
- }
- return rootNode;
-}
-
/**
* Make sure that all browsers serialize html in a similar way
* (Firefox tends to output empty style attrs)
@@ -97,51 +84,17 @@ describe('OverlayController', () => {
});
describe('Stylesheets', () => {
- it('adds a stylesheet to the body when contentWrapper is located there', async () => {
- new OverlayController({
- ...withLocalTestConfig(),
- });
- // @ts-ignore
- if (document.body.adoptedStyleSheets) {
- // @ts-ignore
- expect(document.body.adoptedStyleSheets).to.include(overlayShadowDomStyle.styleSheet);
- }
- });
-
- it('adds a stylesheet to the shadowRoot when contentWrappeNode is located there', async () => {
+ it('calls adoptStyles', async () => {
+ const spy = sinon.spy(_adoptStyleUtils, 'adoptStyle');
+ const { shadowHost, cleanupShadowHost } = createShadowHost();
const contentNode = /** @type {HTMLElement} */ (await fixture('
contentful
'));
- const shadowHost = document.createElement('div');
- shadowHost.attachShadow({ mode: 'open' });
- /** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = ``;
shadowHost.appendChild(contentNode);
- document.body.appendChild(shadowHost);
- const ctrl = new OverlayController({
+ new OverlayController({
...withLocalTestConfig(),
contentNode,
});
-
- const rootNodeOrBody = getRootNodeOrBodyElThatAdoptsStylesheet(ctrl.contentWrapperNode);
- expect(rootNodeOrBody).to.not.equal(document.body);
-
- if (rootNodeOrBody.adoptedStyleSheets) {
- expect(rootNodeOrBody.adoptedStyleSheets).to.include(overlayShadowDomStyle.styleSheet);
- }
- document.body.removeChild(shadowHost);
- });
-
- it('does not add same stylesheet twice', async () => {
- // @ts-ignore
- if (!document.body.adoptedStyleSheets) {
- return;
- }
-
- new OverlayController({ ...withLocalTestConfig() });
- // @ts-ignore
- const amountOfStylesheetsAfterOneInit = document.body.adoptedStyleSheets.length;
-
- new OverlayController({ ...withLocalTestConfig() });
- // @ts-ignore
- expect(document.body.adoptedStyleSheets.length).to.equal(amountOfStylesheetsAfterOneInit);
+ expect(spy).to.have.been.called;
+ cleanupShadowHost();
});
});
diff --git a/packages/ui/components/overlays/test/local-positioning.test.js b/packages/ui/components/overlays/test/local-positioning.test.js
index 026302090..f31cd632e 100644
--- a/packages/ui/components/overlays/test/local-positioning.test.js
+++ b/packages/ui/components/overlays/test/local-positioning.test.js
@@ -2,7 +2,7 @@
import { expect, fixture, fixtureSync } from '@open-wc/testing';
import { html } from 'lit/static-html.js';
import { OverlayController } from '@lion/ui/overlays.js';
-import { normalizeTransformStyle } from './utils-tests/local-positioning-helpers.js';
+import { normalizeTransformStyle } from '../test-helpers/normalizeTransformStyle.js';
/**
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
diff --git a/packages/ui/components/overlays/test/utils-tests/adopt-styles.test.js b/packages/ui/components/overlays/test/utils-tests/adopt-styles.test.js
new file mode 100644
index 000000000..a962e7aef
--- /dev/null
+++ b/packages/ui/components/overlays/test/utils-tests/adopt-styles.test.js
@@ -0,0 +1,177 @@
+import { expect } from '@open-wc/testing';
+import { css } from 'lit';
+import sinon from 'sinon';
+import {
+ adoptStyle,
+ adoptStyles,
+ serializeConstructableStylesheet,
+ _adoptStyleUtils,
+} from '../../src/utils/adopt-styles.js';
+import { createShadowHost } from '../../test-helpers/createShadowHost.js';
+
+function mockNoSupportAdoptedStylesheets() {
+ _adoptStyleUtils.supportsAdoptingStyleSheets = false;
+}
+
+function restoreMockNoSupportAdoptedStylesheets() {
+ _adoptStyleUtils.supportsAdoptingStyleSheets = true;
+}
+
+describe('adoptStyle()', () => {
+ it('adds a stylesheet from a CSSResult to the shadowRoot', async () => {
+ const { shadowHost, cleanupShadowHost } = createShadowHost();
+ const myCssResult = css``;
+ const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
+
+ adoptStyle(root, myCssResult);
+ expect(root.adoptedStyleSheets).to.include(myCssResult.styleSheet);
+
+ cleanupShadowHost();
+ });
+
+ it('adds a stylesheet from a CSSStyleSheet to the shadowRoot', async () => {
+ const { shadowHost, cleanupShadowHost } = createShadowHost();
+ const myCSSStyleSheet = new CSSStyleSheet();
+ const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
+
+ adoptStyle(root, myCSSStyleSheet);
+ expect(root.adoptedStyleSheets).to.include(myCSSStyleSheet);
+
+ cleanupShadowHost();
+ });
+
+ it('does not add same stylesheet twice', async () => {
+ const { shadowHost, cleanupShadowHost } = createShadowHost();
+ const myCssResult = css``;
+ const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
+
+ adoptStyle(root, myCssResult);
+ const amountOfStylesheetsAfterOneInit = root.adoptedStyleSheets.length;
+ adoptStyle(root, myCssResult);
+ expect(root.adoptedStyleSheets.length).to.equal(amountOfStylesheetsAfterOneInit);
+
+ cleanupShadowHost();
+ });
+
+ it('works as well when document is the root', async () => {
+ const myCssResult = css``;
+ const root = document;
+
+ adoptStyle(root, myCssResult);
+ expect(root.adoptedStyleSheets).to.include(myCssResult.styleSheet);
+ });
+
+ describe('Teardown', () => {
+ it('removes stylesheets from the shadowRoot', async () => {
+ const { shadowHost, cleanupShadowHost } = createShadowHost();
+ const myCssResult = css``;
+ const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
+
+ adoptStyle(root, myCssResult);
+ expect(root.adoptedStyleSheets).to.include(myCssResult.styleSheet);
+ adoptStyle(root, myCssResult, { teardown: true });
+ expect(root.adoptedStyleSheets).to.not.include(myCssResult.styleSheet);
+
+ const myCSSStyleSheet = new CSSStyleSheet();
+ adoptStyle(root, myCSSStyleSheet);
+ expect(root.adoptedStyleSheets).to.include(myCSSStyleSheet);
+ adoptStyle(root, myCSSStyleSheet, { teardown: true });
+ expect(root.adoptedStyleSheets).to.not.include(myCSSStyleSheet);
+
+ cleanupShadowHost();
+ });
+ });
+
+ describe('Fallback when adoptedStyleSheets are not supported', () => {
+ beforeEach(() => {
+ mockNoSupportAdoptedStylesheets();
+ });
+
+ afterEach(() => {
+ restoreMockNoSupportAdoptedStylesheets();
+ });
+
+ it('adds a "traditional" stylesheet to the shadowRoot', async () => {
+ const { shadowHost, cleanupShadowHost } = createShadowHost();
+ const myCssResult = css`
+ .check {
+ color: blue;
+ }
+ `;
+ const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
+
+ adoptStyle(root, myCssResult);
+ const sheets = Array.from(root.querySelectorAll('style'));
+ const lastAddedSheet = sheets[sheets.length - 1];
+ expect(lastAddedSheet.textContent).to.equal(myCssResult.cssText);
+
+ cleanupShadowHost();
+ });
+
+ it('adds a "traditional" stylesheet to the body', async () => {
+ mockNoSupportAdoptedStylesheets();
+
+ const myCssResult = css`
+ .check {
+ color: blue;
+ }
+ `;
+ const root = document;
+ adoptStyle(root, myCssResult);
+
+ const sheets = Array.from(document.body.querySelectorAll('style'));
+ const lastAddedSheet = sheets[sheets.length - 1];
+ expect(lastAddedSheet.textContent).to.equal(myCssResult.cssText);
+ restoreMockNoSupportAdoptedStylesheets();
+ });
+
+ describe('Teardown', () => {
+ it('removes a "traditional" stylesheet from the shadowRoot', async () => {
+ const { shadowHost, cleanupShadowHost } = createShadowHost();
+ const myCssResult = css`
+ .check {
+ color: blue;
+ }
+ `;
+ const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
+
+ adoptStyle(root, myCssResult);
+ const sheets1 = Array.from(root.querySelectorAll('style'));
+ const lastAddedSheet1 = sheets1[sheets1.length - 1];
+ expect(lastAddedSheet1.textContent).to.equal(myCssResult.cssText);
+ adoptStyle(root, myCssResult, { teardown: true });
+ const sheets2 = Array.from(root.querySelectorAll('style'));
+ const lastAddedSheet2 = sheets2[sheets2.length - 1];
+ expect(lastAddedSheet2?.textContent).to.not.equal(myCssResult.cssText);
+
+ const myCSSStyleSheet = new CSSStyleSheet();
+ myCSSStyleSheet.insertRule('.check { color: blue; }');
+
+ adoptStyle(root, myCSSStyleSheet);
+ const sheets3 = Array.from(root.querySelectorAll('style'));
+ const lastAddedSheet3 = sheets3[sheets3.length - 1];
+ expect(lastAddedSheet3.textContent).to.equal(
+ serializeConstructableStylesheet(myCSSStyleSheet),
+ );
+ adoptStyle(root, myCSSStyleSheet, { teardown: true });
+ const sheets4 = Array.from(root.querySelectorAll('style'));
+ const lastAddedSheet4 = sheets4[sheets4.length - 1];
+ expect(lastAddedSheet4?.textContent).to.not.equal(myCSSStyleSheet);
+
+ cleanupShadowHost();
+ });
+ });
+ });
+});
+
+describe('adoptStyles()', () => {
+ it('calls "adoptStyle" for all entries in CSSResult|CSSStylesheet[]', async () => {
+ const spy = sinon.spy(_adoptStyleUtils, 'adoptStyle');
+
+ const myCssResult = css``;
+ const myCSSStyleSheet = new CSSStyleSheet();
+
+ adoptStyles(document, [myCssResult, myCSSStyleSheet]);
+ expect(spy).to.have.been.calledTwice;
+ });
+});