Create WebComponent through createElement - javascript

I'm having an issue creating a Web Component using createElement. I'm getting this error:
Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children
at appendTodo
class TodoCard extends HTMLElement {
constructor() {
super()
this.innerHTML = `
<li>
<div class="card">
<span class="card-content">${this.getAttribute('content')}</span>
<i class="fa fa-circle-o" aria-hidden="true"></i>
<i class="fa fa-star-o" aria-hidden="true"></i>
</div>
</li>
`
}
}
window.customElements.define('todo-card', TodoCard)
const todoList = document.getElementById('todo-list')
const todoForm = document.getElementById('todo-form')
const todoInput = document.getElementById('todo-input')
function appendTodo(content) {
const todo = document.createElement('todo-card')
todo.setAttribute('content', content)
todoList.appendChild(todo)
}
todoForm.addEventListener('submit', e => {
e.preventDefault()
appendTodo(todoInput.value)
todoInput.value = ''
})
any ideas?
Thanks.

A Custom Element that sets DOM content in the constructor
can never be created with document.createElement()
You will see many examples (including from me) where DOM content is set in the constructor.
Those Elements can never be created with document.createElement
Explanation (HTML DOM API):
When you use:
<todo-card content=FOO></todo-card>
The element (extended from HTMLElement) has all the HTML interfaces (it is in a HTML DOM),
and you can set the innerHTML in the constructor
But, when you do:
document.createElement("todo-card");
The constructor runs, without HTML interfaces (the element may have nothing to do with a DOM),
thus setting innerHTML in the constructor produces the error:
Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children
From https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-conformance:
The element must not gain any attributes or children, as this violates the expectations of consumers who use the createElement or createElementNS methods.
In general, work should be deferred to connectedCallback as much as possible
shadowDOM is a DOM
When using shadowDOM you can set shadowDOM content in the constructor:
constructor(){
super().attachShadow({mode:"open"})
.innerHTML = `...`;
}
Correct code (no shadowDOM): use the connectedCallback:
<todo-card content=FOO></todo-card>
<script>
window.customElements.define(
"todo-card",
class extends HTMLElement {
constructor() {
super();
//this.innerHTML = this.getAttribute("content");
}
connectedCallback() {
this.innerHTML = this.getAttribute("content");
}
}
);
try {
const todo = document.createElement("todo-card");
todo.setAttribute("content", "BAR");
document.body.appendChild(todo);
} catch (e) {
console.error(e);
}
</script>
You have another minor issue: content was a default attribute, and FireFox won't stop warning you:
Or don't use createElement
const todo = document.createElement("todo-card");
todo.setAttribute("content", "BAR");
document.body.appendChild(todo);
can be written as:
const html = `<todo-card content="BAR"></todo-card`;
document.body.insertAdjacentHTML("beforeend" , html);
The connectedCallback can run multiple times!
When you move DOM nodes around:
<div id=DO_Learn>
<b>DO Learn: </b><todo-card todo="Custom Elements API"></todo-card>
</div>
<div id="DONT_Learn">
<b>DON'T Learn!!! </b><todo-card todo="React"></todo-card>
</div>
<script>
window.customElements.define(
"todo-card",
class extends HTMLElement {
connectedCallback() {
let txt = this.getAttribute("todo");
this.append(txt);// and appended again on DOM moves
console.log("qqmp connectedCallback\t", this.parentNode.id, this.innerHTML);
}
disconnectedCallback() {
console.log("disconnectedCallback\t", this.parentNode.id , this.innerHTML);
}
}
);
const LIT = document.createElement("todo-card");
LIT.setAttribute("todo", "Lit");
DO_Learn.append(LIT);
DONT_Learn.append(LIT);
</script>
connectedCallback runs for LIT
when LIT is moved
disconnectedCallback runs (note the parent! The Element is already in the new location)
connectedCallback for LIT runs again, appending "Learn Lit" again
It is up to you the programmer how your component/application must handle this
Web Component Libraries
Libraries like Lit, HyperHTML and Hybrids have extra callbacks implemented that help with all this.
I advice to learn the Custom Elements API first, otherwise you are learning a tool and not the technology.
And a Fool with a Tool, is still a Fool

Related

Cannot appendChild to a custom web component

