diff --git a/README.md b/README.md index 660e04b..43d38ae 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,63 @@ FullName.propTypes = { register(FullName, 'full-name'); ``` +### API + +### register(Component, tagName, props?, options?) + +Returns a Custom Element. + +#### Component + +Type: `object` + +#### tagName + +Type: `string` + +#### props? + +Type: `Array` + +#### options + +Type: `boolean | object` +Default: `false` + +#### options.shadow + +Type: `boolean` +Default: `false` + +Attaches a Shadow DOM instance to your Custom Element. Remember that global styles won't affect the Custom Element unless you inject (see `options.injectGlobalStyles`) or somehow import them from the component itself. + +#### options.injectGlobalStyles + +Type: `boolean | object` +Default: `false` + +Injects current and future style and link elements related to styling into your Custom Element. Only works if `shadow: true`. + +##### options.injectGlobalStyles.target + +Type: `DOMNode` +Default: `document.head` + +Where to look for styles to get added. Most 3rd party tooling loads style tags into document.head. + +##### options.injectGlobalStyles.selector + +Type: `(querySelector) string` +Default: `'style, link[rel="stylesheet"], link[rel="preload"][as="style"]'` + +What types of elements to inject to your Custom Element's Shadow DOM. + +##### options.injectGlobalStyles.filter + +Type: `Function` +Default: `undefined` + +Allows you to filter what elements get added to the Custom Element more granularly. Gets executed on `addedNodes` by the MutationObserver, as well as the initial styles present on the page. ## Related diff --git a/src/index.js b/src/index.js index 6035643..2f8e988 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,29 @@ export default function register(Component, tagName, propNames, options) { inst._vdomComponent = Component; inst._root = options && options.shadow ? inst.attachShadow({ mode: 'open' }) : inst; + + if (options && options.shadow && options.injectGlobalStyles) { + const defaults = { + target: document.head, + selector: + 'style, link[rel="stylesheet"], link[rel="preload"][as="style"]', + filter: undefined, + observeOptions: { childList: true, subtree: true }, + }; + + this.styleObserver = beginInjectingGlobalStyles( + inst.shadowRoot, + /* eslint-disable indent */ + options.injectGlobalStyles === true + ? defaults + : { + ...defaults, + ...options.injectGlobalStyles, + } + /* eslint-enable indent */ + ); + } + return inst; } PreactElement.prototype = Object.create(HTMLElement.prototype); @@ -62,6 +85,62 @@ function ContextProvider(props) { return cloneElement(children, rest); } +export function cloneElementsToShadowRoot(shadowRoot, elements) { + elements.forEach((el) => shadowRoot.appendChild(el.cloneNode(true))); +} + +export function getAllStyles(target, selector, filter) { + const elements = Array.prototype.slice.call( + target.querySelectorAll(selector) + ); + + return filter ? elements.filter(filter) : elements; +} + +export const beginInjectingGlobalStyles = ( + shadowRootRef, + injectGlobalStyles +) => { + cloneElementsToShadowRoot( + shadowRootRef, + getAllStyles( + injectGlobalStyles.target, + injectGlobalStyles.selector, + injectGlobalStyles.filter + ) + ); + + return observeStyleChanges( + (elements) => { + cloneElementsToShadowRoot(shadowRootRef, elements); + }, + injectGlobalStyles.target, + injectGlobalStyles.selector, + injectGlobalStyles.filter, + injectGlobalStyles.observeOptions + ); +}; + +export function observeStyleChanges( + callback, + target, + selector, + filter, + observeOptions +) { + return new MutationObserver((mutations, observer) => { + mutations.forEach((mutation) => { + const matchedElements = Array.prototype.slice + .call(mutation.addedNodes) + .filter((node) => node.matches && node.matches(selector)); + + if (matchedElements.length > 0) { + callback(filter ? matchedElements.filter(filter) : matchedElements); + } + }); + }).observe(target, observeOptions); +} + function connectedCallback() { // Obtain a reference to the previous context by pinging the nearest // higher up node that was rendered with Preact. If one Preact component @@ -99,6 +178,10 @@ function attributeChangedCallback(name, oldValue, newValue) { function disconnectedCallback() { render((this._vdom = null), this._root); + + if (this.styleObserver) { + this.styleObserver.disconnect(); + } } /** diff --git a/src/index.test.jsx b/src/index.test.jsx index 77b5b2d..7475dae 100644 --- a/src/index.test.jsx +++ b/src/index.test.jsx @@ -230,4 +230,163 @@ describe('web components', () => { }); assert.equal(getShadowHTML(), '

Active theme: sunny

'); }); + + function Thing() { + return Hello world!; + } + + const styleElem = document.createElement('style'); + styleElem.innerHTML = 'span { color: red; }'; + document.head.appendChild(styleElem); + + registerElement(Thing, 'x-thing', undefined, { + shadow: true, + injectGlobalStyles: true, + }); + + describe('Global style injections from document.head', () => { + it('injects style-tags', () => { + const el = document.createElement('x-thing'); + + root.appendChild(el); + + assert.equal( + document.querySelector('x-thing').shadowRoot.innerHTML, + 'Hello world!' + ); + + const computedStyle = window + .getComputedStyle( + document.querySelector('x-thing').shadowRoot.querySelector('span'), + null + ) + .getPropertyValue('color'); + + assert.equal(computedStyle, 'rgb(255, 0, 0)'); + + // assert.equal( + // root.innerHTML, + // 'Hello world!' + // ); + styleElem.parentElement.removeChild(styleElem); + }); + + it('injects link-tags of rel="stylesheet"', async () => { + const blob = new Blob([], { type: 'text/css' }); + + let linkElementLoaded; + + let deferred; + let promise = new Promise((resolve) => { + deferred = resolve; + }); + + const linkElem = document.createElement('link'); + linkElem.rel = 'stylesheet'; + linkElem.href = window.URL.createObjectURL(blob); + linkElem.onload = () => { + linkElementLoaded = true; + deferred(); + }; + + document.head.appendChild(linkElem); + + const el = document.createElement('x-thing'); + + root.appendChild(el); + + assert.match( + document.querySelector('x-thing').shadowRoot.innerHTML, + new RegExp( + `Hello world!` + ) + ); + + await promise; + assert.isTrue(linkElementLoaded); + + linkElem.parentElement.removeChild(linkElem); + }); + + it('injects link-tags of rel="preload"', async () => { + const blob = new Blob([], { type: 'text/css' }); + + let linkElementLoaded; + + let deferred; + let promise = new Promise((resolve) => { + deferred = resolve; + }); + + const linkElem = document.createElement('link'); + linkElem.rel = 'preload'; + linkElem.as = 'style'; + linkElem.href = window.URL.createObjectURL(blob); + linkElem.onload = () => { + linkElementLoaded = true; + deferred(); + }; + + document.head.appendChild(linkElem); + + const el = document.createElement('x-thing'); + + root.appendChild(el); + + assert.match( + document.querySelector('x-thing').shadowRoot.innerHTML, + new RegExp( + `Hello world!` + ) + ); + + await promise; + assert.isTrue(linkElementLoaded); + + linkElem.parentElement.removeChild(linkElem); + }); + + it('injects style-tags that is added after custom element is loaded', async () => { + const el = document.createElement('x-thing'); + + root.appendChild(el); + + assert.equal( + document.querySelector('x-thing').shadowRoot.innerHTML, + 'Hello world!' + ); + + const computedStyle = window + .getComputedStyle( + document.querySelector('x-thing').shadowRoot.querySelector('span'), + null + ) + .getPropertyValue('color'); + + assert.equal(computedStyle, 'rgb(0, 0, 0)'); + + const styleElem = document.createElement('style'); + styleElem.innerHTML = 'span { color: red; }'; + + // wait for the element to be added + await new Promise((resolve) => { + new MutationObserver((mutations, observer) => { + resolve(); + observer.disconnect(); + }).observe(document.querySelector('x-thing').shadowRoot, { + childList: true, + subtree: true, + }); + + document.head.appendChild(styleElem); + }); + + assert.equal( + document.querySelector('x-thing').shadowRoot.innerHTML, + 'Hello world!' + ); + + styleElem.parentElement.removeChild(styleElem); + }); + }); });