I have a widget which renders a bar chart(HTML/CSS/JavaScript), after point to a particular datasource/API. There is a configuration parameter to change the datasource/API, So I can have multiple instances of the same widget.
Is it possible to do above with the use of shadow-dom? I tried following but noticed widget is unable to select the correct div-element to do the rendering part.
var host = document.querySelector("#" + gadgetContainer.getAttribute("data-grid-id"));
var root = host.createShadowRoot();
var template = document.createElement('template');
template.innerHTML = gadgetContainer.outerHTML;
root.appendChild(document.importNode(template.content, true));
Following is the JavaScript logic which do the widget rendering, I have removed dataset and the config for clarity.
(function () {
"use strict";
//dataset used to plot charts
var dataTable = {}, //REMOVED
width = document.querySelector('*::shadow #bar').offsetWidth,
height = 270, //canvas height
config = {}; //REMOVED
widget.renderer.setWidgetName("bar-chart", "BAR CHART");
chartLib.plot("*::shadow #bar",config, dataTable);
}());
Following is the simple HTML div, all required stylesheets and scripts are there in this page.
<div id="bar" class="bar"></div>
Here is a minimal example of a reuse of a template to create 2 instances of the same widget with Shadow DOM:
var template = document.querySelector( 'template' )
target1.attachShadow( { mode: 'open' } )
.appendChild( template.content.cloneNode( true ) )
target2.attachShadow( { mode: 'open' } )
.appendChild( template.content.cloneNode( true ) )
<template>
<style>
:host { display: inline-block; width:100px; height:50px ; border: 1px solid gray; }
header { color: blue; font-size:15px; font-weight: bold; }
section { color: gray; font-size:12px ; }
</style>
<header>
Widget <slot name="number"></slot>
</header>
<section>
Content
</section>
</template>
<div id="target1"><span slot="number">1</span></div>
<div id="target2"><span slot="number">2</span></div>
Note that you should use Shadow DOM "v1", with attachShadow() instead of createShadowRoot() as it's the standard specification that will be implemented on browsers other that Chrome. The old version will be deprecated in the future.
Use <slot> to get the content of the light DOM and insert it in the Shadow DOM.
Related
I have a web component with a shadow DOM and a default slot.
I need to apply certain styling based on the presence or absence of specific a light DOM descendant. Please note that I don't need a specific workaround for this specific styling, it's just an example and in the real world the example is alot more complex.
I also cannot work with regular DOM CSS like x-y:has(div) since I need to apply styles to an element in the shadow DOM based on the presence of the div in the light DOM.
Please note that the code snippet only works in browsers that support constructable stylesheets (e.g. Safari won't).
const styleStr = `
:host {
display: block;
border: 3px dotted red;
}
:host(:has(div)) {
border-color: green;
}
`;
let css;
try {
css = new CSSStyleSheet;
css.replaceSync(styleStr);
} catch(e) { console.error(e) }
customElements.define('x-y', class extends HTMLElement {
constructor() {
super().attachShadow({mode: 'open'}).adoptedStyleSheets.push(css);
this.shadowRoot.append(document.createElement('slot'))
}
})
<x-y>no div - should have red border</x-y>
<x-y>
<div>div, should have green border</div>
</x-y>
I was trying to find if maybe :host() is not accepting :has(), but was unable to find anything on it, neither in the spec, nor on MDN or caniuse.
Does anyone have definitive knowledge/reference about this, and can point me to some documentation?
You want to style slotted content based on an element inside the slot
Since <slot> are reflected, (deep dive: ::slotted CSS selector for nested children in shadowDOM slot)
you need to style a <slot> in its container element.
If you want that logic to be done from inside the Component,
you could do it from the slotchange Event, which checks if a slotted element contains that DIV
Then creates a <style> element in the container element
Disclaimer: Provided code is a Proof of Concept, not production ready
<my-component>
Hello Web Component
</my-component>
<!-- <my-component> will add a STYLE element here -->
<my-component>
<!-- <my-component> will assign a unique ID to the DIV -->
<div>Web Component with a DIV in the slot</div>
</my-component>
<script>
customElements.define("my-component", class extends HTMLElement {
constructor() {
super().attachShadow({mode: "open"}).innerHTML = `<slot/>`;
let slot = this.shadowRoot.querySelector("slot");
slot.addEventListener("slotchange", (evt) => {
[...slot.assignedNodes()].forEach(el => {
if (el.nodeName == "DIV") {
el.id = "unique" + new Date() / 1;
// inject a <style> before! <my-component>
this.before( Object.assign( document.createElement("STYLE"), {
innerHTML : `#${el.id} { background:lightgreen } `
}));
}
});
});
}
})
</script>
PS. Don't dynamically add any content inside <my-component>, because that slotchange will fire again...
I tried to ask this question earlier tonight but deleted the question because I wasn't doing a good job of it. I learned a bit more through experimenting further to ask what I hope better explains it. I'm asking this question because I want to understand how memory is consumed and released when repeatedly fetching new resources to a shadow-DOM element relative to using an iframe.
I'm having some trouble understanding what is really taking place in the shadow DOM regarding scripts. From reading CSS Tricks article on templates and the shadow DOM and the MDN document on Using the Shadow DOM, and experimenting with this, it appears that the CSS is encapsulated sort of automatically. I cannot tell if the CSS styles are removed from memory when the node containing the shadow element is removed from the DOM. Perhaps all that takes place there is similar to placing shadowRoot at the front of every CSS selector, such that the styles remain in memory after the shadowRoot is removed or content replaced.
The JS doesn't appear to be encapsulated in the sense that it exists only in a module for a particular segment of the DOM. I'm just a hack and don't know much about classes but it appears that all that is done is the references upon which the JS functions operate are selected from the shadowRoot rather than the document level; and if the shadow root was removed from the document the script would still be in memory. If the script is included in a template element, once the template is imported and appended, the script references apply to the document. But through the examples using a class structure, the references are changed to the shadowRoot. We could do that without a shadow DOM and just execute shadow_element.querySelectorAll() rather than document.querySelectorAll(). Could we not?
In other words, it appears that the script is on the window element but acts upon a subset of the DOM identified by the shadowRoot, such that if the shadowRoot element is removed, the script remains. Thus, although it is claimed that the shadow DOM encapsulates CSS and JS like an iframe, if the content in the shadowRoot is changed several times via fetching new resources, will the CSS and JS "grow" in memory rather than being garbage collected such as when the contents of an iframe are replaced?
If instead the script was set up based on "this" and invoked through a function.call(this), would it result in the script being cleared from memory when the shadowRoot is deleted?
tabs[i] = new tab(...) {}
tabs[i].shadow = DOM_element.attachShadow({mode: 'open'});
/* fetch the HTML, CSS, JS from the server. */
/* all references in JS are to this.shadow. */
/* such as:
function init() {
console.log("shadow script");
this.shadow.querySelectorAll("DIV").forEach( v => v.addEventListener( "mousedown", (evt) => {
evt.stopPropagation();
let e = evt.target;
if ( e.matches( "p") ) {
e.classList.toggle("plain");
}
}, false ));
this.log = function(msg) { console.log(msg); };
};
tabs[i].shadow.appendChild(styles);
tabs[i].shadow.appendChild(html);
tabs[i].shadow.appendChild(script);
init.call(tabs[i]);
tabs[i].log("Successfully injected script.");
delete tabs[i];
The following snippet applies the script in this manner and it appears to work the same as when through a class. Just click on the light and shadow paragraphs. I realize that the class structure is used to make a new custom element but that's not my objective. I just want to repeatedly fetch new resources that have some specific styling and some specific interaction. I think the common styling can be achieved through a link and caching the style sheet of common styles.
All the snippet shows is that the light and shadow DOM have separate styles and run separate JS. It uses the object method described above. If don't use the object then still have to use the shadowRoot rather than document with querySelectorAll. Appending the script to the shadowRoot, such as if fetched a generic document, won't automatically apply reference to the shadowRoot only as appears to take place for CSS.
sc.textContent = `function init() { console.log("shadow script");
tab.sh.querySelectorAll("DIV").forEach( v => v.addEventListener( "mousedown", (evt) => {
evt.stopPropagation();
console.log( "shadow" );
let e = evt.target;
if ( e.matches( "p") ) {
e.classList.toggle("plain");
}
}, false ));
this.log = function(msg) { console.log(msg); };
}`;
var d_light = document.querySelectorAll(".light");
d_light.forEach( v => v.addEventListener( "mousedown", (evt) => {
evt.stopPropagation(); //console.log("light");
let e = evt.target;
if ( e.matches( "p") ) {
e.classList.toggle("plain");
}
}, false )
);
var tab = new Object();
var el_t1 = document.importNode(document.getElementById('t1').content, true)
var el_sh = document.querySelector("div.shadow");
tab.sh = el_sh.attachShadow({mode: 'open'});
var st = document.createElement("STYLE");
var sc = document.createElement("SCRIPT");
st.textContent = `p {
background-color: rgb(73,110,147);
color: white;
padding: 5px 10px;
}
p.plain {
background-color: white;
color: blue;
}
blockquote {
border-left: 5px solid green;
padding-left: 20px;
}`;
sc.textContent = `function init() { //console.log("shadow script");
this.sh.querySelectorAll("DIV").forEach( v => v.addEventListener( "mousedown", (evt) => {
evt.stopPropagation();
//console.log( "shadow" );
let e = evt.target;
if ( e.matches( "p") ) {
e.classList.toggle("plain");
}
}, false ));
this.log = function(msg) { console.log(msg); };
}`;
tab.sh.appendChild(st);
tab.sh.appendChild(el_t1);
tab.sh.appendChild(sc);
init.call(tab);
//tab.log("hello");
//delete tab;
div {
border: 1px solid black;
margin-bottom: 20px;
}
blockquote {
border-left: 5px solid blue;
padding-left: 10px;
}
p {
padding: 5px 10px;
}
p.plain {
background-color: rgb(200,200,200);
color: purple;
}
<div class="light">
<h3>Light</h3>
<p>Opening Paragraph</p>
<blockquote>Hear this!</blockquote>
<p>Closing Paragraph</p>
</div>
<div class="light">
<h3>Light</h3>
<p>Opening Paragraph</p>
<blockquote>Did you hear that!</blockquote>
<p>Closing Paragraph</p>
</div>
<div class="shadow">
</div>
<template id="t1">
<div>
<h3>Shadow</h3>
<p>Opening Paragraph</p>
<blockquote>Heard from the Shadow?</blockquote>
<p>Closing Paragraph</p>
</div>
</template>
Is there a native API which can update the CSS variables scoped under a particular CSS class(or even any other complex CSS selector)predefined in a stylesheet? The question can be generalized for not just CSS variables but other CSS properties as well, i.e whether class specific CSS properties can be updated without targeting a specific HTML element, but by targeting the class definition itself.
Please find below the code snippets which demonstrates an example scenario. You can also find comments in the code to as to what I believe is happening/I am doing on specific lines.
var toggle = true;
function changeColor() {
document.documentElement.style.setProperty('--bg-color', toggle ? 'green' : 'red');
// this works for the "outer" div since there we receive global value(value defined in :root) of --bg-color
toggle = !toggle;
// here I want to also change the scoped value of --bg-color for "inner-primary" and "inner-secondary"
// currently I can do this by doing:
document.querySelectorAll('.inner-primary').forEach(ele => ele.style.setProperty('--bg-color', toggle ? 'blue' : 'yellow'))
document.querySelectorAll('.inner-secondary').forEach(ele => ele.style.setProperty('--bg-color', toggle ? 'yellow' : 'blue' ))
// another way I can see is: we dynamically insert a style tag, but this feels very awkward and can quickly get out of hand on multiple iterations
}
:root {
--bg-color: red;
}
body {
margin: 0;
}
.outer {
width: 100vw;
height: auto;
min-height: 100vh;
text-align: center;
background-color: var(--bg-color); /* receives value from :root */
}
.inner-primary,
.inner-secondary {
width: 100px;
height: 100px;
/* received scoped value from .inner-primary or .inner-secondary defined below*/
background-color: var(--bg-color);
border: 1px solid black;
margin: auto;
margin-bottom: 10px;
}
.inner-secondary {
--bg-color: yellow;
}
.inner-primary {
--bg-color: blue;
}
<!DOCTYPE html>
<html lang="en">
<head>
<title>Testing</title>
</head>
<body>
<div class="outer">
<div class="inner-primary"></div>
<div class="inner-secondary"></div>
<div class="inner-primary"></div>
<div class="inner-secondary"></div>
<button onclick="changeColor()">Change Color</button>
</body>
</html>
Please try running this to get a full idea of intended effect. You can click "Change Color" button at the bottom to see the effects in action.
To get the intended overriding for CSS variable --bg-color for classes inner-primary and inner-secondary, I had to use querySelectorAll with the required CSS selector(in this case just a class name) and iteratively set the CSS variable for each individual element found.
By nature of how CSS gets read by the browser, feels like the other solution to this is to dynamically insert a style element tag into the DOM, with the required CSS variable update, scoped under the required class name(or any other required selector)..
But this feels awkward and can quickly get out of hand if we don't implement some system to reuse the same style tag and not insert new ones during each toggle.
Is there any other way to do this? Any native API which can solve this without having to access individual elements or without inserting style tags dynamically..?
As suggested by A Haworth and referring Change CSS of class in Javascript? I was able update changeColor function to use CSSStyleSheet(MDN link) instead. Please find the updated function below, which uses this API:
var toggle = true;
function changeColor() {
document.documentElement.style.setProperty('--bg-color', toggle ? 'green' : 'red');
// solution using document stylesheets
const styleSheet = document.styleSheets[0]
const cssRules = Array.from(styleSheet.cssRules);
const primaryClassIndex = cssRules.findIndex(cssRule => cssRule.selectorText === '.inner-primary');
const secondaryClassIndex = cssRules.findIndex(cssRule => cssRule.selectorText === '.inner-secondary');
//update primary:
styleSheet.deleteRule(primaryClassIndex);
styleSheet.insertRule(`.inner-primary {--bg-color: ${toggle ? 'yellow' : 'blue'};}`, primaryClassIndex)
//update secondary:
styleSheet.deleteRule(secondaryClassIndex);
styleSheet.insertRule(`.inner-secondary {--bg-color: ${toggle ? 'blue' : 'yellow'};}`, secondaryClassIndex)
//toggle
toggle = !toggle;
}
This is still some concern here since it seems like we can only overwrite the entire cssRule(which may also include other CSS properties) for a particular selector, and not just one required property. But this may arguably be better than updating each individual element style or inserting style tags as mentioned in the question.
Can check the full working codepen at => https://codepen.io/yadus/pen/mdWZmXX
I'm taking my first steps into web components without using any third-party libraries, such as Polymer. One of the main selling points is that web component styles are separated from styles defined elsewhere, allowing the component's shadow-DOM to be styled in a sandbox-like environment.
The issue I'm running into is how styles cascade through slotted elements. Since slotted elements are not part of the shadow DOM, they can only be targed with the ::slotted() selector within the component template. This is great, but it makes it almost impossible to guarantee a web component will display correctly in all contexts, since externally-defined styles also apply with undefeatable specificity* to slotted elements.
*besides !important.
This issue can be distilled down to this:
customElements.define("my-nav",
class extends HTMLElement {
constructor() {
super();
const template = document.querySelector("template#my-nav").content;
this.attachShadow({ mode: "open" })
.appendChild(template.cloneNode(true));
}
}
);
a {
color: red; /* >:( */
}
<template id="my-nav">
<style>
.links-container ::slotted(a) {
color: lime;
font-weight: bold;
margin-right: 20px;
}
</style>
<div class="links-container">
<slot name="links"></slot>
</div>
</template>
<p>I want these links to be green:</p>
<my-nav>
Link 1
Link 2
Link 3
</my-nav>
I'm having a hard time understanding the value of this "feature". I either have to specify my links in some other format and create their nodes with JS, or add !important to my color property - which still doesn't guarantee consistency when it comes to literally any other property I haven't defined.
Has this issue been addressed somewhere, or is this easily solved by changing my light DOM structure? I am not sure how else to get a list of links into a slot.
The <slot> is intentionally designed to allow the outer code to style the content placed into it. This is a great feature when used correctly.
But if you want better control of what shows in the web component then you need to copy cloned copies of the content from this.childNodes into the shadow DOM. Then you have 100% control over the CSS.
OK. You really only have 90% control because the person using your component can still set the style attribute.
customElements.define("my-nav",
class extends HTMLElement {
constructor() {
super();
const template = document.querySelector("template#my-nav").content;
this.attachShadow({ mode: "open" })
.appendChild(template.cloneNode(true));
}
connectedCallback() {
var container = this.shadowRoot.querySelector('.links-container');
var children = this.childNodes;
if (children.length > 0 && container) {
while(container.firstChild) {
container.removeChild(container.firstChild);
}
for (var i = 0; i < children.length; i++) {
container.appendChild(children[i].cloneNode(true));
}
}
}
}
);
a {
color: red;
}
<template id="my-nav">
<style>
.links-container a {
color: lime;
font-weight: bold;
margin-right: 20px;
}
</style>
<div class="links-container">
</div>
</template>
<p>I want these links to be green:</p>
<my-nav>
Link 1
Link 2
Link 3
</my-nav>
As you can see in the example above the third link is still red because we set the style attribute.
If you want to prevent that from happening then you would need to strip the style attribute from the inner content.
customElements.define("my-nav",
class extends HTMLElement {
constructor() {
super();
const template = document.querySelector("template#my-nav").content;
this.attachShadow({ mode: "open" })
.appendChild(template.cloneNode(true));
}
connectedCallback() {
var container = this.shadowRoot.querySelector('.links-container');
var children = this.childNodes;
if (children.length > 0 && container) {
while(container.firstChild) {
container.removeChild(container.firstChild);
}
for (var i = 0; i < children.length; i++) {
container.appendChild(children[i].cloneNode(true));
}
container.querySelectorAll('[style]').forEach(el => el.removeAttribute('style'));
}
}
}
);
a {
color: red;
}
<template id="my-nav">
<style>
.links-container a {
color: lime;
font-weight: bold;
margin-right: 20px;
}
</style>
<div class="links-container">
</div>
</template>
<p>I want these links to be green:</p>
<my-nav>
Link 1
Link 2
Link 3
</my-nav>
I have even created some components that allow unique children that I read in and convert into custom internal nodes.
Think of the <video> tag and its <source> children. Those children don't really render anything, they are just a way of holding data that is used to indicate the source location of the video to be played.
The key here is to understand what <slot> is supposed to be used for and only use it that way without trying to force it to do something it was never intended to do.
BONUS POINTS
Since ConnectedCallback is called every time this node in placed into the DOM you have to be careful to remove anything within the shadow DOM each time or you will duplicate the children over and over.
customElements.define("my-nav",
class extends HTMLElement {
constructor() {
super();
const template = document.querySelector("template#my-nav").content;
this.attachShadow({ mode: "open" })
.appendChild(template.cloneNode(true));
}
connectedCallback() {
var container = this.shadowRoot.querySelector('.links-container');
var children = this.childNodes;
if (children.length > 0 && container) {
for (var i = 0; i < children.length; i++) {
container.appendChild(children[i].cloneNode(true));
}
}
}
}
);
function reInsert() {
var el = document.querySelector('my-nav');
var parent = el.parentNode;
el.remove();
parent.appendChild(el);
}
setTimeout(reInsert, 1000);
setTimeout(reInsert, 2000);
a {
color: red;
}
<template id="my-nav">
<style>
.links-container a {
color: lime;
font-weight: bold;
margin-right: 20px;
}
</style>
<div class="links-container">
</div>
</template>
<p>I want these links to be green:</p>
<my-nav>
Link 1
Link 2
Link 3
</my-nav>
So removing the duplicated nodes is important:
customElements.define("my-nav",
class extends HTMLElement {
constructor() {
super();
const template = document.querySelector("template#my-nav").content;
this.attachShadow({ mode: "open" })
.appendChild(template.cloneNode(true));
}
connectedCallback() {
var container = this.shadowRoot.querySelector('.links-container');
var children = this.childNodes;
if (children.length > 0 && container) {
while(container.firstChild) {
container.removeChild(container.firstChild);
}
for (var i = 0; i < children.length; i++) {
container.appendChild(children[i].cloneNode(true));
}
}
}
}
);
function reInsert() {
var el = document.querySelector('my-nav');
var parent = el.parentNode;
el.remove();
parent.appendChild(el);
}
setTimeout(reInsert, 1000);
setTimeout(reInsert, 2000);
a {
color: red;
}
<template id="my-nav">
<style>
.links-container a {
color: lime;
font-weight: bold;
margin-right: 20px;
}
</style>
<div class="links-container">
</div>
</template>
<p>I want these links to be green:</p>
<my-nav>
Link 1
Link 2
Link 3
</my-nav>
You're right, there's no solution other that using !important for every CSS property.
Instead, I would not use <slot> and copy the nodes you need:
customElements.define("my-nav",
class extends HTMLElement {
constructor() {
super();
const template = document.querySelector("template#my-nav").content;
this.attachShadow({ mode: "open" })
.appendChild(template.cloneNode(true));
}
connectedCallback() {
var links = this.querySelectorAll( 'a[slot]' )
var container = this.shadowRoot.querySelector( '.links-container' )
links.forEach( l => container.appendChild( l ) )
}
}
);
a {
color: red; /* >:( */
}
<template id="my-nav">
<style>
.links-container > a {
color: lime;
font-weight: bold;
margin-right: 20px;
}
</style>
<div class="links-container">
</div>
</template>
<p>I want these links to be green:</p>
<my-nav>
Link 1
Link 2
Link 3
</my-nav>
I have a custom element, my-el, which in its DOM template (a shadow root) contains a slot element. In a web page, I use my-el by placing additional elements in between the tags <my-el> </my-el> as shown below.
<my-el>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</my-el>
I intend to measure the divs' dimensions placed in my-el as they are in the distributed tree, as soon as (i.e. the first time) they are rendered on the page. According to the spec:
To assign slotables, for a slot slot with an optional suppress signaling flag (unset unless stated otherwise), run these steps:
Let slotables be the result of finding slotables for slot.
If suppress signaling flag is unset, and slotables and slot’s assigned nodes are not identical, then run signal a slot change for slot.
...
If I have understood correctly, the first distribution of slot slottables does not fire a slotchange event since the suppress signaling flag is set when the slot has not performed an initial distribution, further stated in
Shadow DOM v1: Self-Contained Web Components. This means that I cannot make an initial measurement of the children elements in the callback of slotchange. Further, the following approach does not guarantee that at that point the children will be rendered:
connectedCallback() {
super.connectedCallback();
requestAnimationFrame(() => {
// Measure chilren
});
}
Question
How can I trigger my measurements upon the first time they are rendered after being distributed in the slot in my-el?
You can define a variable in your custom element that will be set the first time your elements are rendered (either in connectedCallback or at slotchange event).
Then check the variable value to decide whether it's a first time rendering.
customElements.define( 'c-e', class extends HTMLElement
{
connectedCallback ()
{
var sh = this.attachShadow( { mode: 'open' } )
sh.appendChild( T1.content.cloneNode( true ) )
sh.querySelector( 'button').onclick = ev =>
this.appendChild( document.createElement( 'div') ).innerHTML = 'new div'
sh.querySelector( 'slot' ).addEventListener( 'slotchange', ev =>
!len && ( len = this.render() ) )
var len = this.render()
}
render ()
{
var count = this.querySelectorAll( 'div' ).length
if ( count )
console.info( '%s elem. first rendered in %s', count, this.id )
return count
}
} )
<c-e id=CE1>
<div>div 1</div>
<div>div 2</div>
</c-e>
<c-e id=CE2>
</c-e>
<template id=T1>
<style>
:host {
display: inline-flex;
flex-direction: column;
width: 150px;
border: 1px solid lightblue;
min-height: 4em;
margin: 5px;
background: #f7f7f7;
}
</style>
<div>
<button>add div</button>
</div>
<slot>
</slot>
</template>