Skip to content

Commit

Permalink
feature/issue 154 light DOM children aka HTML web components (#155)
Browse files Browse the repository at this point in the history
* light DOM children aka HTML web components

* fix lint

* HTML web component sandbox example

* HTML web component test cae

* nested HTML web component test

* fix linting

* additional test cases and refactoring

* HTML parity test cases and Light DOM edge cases

* document self closing tags list

* document example of HTML Web Components
  • Loading branch information
thescientist13 authored Oct 19, 2024
1 parent 21b4850 commit cb4ae5c
Show file tree
Hide file tree
Showing 19 changed files with 559 additions and 39 deletions.
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

0 comments on commit cb4ae5c

Please sign in to comment.