fix(ui): [overlays]: enhance adoptStyles fallback and make testable

This commit is contained in:
Thijs Louisse 2023-01-11 17:56:12 +01:00 committed by Thijs Louisse
parent 974a1bd635
commit af2e0293a1
8 changed files with 356 additions and 80 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[overlays]: fix adoptStyles fallback and make testable

View file

@ -1,7 +1,7 @@
import { adoptStyles } from 'lit';
import { overlays } from './singleton.js'; import { overlays } from './singleton.js';
import { containFocus } from './utils/contain-focus.js'; import { containFocus } from './utils/contain-focus.js';
import { overlayShadowDomStyle } from './overlayShadowDomStyle.js'; import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
import { _adoptStyleUtils } from './utils/adopt-styles.js';
/** /**
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig * @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 * @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: * From:
* - wrappingDialogNodeL1: `<dialog role="none"/>` * - wrappingDialogNodeL1: `<dialog role="none"/>`
@ -536,16 +520,20 @@ export class OverlayController extends EventTarget {
OverlayController.popperModule = preloadPopper(); OverlayController.popperModule = preloadPopper();
} }
} }
this.__initOverlayStyles(); this.__handleOverlayStyles({ phase: 'init' });
this._handleFeatures({ phase: 'init' }); this._handleFeatures({ phase: 'init' });
} }
__initOverlayStyles() { /**
const rootNode = getRootNodeOrBodyElThatAdoptsStylesheet(this.contentWrapperNode); * @param {{ phase: OverlayPhase }} config
if (!rootNodeStylesMap.has(rootNode)) { * @private
// TODO: ideally we should also support a teardown */
adoptStyles(rootNode, [...(rootNode.adoptedStyleSheets || []), overlayShadowDomStyle]); __handleOverlayStyles({ phase }) {
rootNodeStylesMap.add(rootNode); 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() { teardown() {
this.__handleOverlayStyles({ phase: 'teardown' });
this._handleFeatures({ phase: 'teardown' }); this._handleFeatures({ phase: 'teardown' });
} }

View file

@ -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;

View file

@ -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 = `<slot></slot>`;
document.body.appendChild(shadowHost);
return {
shadowHost,
cleanupShadowHost: () => {
document.body.removeChild(shadowHost);
},
};
}

View file

@ -11,9 +11,10 @@ import {
import sinon from 'sinon'; import sinon from 'sinon';
import { OverlayController, overlays } from '@lion/ui/overlays.js'; import { OverlayController, overlays } from '@lion/ui/overlays.js';
import { mimicClick } from '@lion/ui/overlays-test-helpers.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 { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.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 * @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;'; 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 * Make sure that all browsers serialize html in a similar way
* (Firefox tends to output empty style attrs) * (Firefox tends to output empty style attrs)
@ -97,51 +84,17 @@ describe('OverlayController', () => {
}); });
describe('Stylesheets', () => { describe('Stylesheets', () => {
it('adds a stylesheet to the body when contentWrapper is located there', async () => { it('calls adoptStyles', async () => {
new OverlayController({ const spy = sinon.spy(_adoptStyleUtils, 'adoptStyle');
...withLocalTestConfig(), const { shadowHost, cleanupShadowHost } = createShadowHost();
});
// @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 () => {
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>contentful</div>')); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>contentful</div>'));
const shadowHost = document.createElement('div');
shadowHost.attachShadow({ mode: 'open' });
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `<slot></slot>`;
shadowHost.appendChild(contentNode); shadowHost.appendChild(contentNode);
document.body.appendChild(shadowHost); new OverlayController({
const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode, contentNode,
}); });
expect(spy).to.have.been.called;
const rootNodeOrBody = getRootNodeOrBodyElThatAdoptsStylesheet(ctrl.contentWrapperNode); cleanupShadowHost();
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);
}); });
}); });

View file

@ -2,7 +2,7 @@
import { expect, fixture, fixtureSync } from '@open-wc/testing'; import { expect, fixture, fixtureSync } from '@open-wc/testing';
import { html } from 'lit/static-html.js'; import { html } from 'lit/static-html.js';
import { OverlayController } from '@lion/ui/overlays.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 * @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig

View file

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