I have a web-component at root level. The simplified version of which is shown below:
class AppLayout {
constructor() {
super();
this.noShadow = true;
}
connectedCallback() {
super.connectedCallback();
this.render();
this.insertAdjacentHTML("afterbegin", this.navigation);
}
render() {
this.innerHTML = this.template;
}
get template() {
return `
<h1>Hello</h1>
`;
}
navigation = `
<script type="module">
import './components/nav-bar.js'
</script>
`;
}
customElements.define('app-layout', AppLayout);
I want to load a script after this component loads. The script creates html for navigation and tries to add it to the app-layout element shown above. However, even though, it does find the app-layout element, it is unable to append the navBar element. It is, however, able to append the navBar to the body of the html. Any ideas what I'm missing.
const navLinks =
`<ul>
<li>Some</li>
<li>Links</li>
</ul>
`;
const navBar = document.createElement('nav');
navBar.innerHTML = navLinks;
const appLayout = document.querySelector('app-layout'); // works with 'body' but not with 'appLayout'
console.log(appLayout); // Logs correct element
appLayout.appendChild(navBar);
I know that what I'm trying to do here (loading a script inside a web component) is not ideal, however, I would like to still understand why the above doesn't work.
using innerHTML or in your case insertAdjacentHTML to add <script> tags to the document doesn't work because browsers historically try to prevent potential cross site script attacks (https://www.w3.org/TR/2008/WD-html5-20080610/dom.html#innerhtml0)
What you could do is something like:
const s = document.createElement("script");
s.type = "module";
s.innerText = `import './components/nav-bar.js'`;
this.append(s);
// or simply directly without the script: `import('./comp..')` if it is really your only js in the script tag.

Is there a way to add custom attributes to custom elements

While messing around with custom elements I wondered if one could use custom attributes within the elements (and possibly within their children too). I know VueJS does something similar with attributes like v-bind, v-for, etc; and I know there's probably a lot more going on under the hood there than I realize. I've tried registering custom elements and attempting to retrieve them like so:
<new-element cool="Awesome!"> </new-element>
class NewElement extends HTMLElement {
constructor() {
super();
this.coolAttr = this.getAttribute("cool");
}
}
customElements.define("new-element", NewElement);
However, when loading the page (in Google Chrome for me) the "custom" attributes disappear, and any attempt at getting them retrieves null. Is there a way to "register" these custom attributes, or do I have to stick with data- attributes?
Attributes become available in the connectedCallback,
they are not available yet in the constructor
Unless the Custom Element is PARSED (in the DOM) BEFORE the Element is defined!!
Also be aware the attributeChangedCallback runs before the connectedCallback
for Observed attributes
Also see: https://andyogo.github.io/custom-element-reactions-diagram/
.as-console-row-code {
font: 12px Arial!important;
background:yellow;
color:darkred;
}
.as-console-row:after{ display:none!important }
<before-element cool="Awesome?">FOO</before-element>
<script>
class NewElement extends HTMLElement {
log( ...args ){
console.log(this.nodeName, `cool:${this.getAttribute("cool")}`,"\t\t\t",...args );
}
static get observedAttributes() {
return ["cool"];
}
constructor() {
const name = "constructor"; // CAN! run code BEFORE super()!
// super() sets AND returns the 'this' scope
super().log(name);
}
connectedCallback() {
this.log("connectedCallback", this.innerHTML || "innerHTML not parsed yet");
// be aware this.innerHTML is only available for PARSED elements
// use setTimeout(()=>{...},0) if you do need this.innerHTML
}
attributeChangedCallback(name, oldValue, newValue) {
this.log(`attributeChangedCallback name:${name}, old:${oldValue}, new:${newValue}`);
}
}
customElements.define("before-element", class extends NewElement {});
customElements.define("after-element", class extends NewElement {});
</script>
<after-element cool="Awesome!!">BAR</after-element>
It can be easily solved by adding the name of the attributes after "data-".
<New-element data-*="Anything you want" />
for example, you can have:
<element data-cool="value">
You can have as many custom attributes as you want.

Stenciljs: What is the right way to prepend/append Slot elements?

