Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/issue 154 light DOM children aka HTML web components #155

35 changes: 35 additions & 0 deletions docs/pages/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,41 @@ class Layout extends HTMLElement {
export default Layout;
```

## HTML (Light DOM) Web Components

As detailed in this excellent blog post, HTML Web Components are a strategy for transcluding content into the Light DOM of a custom element instead of (or in addition to) setting attributes. This can be useful for providing a set of styles to a block of content.

So instead of setting attributes:

```html
<picture-frame img="/path/to/image.png" title="My Image"></picture-frame>
```

Pass HTML as children:

```html
<picture-frame>
<h3>My Image<h3>
<img src="/path/to/image.png" alt="My Image">
</picture-frame>
```

With a custom element definition like so:

```js
export default class PictureFrame extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div class="picture-frame">
${this.innerHTML}
</div>
`;
}
}

customElements.define('picture-frame', PictureFrame);
```

## Progressive Hydration

Using the `metadata` information from a custom element with the `hydrate=true` attribute, you can use use the metadata with an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to progressively load a custom element. In this case, _handler.js_ builds `SliderComponent` from HTML and not only uses the `hydrate` attribute and metadata for lazy hydration, but also passes in the animated color via a CSS custom property set at build time! 🤯
Expand Down
3 changes: 2 additions & 1 deletion sandbox/components/card.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default class Card extends HTMLElement {
:host .card {
width: 30%;
margin: 0 auto;
text-align: center;
}
:host h3 {
Expand All @@ -28,7 +29,7 @@ export default class Card extends HTMLElement {
</style>
<div class="card">
<h3>${title}</h3>
<img src="${thumbnail}" alt="${title}" loading="lazy" width="100%">
<img src="${thumbnail}" alt="${title}" loading="lazy" width="200" height="200">
<button onclick="this.getRootNode().host.selectItem()">View Item Details</button>
</div>
`;
Expand Down
5 changes: 1 addition & 4 deletions sandbox/components/card.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ const styles = `
:host .card {
width: 30%;
margin: 0 auto;
}

:host h3 {
text-align: center;
}

Expand Down Expand Up @@ -44,7 +41,7 @@ export default class CardJsx extends HTMLElement {
{styles}
</style>
<h3>{title}</h3>
<img src={thumbnail} alt={title} loading="lazy" width="100%"/>
<img src={thumbnail} alt={title} loading="lazy" width="200" height="200"/>
<button onclick={this.selectItem}>View Item Details</button>
</div>
);
Expand Down
14 changes: 14 additions & 0 deletions sandbox/components/picture-frame.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export default class PictureFrame extends HTMLElement {
connectedCallback() {
const title = this.getAttribute('title');

this.innerHTML = `
<div class="picture-frame">
<h6 class="heading">${title}</h6>
${this.innerHTML}
</div>
`;
}
}

customElements.define('sb-picture-frame', PictureFrame);
46 changes: 42 additions & 4 deletions sandbox/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
}

pre, details {
width: 50%;
margin: 0 auto;
width: 30%;
margin: 10px auto;
padding: 20px;
text-align: center;
white-space: collapse;
border: 2px dotted #222;
}

button.reset {
Expand Down Expand Up @@ -77,10 +80,44 @@ <h2>Declarative Shadow DOM (has JS)</h2>

<pre>
&lt;sb-card
title="iPhone 9" thumbnail="https://d2e6ccujb3mkqf.cloudfront.net/f2c7f593-2e6b-4e39-9d9e-9d92c95d84a3-1_0f59d2ff-bd21-417f-9002-0aa1f1e8236e.jpg"
title="iPhone 9"
thumbnail="https://www.greenwoodjs.io/assets/greenwood-logo-og.png"
&gt;&lt;/sb-card&gt;
</pre>

<h2>HTML Web Component (Light DOM + has JS)</h2>

<style>
.picture-frame {
width: 200px;
margin: 0 auto;
border: 1px solid #222;
padding: 0 0 10px 0;
border-radius: 10px;

& .heading {
text-align: center;
text-decoration: underline;
font-size: 16px;
}
}
</style>
<sb-picture-frame title="Greenwood Logo">
<img
src="https://www.greenwoodjs.io/assets/greenwood-logo-og.png"
alt="Greenwood logo"
/>
</sb-picture-frame>

<pre>
&lt;sb-picture-frame title="Greenwood Logo"&gt;
&lt;img
src="https://www.greenwoodjs.io/assets/greenwood-logo-og.png"
alt="Greenwood logo"
/&gt;
&lt;/sb-picture-frame&gt;
</pre>

<hr/>

<h2>JSX + Light DOM (no JS)</h2>
Expand All @@ -102,7 +139,8 @@ <h2>JSX + Declarative Shadow DOM (has JS)</h2>

<pre>
&lt;sb-card-jsx
title="iPhone X" thumbnail="https://d2e6ccujb3mkqf.cloudfront.net/7e8317d3-cc5d-42a8-8350-ba6a02560477-1_d64a25e3-e1f3-4172-b7c9-0c96e82c4d3f.jpg"
title="iPhone X"
thumbnail="https://d2e6ccujb3mkqf.cloudfront.net/7e8317d3-cc5d-42a8-8350-ba6a02560477-1_d64a25e3-e1f3-4172-b7c9-0c96e82c4d3f.jpg"
&gt;&lt;/sb-card-jsx&gt;
</pre>

Expand Down
88 changes: 76 additions & 12 deletions src/wcc.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ import { importAttributes } from 'acorn-import-attributes';
import { transform } from 'sucrase';
import fs from 'fs';

// https://developer.mozilla.org/en-US/docs/Glossary/Void_element
const VOID_ELEMENTS = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param', // deprecated
'source',
'track',
'wbr'
];

function getParse(html) {
return html.indexOf('<html>') >= 0 || html.indexOf('<body>') >= 0 || html.indexOf('<head>') >= 0
? parse
Expand All @@ -33,17 +51,26 @@ async function renderComponentRoots(tree, definitions) {

if (definitions[tagName]) {
const { moduleURL } = definitions[tagName];
const elementInstance = await initializeCustomElement(moduleURL, tagName, node.attrs, definitions);
const elementHtml = elementInstance.shadowRoot
? elementInstance.getInnerHTML({ includeShadowRoots: true })
: elementInstance.innerHTML;
const elementTree = parseFragment(elementHtml);

node.childNodes = node.childNodes.length === 0
? elementTree.childNodes
: [...elementTree.childNodes, ...node.childNodes];
const elementInstance = await initializeCustomElement(moduleURL, tagName, node, definitions);

if (elementInstance) {
const hasShadow = elementInstance.shadowRoot;
const elementHtml = hasShadow
? elementInstance.getInnerHTML({ includeShadowRoots: true })
: elementInstance.innerHTML;
const elementTree = parseFragment(elementHtml);
const hasLight = elementTree.childNodes > 0;

node.childNodes = node.childNodes.length === 0 && hasLight && !hasShadow
? elementTree.childNodes
: hasShadow
? [...elementTree.childNodes, ...node.childNodes]
: elementTree.childNodes;
} else {
console.warn(`WARNING: customElement <${tagName}> detected but not serialized. You may not have exported it.`);
}
} else {
console.warn(`WARNING: customElement <${tagName}> is not defined. You may not have imported it yet.`);
console.warn(`WARNING: customElement <${tagName}> is not defined. You may not have imported it.`);
}
}

Expand Down Expand Up @@ -82,7 +109,7 @@ function registerDependencies(moduleURL, definitions, depth = 0) {
const isBareSpecifier = specifier.indexOf('.') !== 0 && specifier.indexOf('/') !== 0;
const extension = specifier.split('.').pop();

// TODO would like to decouple .jsx from the core, ideally
// would like to decouple .jsx from the core, ideally
// https://github.com/ProjectEvergreen/wcc/issues/122
if (!isBareSpecifier && ['js', 'jsx', 'ts'].includes(extension)) {
const dependencyModuleURL = new URL(node.source.value, moduleURL);
Expand Down Expand Up @@ -138,7 +165,41 @@ async function getTagName(moduleURL) {
return tagName;
}

async function initializeCustomElement(elementURL, tagName, attrs = [], definitions = [], isEntry, props = {}) {
function renderLightDomChildren(childNodes, iHTML = '') {
let innerHTML = iHTML;

childNodes.forEach((child) => {
const { nodeName, attrs = [], value } = child;

if (nodeName !== '#text') {
innerHTML += `<${nodeName}`;

if (attrs.length > 0) {
attrs.forEach(attr => {
innerHTML += ` ${attr.name}="${attr.value}"`;
});
}

innerHTML += '>';

if (child.childNodes.length > 0) {
innerHTML = renderLightDomChildren(child.childNodes, innerHTML);
}

innerHTML += VOID_ELEMENTS.includes(nodeName)
? ''
: `</${nodeName}>`;
} else if (nodeName === '#text') {
innerHTML += value;
}
});

return innerHTML;
}

async function initializeCustomElement(elementURL, tagName, node = {}, definitions = [], isEntry, props = {}) {
const { attrs = [], childNodes = [] } = node;

if (!tagName) {
const depth = isEntry ? 1 : 0;
registerDependencies(elementURL, definitions, depth);
Expand All @@ -158,6 +219,9 @@ async function initializeCustomElement(elementURL, tagName, attrs = [], definiti
if (element) {
const elementInstance = new element(data); // eslint-disable-line new-cap

// support for HTML (Light DOM) Web Components
elementInstance.innerHTML = renderLightDomChildren(childNodes);

attrs.forEach((attr) => {
elementInstance.setAttribute(attr.name, attr.value);

Expand Down
13 changes: 13 additions & 0 deletions test/cases/html-web-components/expected.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<wcc-picture-frame title="Greenwood">
<div class="picture-frame">
<img src="https://www.greenwoodjs.io/assets/greenwood-logo-og.png" alt="Greenwood logo">
<br>
<span>Author: <span>WCC</span></span>
<wcc-caption>
<div class="caption">
<h6 class="heading">Greenwood</h6>
<span>© 2024</span>
</div>
</wcc-caption>
</div>
</wcc-picture-frame>
85 changes: 85 additions & 0 deletions test/cases/html-web-components/html-web-components.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Use Case
* Run wcc against an "HTML" Web Component.
* https://blog.jim-nielsen.com/2023/html-web-components/
*
* User Result
* Should return the expected HTML with no template tags or Shadow Roots.
*
* User Workspace
* src/
* components/
* caption.js
* picture-frame.js
* pages/
* index.js
*/
import chai from 'chai';
import { JSDOM } from 'jsdom';
import fs from 'fs/promises';
import { renderToString } from '../../../src/wcc.js';

const expect = chai.expect;

describe('Run WCC For ', function() {
const LABEL = 'HTML (Light DOM) Web Components';
let dom;
let pictureFrame;
let expectedHtml;
let actualHtml;

before(async function() {
const { html } = await renderToString(new URL('./src/pages/index.js', import.meta.url));

actualHtml = html;
dom = new JSDOM(actualHtml);
pictureFrame = dom.window.document.querySelectorAll('wcc-picture-frame');
expectedHtml = await fs.readFile(new URL('./expected.html', import.meta.url), 'utf-8');
});

describe(LABEL, function() {
it('should not have any <template> tags within the document', function() {
expect(dom.window.document.querySelectorAll('template').length).to.equal(0);
});

it('should only have one <wcc-picture-frame> tag', function() {
expect(pictureFrame.length).to.equal(1);
});

it('should have the expected image from userland in the HTML', () => {
const img = pictureFrame[0].querySelectorAll('.picture-frame img');

expect(img.length).to.equal(1);
expect(img[0].getAttribute('alt')).to.equal('Greenwood logo');
expect(img[0].getAttribute('src')).to.equal('https://www.greenwoodjs.io/assets/greenwood-logo-og.png');
});

it('should have the expected Author name <span> from userland in the HTML', () => {
const img = pictureFrame[0].querySelectorAll('.picture-frame img + br + span');

expect(img.length).to.equal(1);
expect(img[0].textContent).to.equal('Author: WCC');
});

it('should have the expected title attribute content in the nested <wcc-caption> tag', () => {
const caption = pictureFrame[0].querySelectorAll('.picture-frame wcc-caption .caption');
const heading = caption[0].querySelectorAll('.heading');

expect(caption.length).to.equal(1);
expect(heading.length).to.equal(1);
expect(heading[0].textContent).to.equal('Greenwood');
});

it('should have the expected copyright content in the nested <wcc-caption> tag', () => {
const caption = pictureFrame[0].querySelectorAll('.picture-frame wcc-caption .caption');
const span = caption[0].querySelectorAll('span');

expect(span.length).to.equal(1);
expect(span[0].textContent).to.equal('© 2024');
});

it('should have the expected recursively generated HTML', () => {
expect(expectedHtml.replace(/ /g, '').replace(/\n/g, '')).to.equal(actualHtml.replace(/ /g, '').replace(/\n/g, ''));
});
});
});
Loading
Loading