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; + }); +});