I am having some trouble with the lifecycle methods in web components.
We want to dynamically order child elements being passed in as slots.
To illustrate, this web component takes a prop, iconPos, and will determine whether the icon will be placed at the start or end of the slot.
<my-component iconPos="start">
<img src="/path/icon.svg" />
<div>{this.list}</div>
</my-component>
I haven't had any luck getting it working with ref:
dc6b89e7.js:2926 TypeError: Cannot read properties of undefined (reading 'prepend')
Here's what I have so far:
#State() slotElement!: HTMLDivElement;
#Prop() iconPos: 'start' | 'end';
...
private createSlots() {
switch (this.iconPos) {
case 'start':
this.slotElement.prepend(<img />);
break;
case 'end':
this.slotElement.append(<img />);
break;
default:
throw new Error(
`Invalid value \`${this.iconPos}\`, passed into \`iconPos\`. Expected valid values are \`start\`, \`end\``.
);
}
}
render() {
return (
// iconPos="start"
<parent-component>
<div ref={(el) => (this.slotElement= el as HTMLDivElement)}>
<slot></slot>
</div>
</parent-component>
)
}
I would prefer to not use a CSS solution if possible. Any help would be much appreciated!
Slotted content is NOT MOVED to <slot> elements; it is reflected!!
So all styling and element operations must be done in "lightDOM"
For (very) long read see:
::slotted CSS selector for nested children in shadowDOM slot
That means you have to append your elements in ligtDOM with:
this.append(this.firstElementChild)
You can't read the <my-component> innerHTML before it is parsed; so you need to wait till the innerHTML elements are created. Thus you will see the DOM change.
A better method might be to not use <slot> and declare your icon and content as attributes, and have the Web Component create the HTML.
<style>
span::after { content: attr(id) }
#FOO { background: lightgreen }
</style>
<my-component>
<span id="FOO"></span>
<span id="BAR"></span>
</my-component>
<my-component reversed>
<span id="FOO"></span>
<span id="BAR"></span>
</my-component>
<script>
window.customElements.define('my-component', class extends HTMLElement {
constructor() {
super().attachShadow({mode:'open'})
.innerHTML = `<style>::slotted(span){background:gold}</style>
${this.nodeName}<slot></slot><br>`;
}
connectedCallback() {
setTimeout(() => { // make sure innerHTML is parsed!
if (this.hasAttribute("reversed")) {
this.append(this.firstElementChild);
}
})
}
});
</script>

Vue and Prismic rich text: add event listener to a span node

The content of my Vue app is fetched from Prismic (an API CMS). I have a rich text block, some parts of which are wrapped inside span tags with a specific class. I want to get those span nodes with Vue and add to them an event listener.
With JS, this code would work:
var selectedSpanElements = document.querySelectorAll('.className');
selectedSpanElements[0].style.color = "red"
But when I use this code in Vue, I can see that it works just a fraction of a second before Vue updates the DOM. I've tried using this code on mounted, beforeupdate, updated, ready hooks... Nothing has worked.
Update: Some hours later, I found that with the HTMLSerializer I can add HTML code to the span tag. But this is regular HTML, I cannot access to Vue methods.
#Bruja
I was able to find a solution using a closure. The folks at Prismic reminded/showed me.
Of note, per Phil Snow's comment above: If you are using Nuxt you won't have access to Vue's functionality and will have to go old-school JS.
Here is an example where you can pass in component-level props, data, methods, etc... to the prismic htmlSerializer:
<template>
<div>
<prismic-rich-text
:field="data"
:htmlSerializer="anotherHtmlSerializer((startNumber = list.start_number))"
/>
</div>
</template>
import prismicDOM from 'prismic-dom';
export default {
methods: {
anotherHtmlSerializer(startNumber = 1) {
const Elements = prismicDOM.RichText.Elements;
const that = this;
return function(type, element, content, children) {
// To add more elements and customizations use this as a reference:
// https://prismic.io/docs/vuejs/beyond-the-api/html-serializer
that.testMethod(startNumber);
switch (type) {
case Elements.oList:
return `<ol start=${startNumber}>${children.join('')}</ol>`;
}
// Return null to stick with the default behavior for everything else
return null;
};
},
testMethod(startNumber) {
console.log('test method here');
console.log(startNumber);
}
}
};
I believe you are on the right track looking into the HTML Serializer. If you want all your .specialClass <span> elements to trigger a click event that calls specialmethod() this should work for you:
import prismicDOM from 'prismic-dom';
const Elements = prismicDOM.RichText.Elements;
export default function (type, element, content, children) {
// I'm not 100% sure if element.className is correct, investigate with your devTools if it doesn't work
if (type === Elements.span && element.className === "specialClass") {
return `<span #click="specialMethod">${content}</span>`;
}
// Return null to stick with the default behavior for everything else
return null;
};

