From a47eb47a87171d1fb5dd0a5611dd4c9d27113003 Mon Sep 17 00:00:00 2001 From: Brian Grider Date: Mon, 20 Jan 2025 11:39:48 -0800 Subject: [PATCH 1/2] - Simplified how node properties are assigned to a fresh initializedElement which allows for element properties without having to change API - Removed excessive commenting in dom-shim - Added a test case for setting element properties with a custom renderer - Added a test case for setAttribute --- src/dom-shim.js | 17 ++-- src/wcc.js | 7 +- .../cases/element-props/element-props.spec.js | 38 ++++++++ .../src/components/prop-passer.js | 10 +++ .../src/components/prop-receiver.js | 9 ++ test/cases/element-props/src/index.js | 10 +++ test/cases/element-props/src/renderer.js | 90 +++++++++++++++++++ .../cases/set-attribute/set-attribute.spec.js | 33 +++++++ test/cases/set-attribute/src/index.js | 10 +++ 9 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 test/cases/element-props/element-props.spec.js create mode 100644 test/cases/element-props/src/components/prop-passer.js create mode 100644 test/cases/element-props/src/components/prop-receiver.js create mode 100644 test/cases/element-props/src/index.js create mode 100644 test/cases/element-props/src/renderer.js create mode 100644 test/cases/set-attribute/set-attribute.spec.js create mode 100644 test/cases/set-attribute/src/index.js diff --git a/src/dom-shim.js b/src/dom-shim.js index b25255d..a53190d 100644 --- a/src/dom-shim.js +++ b/src/dom-shim.js @@ -14,7 +14,7 @@ function isShadowRoot(element) { function deepClone(obj, map = new WeakMap()) { if (obj === null || typeof obj !== 'object') { - return obj; // Return primitives or functions as-is + return obj; } if (typeof obj === 'function') { @@ -90,7 +90,7 @@ class Node extends EventTarget { const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes; if (node.parentNode) { - node.parentNode?.removeChild?.(node); // Remove from current parent + node.parentNode?.removeChild?.(node); } if (node.nodeName === 'template') { @@ -130,24 +130,21 @@ class Node extends EventTarget { get textContent() { if (this.nodeName === '#text') { - return this.value || ''; // Text nodes should return their value + return this.value || ''; } - // Compute textContent for elements by concatenating text of all descendants return this.childNodes .map((child) => child.nodeName === '#text' ? child.value : child.textContent) .join(''); } set textContent(value) { - // Remove all current child nodes this.childNodes = []; if (value) { - // Create a single text node with the given value const textNode = new Node(); textNode.nodeName = '#text'; - textNode.value = value; // Text node content + textNode.value = value; textNode.parentNode = this; this.childNodes.push(textNode); } @@ -171,29 +168,25 @@ class Element extends Node { return this.shadowRoot && serializableShadowRoots && this.shadowRoot.serializable ? this.shadowRoot.innerHTML : ''; } - // Serialize the content of the DocumentFragment when getting innerHTML get innerHTML() { const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes; return childNodes ? serialize({ childNodes }) : ''; } set innerHTML(html) { - (this.nodeName === 'template' ? this.content : this).childNodes = getParse(html)(html).childNodes; // Replace content's child nodes + (this.nodeName === 'template' ? this.content : this).childNodes = getParse(html)(html).childNodes; } hasAttribute(name) { - // Modified attribute handling to work with parse5 return this.attrs.some((attr) => attr.name === name); } getAttribute(name) { - // Modified attribute handling to work with parse5 const attr = this.attrs.find((attr) => attr.name === name); return attr ? attr.value : null; } setAttribute(name, value) { - // Modified attribute handling to work with parse5 const attr = this.attrs?.find((attr) => attr.name === name); if (attr) { diff --git a/src/wcc.js b/src/wcc.js index b328cf3..d394f17 100644 --- a/src/wcc.js +++ b/src/wcc.js @@ -144,7 +144,6 @@ async function getTagName(moduleURL) { } async function initializeCustomElement(elementURL, tagName, node = {}, definitions = [], isEntry, props = {}) { - const { attrs = [], childNodes = [] } = node; if (!tagName) { const depth = isEntry ? 1 : 0; @@ -161,11 +160,7 @@ async function initializeCustomElement(elementURL, tagName, node = {}, definitio if (element) { const elementInstance = new element(data); // eslint-disable-line new-cap - elementInstance.childNodes = childNodes; - - attrs.forEach((attr) => { - elementInstance.setAttribute(attr.name, attr.value); - }); + Object.assign(elementInstance, node); await elementInstance.connectedCallback(); diff --git a/test/cases/element-props/element-props.spec.js b/test/cases/element-props/element-props.spec.js new file mode 100644 index 0000000..ef2253e --- /dev/null +++ b/test/cases/element-props/element-props.spec.js @@ -0,0 +1,38 @@ +/* + * Use Case + * Run wcc against a component that passes properties to a child component. + * + * User Result + * Should return the expected HTML output based on the content of the passed property. + * + * User Workspace + * src/ + * index.js + * renderer.js + * components/ + * prop-passer.js + * prop-receiver.js + */ + +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +const expect = chai.expect; + +describe('Run WCC For ', function () { + const LABEL = 'Custom Element w/ Element Properties'; + let dom; + + before(async function () { + const { html } = await renderToString(new URL('./src/index.js', import.meta.url)); + console.log(html); + dom = new JSDOM(html); + }); + + describe(LABEL, function () { + it('should have a prop-receiver component with a heading tag with text content equal to "bar"', function () { + expect(dom.window.document.querySelector('prop-receiver h2').textContent).to.equal('bar'); + }); + }); +}); diff --git a/test/cases/element-props/src/components/prop-passer.js b/test/cases/element-props/src/components/prop-passer.js new file mode 100644 index 0000000..d7f2c7b --- /dev/null +++ b/test/cases/element-props/src/components/prop-passer.js @@ -0,0 +1,10 @@ +import { html, render } from '../renderer.js'; + +export default class PropPasser extends HTMLElement { + connectedCallback() { + const data = { foo: 'bar' }; + render(html``, this); + } +} + +customElements.define('prop-passer', PropPasser); \ No newline at end of file diff --git a/test/cases/element-props/src/components/prop-receiver.js b/test/cases/element-props/src/components/prop-receiver.js new file mode 100644 index 0000000..ade7717 --- /dev/null +++ b/test/cases/element-props/src/components/prop-receiver.js @@ -0,0 +1,9 @@ +import { html, render } from '../renderer.js'; + +export default class ProperReceiver extends HTMLElement { + connectedCallback() { + render(html`

${this.data.foo}

`, this); + } +} + +customElements.define('prop-receiver', ProperReceiver); \ No newline at end of file diff --git a/test/cases/element-props/src/index.js b/test/cases/element-props/src/index.js new file mode 100644 index 0000000..229586c --- /dev/null +++ b/test/cases/element-props/src/index.js @@ -0,0 +1,10 @@ +import './components/prop-passer.js'; +import './components/prop-receiver.js'; + +export default class ElementProps extends HTMLElement { + connectedCallback() { + this.innerHTML = ''; + } +} + +customElements.define('element-props', ElementProps); \ No newline at end of file diff --git a/test/cases/element-props/src/renderer.js b/test/cases/element-props/src/renderer.js new file mode 100644 index 0000000..22f0e98 --- /dev/null +++ b/test/cases/element-props/src/renderer.js @@ -0,0 +1,90 @@ +import { parseFragment } from 'parse5'; + +function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.floor(Math.random() * 16); + const v = c === 'x' ? r : (r % 4) + 8; + return v.toString(16); + }); +} + +function removeAttribute(element, attribute) { + element.attrs = element.attrs.filter((attr) => attr.name !== attribute); +} + +function handlePropertyAttribute(element, attribute, value, deps) { + const propName = attribute.substring(1); + removeAttribute(element, attribute); + if (!element.props) { element.props = {}; } + element[propName] = deps[value] ?? value; +} + +function buildStringFromTemplate(template) { + const { strings, values } = template; + + if (!strings || !values) { + return { string: '', deps: {} }; + } + + const stringParts = []; + const deps = {}; + let isElement = false; + + strings.reduce((acc, stringAtIndex, index) => { + acc.push(stringAtIndex); + + isElement = + stringAtIndex.includes('<') || stringAtIndex.includes('>') + ? stringAtIndex.lastIndexOf('<') > stringAtIndex.lastIndexOf('>') + : isElement; + + const valueAtIndex = values[index]; + + if (valueAtIndex != null) { + const isPrimitive = typeof valueAtIndex === 'string' || typeof valueAtIndex === 'number'; + const valueKey = isPrimitive ? null : generateUUID() + index; + const lastPart = acc[acc.length - 1]; + const needsQuotes = isElement && !lastPart.endsWith('"'); + acc.push(`${needsQuotes ? '"' : ''}${valueKey !== null ? valueKey : valueAtIndex}${needsQuotes ? '"' : ''}`); + + if (valueKey) { + deps[valueKey] = valueAtIndex; + } + } + return acc; + }, stringParts); + + return { string: stringParts.join(''), deps }; +} + +function setAttributes(childNodes, deps) { + childNodes.forEach((element, index)=>{ + const { attrs, nodeName } = element; + if (nodeName === '#comment') { return; } + attrs?.forEach(({ name, value }) => { + if (name.startsWith('.')) { + handlePropertyAttribute(childNodes[index], name, value, deps); + } + }); + if (element.childNodes) { + setAttributes(element.childNodes, deps); + } + }); +} + +export function render(content, container) { + const { string, deps } = buildStringFromTemplate(content); + const parsedContent = parseFragment(string); + + setAttributes(parsedContent.childNodes, deps); + const template = document.createElement('template'); + template.content.childNodes = parsedContent.childNodes; + container.appendChild(template.content.cloneNode(true)); +} + +export const html = (strings, ...values) => { + return { + strings, + values + }; +}; \ No newline at end of file diff --git a/test/cases/set-attribute/set-attribute.spec.js b/test/cases/set-attribute/set-attribute.spec.js new file mode 100644 index 0000000..a15957e --- /dev/null +++ b/test/cases/set-attribute/set-attribute.spec.js @@ -0,0 +1,33 @@ +/* + * Use Case + * Run wcc against a component which sets an attribute of a child heading. + * + * User Result + * Should return a component with a child h2 with the expected attribute and attribute value. + * + * User Workspace + * src/ + * index.js + */ + +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +const expect = chai.expect; + +describe('Run WCC For ', function () { + const LABEL = 'Custom Element using setAttribute'; + let dom; + + before(async function () { + const { html } = await renderToString(new URL('./src/index.js', import.meta.url)); + dom = new JSDOM(html); + }); + + describe(LABEL, function () { + it('should have a heading tag with the "foo" attribute equal to "bar"', function () { + expect(dom.window.document.querySelector('set-attribute-element h2').getAttribute('foo')).to.equal('bar'); + }); + }); +}); diff --git a/test/cases/set-attribute/src/index.js b/test/cases/set-attribute/src/index.js new file mode 100644 index 0000000..9dca477 --- /dev/null +++ b/test/cases/set-attribute/src/index.js @@ -0,0 +1,10 @@ +export default class SetAttributeElement extends HTMLElement { + + connectedCallback() { + const heading = document.createElement('h2'); + heading.setAttribute('foo', 'bar'); + this.appendChild(heading); + } +} + +customElements.define('set-attribute-element', SetAttributeElement); \ No newline at end of file From 130a8189d40422eb1cd690ba67ffa9dc5567f2de Mon Sep 17 00:00:00 2001 From: Brian Grider Date: Mon, 20 Jan 2025 14:56:50 -0800 Subject: [PATCH 2/2] Removed console.log --- test/cases/element-props/element-props.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/cases/element-props/element-props.spec.js b/test/cases/element-props/element-props.spec.js index ef2253e..c66b6e8 100644 --- a/test/cases/element-props/element-props.spec.js +++ b/test/cases/element-props/element-props.spec.js @@ -26,7 +26,6 @@ describe('Run WCC For ', function () { before(async function () { const { html } = await renderToString(new URL('./src/index.js', import.meta.url)); - console.log(html); dom = new JSDOM(html); });