Skip to content

Commit

Permalink
Add automatic global style injection
Browse files Browse the repository at this point in the history
  • Loading branch information
William Bernting (whf962) committed Sep 1, 2020
1 parent 9aff394 commit 0dfe2ed
Show file tree
Hide file tree
Showing 3 changed files with 299 additions and 0 deletions.
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>`

#### 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<boolean>`
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

Expand Down
83 changes: 83 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -99,6 +178,10 @@ function attributeChangedCallback(name, oldValue, newValue) {

function disconnectedCallback() {
render((this._vdom = null), this._root);

if (this.styleObserver) {
this.styleObserver.disconnect();
}
}

/**
Expand Down
159 changes: 159 additions & 0 deletions src/index.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,163 @@ describe('web components', () => {
});
assert.equal(getShadowHTML(), '<p>Active theme: sunny</p>');
});

function Thing() {
return <span>Hello world!</span>;
}

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,
'<span>Hello world!</span><style>span { color: red; }</style>'
);

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,
// '<x-thing><span>Hello world!</span></x-thing>'
// );
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(
`<span>Hello world!</span><link rel="stylesheet" href="blob:http://localhost:9023/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}">`
)
);

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(
`<span>Hello world!</span><link rel="preload" as="style" href="blob:http://localhost:9023/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}">`
)
);

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,
'<span>Hello world!</span>'
);

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,
'<span>Hello world!</span><style>span { color: red; }</style>'
);

styleElem.parentElement.removeChild(styleElem);
});
});
});

0 comments on commit 0dfe2ed

Please sign in to comment.