How to add style after web component stable state? - javascript

I have a parent component, it generates another 60 child component on render. after the render completed, and my parent component added with body, I would like to adjust the margin, paddings. because i require the length of child components.
when i try now getting output as 'null' any one help me please?
here is my code :
import './../components/avatar.component';
import RandomEmails from './../services/random-email-service';
export default class AvatarContainer extends HTMLElement {
shadowObj;
imageProps = [];
imageURL = `http://www.gravatar.com/avatar/6288f2a2679a0242771aa6cc02e85980?d=identicon&s=200`;
constructor(){
super();
this.shadowObj = this.attachShadow({mode: 'open'});
}
connectedCallback() {
this.imageProps = RandomEmails();
this.render();
this.setStyleByRequired();
}
render() {
let rows = this.imageProps.map((data,index) => {
return this.getTemplate(data);
});
this.shadowObj.innerHTML = `<div class="avatars-holder">${rows.join('')}</div>`;
//how to call after completion of this?
}
getTemplate(data) {
return(
`
<avatar-block link="${data.link}" email="${data.email}"></avatar-block>
${this.getStyle()}
`
)
}
setStyleByRequired() {
console.log('set now', document.querySelector('.avatars-holder')) //getting null
}
getStyle() {
return(
`
<style>
.avatars-holder {
display:flex;
flex-direction:row;
flex-wrap: wrap;
overflow:auto;
height:100%;
}
</style>
`
)
}
}
customElements.define('avatar-container', AvatarContainer);

connectedCallback does not guarantee the element (and thus, its children) has been parsed. If you need guaranteed child access, add your webcomponent bundle like this:
<script src="/path/to/bundle.js" defer></script>
defer makes sure your bundle is not executed before DOMContentLoaded occurs, and delays the end of that event until after the bundle has been executed (all of the synchronous code). This forces the upgrade process to be applied to your webcomponents, at a point in time when the browser guarantees that alll the elements in the DOM have been parsed.
Alternatively, use HTMLParsedElement (which I helped create), which addresses specifically this problem.
https://github.com/WebReflection/html-parsed-element
As a sidenote:
this.shadowObj = this.attachShadow({mode: 'open'});
is unnecessary,
this.attachShadow({mode: 'open'});
is sufficient and the shadow root is accessible in
this.shadowRoot
automatically.

it works fine for me:
const avatarHolder = this.shadowObj.querySelector('.avatars-holder');
thanks.

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.

How to fill the slots when inheriting from a web component?