How to execute javascript in shadow dom/web components?

I'm trying to create a custom element with most of the javascript encapsulated/referenced in the template/html itself. How can I make that javascript from the template/element to be executed in the shadow dom? Below is an example to better understand the issue. How can I make the script from template.innerHTML (<script>alert("hello"); console.log("hello from tpl");</script>) to execute?
Currently I get no alert or logs into the console. I'm testing this with Chrome.
class ViewMedia extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'closed'});
var template = document.createElement( 'template' );
template.innerHTML = '<script>alert("hello"); console.log("hello from tpl")';
shadow.appendChild( document.importNode( template.content, true ) );
}
}
customElements.define('x-view-media', ViewMedia);
<x-view-media />
A few points:
Browsers no longer allow you to add script via innerHTML
There is no sand-boxing of script within the DOM a web component like there is in an iFrame.
You can create script blocks using var el = document.createElement('script'); and then adding them as child elements.
class ViewMedia extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'closed'});
const s = document.createElement('script');
s.textContent = 'alert("hello");';
shadow.appendChild(s);
}
}
customElements.define('x-view-media', ViewMedia);
<x-view-media></x-view-media>
The reason this fails is because importNode does not evaluate scripts that were imported from another document, which is essentially what's happening when you use innerHTML to set the template content. The string you provide is parsed into a DocumentFragment which is considered a separate document. If the template element was selected from the main document, the scripts would be evaluated as expected :
<template id="temp">
<script> console.log('templated script'); </script>
</template>
<div id="host"></div>
<script>
let temp = document.querySelector('#temp');
let host = document.querySelector('#host');
let shadow = host.attachShadow({ mode:'closed' });
shadow.append(document.importNode(temp.content, true));
</script>
One way to force your scripts to evaluate would be to import them using a contextual fragment :
<div id="host"></div>
<script>
let host = document.querySelector('#host');
let shadow = host.attachShadow({ mode:'closed' });
let content = `<script> console.log(this); <\/script>`;
let fragment = document.createRange().createContextualFragment(content);
shadow.append(document.importNode(fragment, true));
</script>
But, this breaks encapsulation as the scripts inside your shadowRoot will actually be evaluated in the global scope with no access to your closed shadow dom. The method that I came up with to deal with this issue is to loop over each script in the shadow dom and evaluate it with the shadowRoot as it's scope. You can't just pass the host object itself as the scope because you'll lose access to the closed shadowRoot. Instead, you can access the ShadowRoot.host property which would be available as this.host inside the embedded scripts.
class TestElement extends HTMLElement {
#shadowRoot = null;
constructor() {
super();
this.#shadowRoot = this.attachShadow({ mode:'closed' });
this.#shadowRoot.innerHTML = this.template
}
get template() {
return `
<style>.passed{color:green}</style>
<div id="test"> TEST A </div>
<slot></slot>
<script>
let a = this.querySelector('#test');
let b = this.host.firstElementChild;
a && a.classList.add('passed');
b && (b.style.color = 'green');
<\/script>
`;
}
get #scripts() {
return this.#shadowRoot.querySelectorAll('script');
}
#scopedEval = (script) =>
Function(script).bind(this.#shadowRoot)();
#processScripts() {
this.#scripts.forEach(
s => this.#scopedEval(s.innerHTML)
);
}
connectedCallback() {
this.#processScripts();
}
}
customElements.define('test-element', TestElement);
<test-element>
<p> TEST B </p>
</test-element>
Do not use this technique with an open shadowRoot as you will leave your component vulnerable to script injection attacks. The browser prevents arbitrary code execution for a reason: to keep you and your users safe. Do not inject untrusted content into your shadow dom with this enabled, only use this to evaluate your own scripts or trusted libraries, and ideally avoid this trick if at all possible. There are almost always better ways to execute scripts that interact with your shadow dom, like scoping all your logic into your custom element definition.
Side note: Element.setHTML is a much safer method for importing untrusted content which is coming soon as part of the HTML Sanitizer API.

Categories

Resources