Let's say I have a dialog component like
class ModalDialog extends HTMLElement {
constructor(){
super()
this._shadow = this.attachShadow({mode: 'closed'})
}
connectedCallback(){
const template = `
<style>
... lots of style that doesn't matter to this question ...
</style>
<div class="dialog">
<div class="dialog-content">
<div class="dialog-header">
<slot name="header"></slot>
<span class="close">×</span>
</div>
<div class="dialog-body"><slot name="body"></slot></div>
<div class="dialog-footer"><slot name="footer"></slot></div>
</div>
</div>
`
this._shadow.innerHTML = template
this._shadow.querySelector('.close').onclick = () => this.hide()
const dialog = this._shadow.querySelector('.dialog')
dialog.onclick = evt => {
if(evt.target == dialog){ //only if clicking on the overlay
this.hide()
}
}
this.hide()
}
show() {
this.style.display = 'block'
}
hide(){
this.style.display = 'none'
}
}
window.customElements.define('modal-dialog', ModalDialog)
Now let's say I want to create dedicated dialogs ... e.g. one that allows a user to pick an image.
I could do it like this
import {} from './modal-dialog.js'
class ImageSelector extends HTMLElement {
constructor(){
super()
this._shadow = this.attachShadow({mode: 'closed'})
}
connectedCallback(){
const template = `
<style>
... more style that doesn't matter ...
</style>
<modal-dialog>
<div slot="header"><h3>Select Image</h3></div>
<div slot="body">
... pretend there's some fancy image selection stuff here ...
</div>
</modal-dialog>
`
this._shadow.innerHTML = template
}
show(){
this._shadow.querySelector('modal-dialog').show()
}
hide(){
this._shadow.querySelector('modal-dialog').hide()
}
}
window.customElements.define('image-selector', ImageSelector)
but I don't like the show and hide methods, there.
Another option would be to inherit from the dialog rather than from HTMLElement...
import {} from './modal-dialog.js'
class ImageSelector extends customElements.get('modal-dialog'){
constructor(){
super()
}
connectedCallback(){
... now what? ...
}
}
window.customElements.define('image-selector', ImageSelector)
but if I do that, how do I actually fill the slots?
Naive approach would of course be to just use _shadow and put it into the slots' inner html, but I have a feeling that's not the way to go.
TLDR; It is impossible to use both inheritance and composition at same time.
Long answer:
You are actually mixing two distinct but alternative concepts:
Inheritance - Use when overriding/overloading the existing behavior
Composition - Use when using the behavior of some other entity and add some more behavior around it.
You can substitute one for another and in general, in Web UI programming, Composition is always preferred over the Inheritance for the loose coupling it provides.
In you case, you actually want to use the template and not actually override it. So, composition is a better choice here. But that also means that you will actually have to write some more boilerplate code i.e. wrapper implementations of show and hide method.
In theory, inheritance was invented to promote code re-use and avoid repetitive code but that comes as a cost as compared to composition.
Good info from Harshals' answer; here is the userland code
Unless you are doing SSR, the very first file read is the HTML file.
So put the Template content there and let one generic Modal Web Component read the templates
<template id="MODAL-DIALOG">
<style>
:host { display: block; background: lightgreen; padding:.5em }
[choice]{ cursor: pointer }
</style>
<slot></slot>
<button choice="yes">Yes</button>
<button choice="no" >No</button>
</template>
<template id="MODAL-DIALOG-IMAGES">
<style>
:host { background: lightblue; } /* overrule base template CSS */
img { height: 60px; }
button { display: none; } /* hide stuff from base template */
</style>
<h3>Select the Framework that is not Web Components friendly</h3>
<img choice="React" src="https://image.pngaaa.com/896/2507896-middle.png">
<img choice="Vue" src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Vue.js_Logo_2.svg/1184px-Vue.js_Logo_2.svg.png">
<img choice="Svelte" src="https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Svelte_Logo.svg/1200px-Svelte_Logo.svg.png">
<slot><!-- remove slot from BaseTemplate, then this slot works --></slot>
</template>
<modal-dialog>Standard Modal</modal-dialog>
<modal-dialog template="-IMAGES">Images Modal</modal-dialog>
<script>
document.addEventListener("modal-dialog", (evt) => {
alert(`You selected: ${evt.detail.getAttribute("choice")}`)
});
customElements.define('modal-dialog', class extends HTMLElement {
constructor() {
let template = (id="") => {// if docs say "use super() first" then docs are wrong
let templ = document.getElementById(this.nodeName + id);
if (templ) return templ.content.cloneNode(true);
else return []; // empty content for .append
}
super().attachShadow({mode:"open"})
.append( template(),
template( this.getAttribute("template") ));
this.onclick = (evt) => {
let choice = evt.composedPath()[0].hasAttribute("choice");
if (choice)
this.dispatchEvent(
new CustomEvent("modal-dialog", {
bubbles: true,
composed: true,
detail: evt.composedPath()[0]
})
);
// else this.remove();
}
}
connectedCallback() {}
});
</script>
Notes
Ofcourse you can wrap the <TEMPLATES> in a JS String
You can't add <script> in a Template
well, you can.. but it runs in Global Scope, not Component Scope
For more complex Dialogs and <SLOT> you will probably have to remove the unwanted slots from a Basetemplate with code
Don't make it too complex:
Good Components do a handful of things very good.
Bad Components try to do everything... create another Component

Create WebComponent through createElement

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

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