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>
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>
I want text parts to appear, and disappear, on click.
Before the first click you can only see the banner, and no verses yet; on the first click the first verse appears, on second click the second verse appears in place of the first, and so on.
I am trying to achieve this with hiding the elements, placing them in an array, and let them display when the number of times the function gets called fits the index of the verse.
I am new to JavaScript, and don't understand the exceptions thrown. If i try to inspect my code online, I get this Exception when O try to call the function by clicking on the website:
Uncaught TypeError: Cannot read properties of null (reading 'style')
This is my code so far:
const text = document.querySelector(".banner")
document.addEventListener('click', myFunction);
const verse1 = document.querySelector(".verse1")
const verse2 = document.querySelector(".verse2")
const verse3 = document.querySelector(".verse3")
const verse4 = document.querySelector(".verse4")
const verse5 = document.querySelector(".verse5")
const verses = [verse1, verse2, verse3, verse4, verse5]
let versesLength = verses.length;
function myFunction() {
for (let i = 0; i < versesLength; i++) {
text.innerHTML = verses[i].style.display = 'block';
}
}
<div class="banner">
<script src="main.js"></script>
<img src="files/SomeLogo.jpg" alt="We are still building on our Website:-)">
</div>
<div id="verses">
<div class="verse1" style="display: none">Lorem Ipsum</div>
<div class="verse2" style="display: none">Lorem Ipsum2</div>
<div class="verse3" style="display: none">Lorem Ipsum3</div>
<div class="verse4" style="display: none">Lorem Ipsum4</div>
<div class="verse5" style="display: none">Lorem Ipsum5</div>
</div>
I am stuck, and clicked through similar questions for the last hours. Thanks in advance for any help
without changing anything in the HTML, you can do something like this in javascript
const text = document.querySelector(".banner")
document.addEventListener('click', myFunction);
let verses = document.querySelector("#verses").children
let count = 0
function myFunction() {
Array.from(verses).forEach(el=> el.style.display="none")
if(count < verses.length){
verses[count].style.display = 'block'
count ++
if(count===verses.length) count =0
}
}
You can remove the need for an array by giving all the verse elements the same class: verse. We can grab them with querySelectorAll.
Add a data attribute to each verse to identify them.
In order to limit the number of global variables we can use a closure - in the addEventListener we call the handleClick function which initialises the count, and then returns a function that will be assigned to the listener. This is a closure. It maintains a copy of its outer lexical environment (ie variables) that it can use when it's returned.
// Cache the elements with the verse class
const banner = document.querySelector('.banner');
const verses = document.querySelectorAll('.verse');
// Call `handleClick` and assign the function it
// returns to the listener
document.addEventListener('click', handleClick());
function handleClick() {
// Initialise `count`
let count = 1;
// Return a function that maintains a
// copy of `count`
return function () {
// If the count is 5 or less
if (count < verses.length + 1) {
// Remove the banner
if (count === 1) banner.remove();
// Remove the previous verse
if (count > 1) {
const selector = `[data-id="${count - 1}"]`;
const verse = document.querySelector(selector);
verse.classList.remove('show');
}
// Get the new verse
const selector = `[data-id="${count}"]`;
const verse = document.querySelector(selector);
// And show it
verse.classList.add('show');
// Increase the count
++count;
}
}
}
.verse { display: none; }
.show { display: block; margin-top: 1em; padding: 0.5em; border: 1px solid #787878; }
[data-id="1"] { background-color: #efefef; }
[data-id="2"] { background-color: #dfdfdf; }
[data-id="3"] { background-color: #cfcfcf; }
[data-id="4"] { background-color: #bfbfbf; }
[data-id="5"] { background-color: #afafaf; }
<div class="banner">
<img src="https://dummyimage.com/400x75/404082/ffffff&text=We+are+still+building+our+website" alt="We are still building on our Website:-)">
</div>
<div>
<div data-id="1" class="verse">Lorem Ipsum 1</div>
<div data-id="2" class="verse">Lorem Ipsum 2</div>
<div data-id="3" class="verse">Lorem Ipsum 3</div>
<div data-id="4" class="verse">Lorem Ipsum 4</div>
<div data-id="5" class="verse">Lorem Ipsum 5</div>
</div>
Additional documentation
Template/string literals
classList
This should make it:
const verses = document.querySelectorAll('.verse');
const banner = document.querySelector('.banner');
const length = verses.length;
let counter = 0;
document.onclick = () => {
if (counter === 0) banner.classList.add('hide');
if (counter >= length) return;
verses[counter].classList.add('show');
if (verses[counter - 1]) verses[counter - 1].classList.remove('show');
counter++;
};
body {
background: orange;
}
.hide {
display: none !important;
}
.show {
display: block !important;
}
<html>
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body>
<div class="banner">
<img
src="files/SomeLogo.jpg"
alt="We are still building on our Website:-)"
/>
</div>
<div id="verses">
<div class="verse1 verse hide">Lorem Ipsum</div>
<div class="verse2 verse hide">Lorem Ipsum2</div>
<div class="verse3 verse hide">Lorem Ipsum3</div>
<div class="verse4 verse hide">Lorem Ipsum4</div>
<div class="verse5 verse hide">Lorem Ipsum5</div>
</div>
<script src="main.js"></script>
</body>
</html>
Fixing the error
The "Cannot read properties of null" error occurs because you try to access the properties of null. Your array holds nulls because you queried for the elements before the browser has inserted them into its DOM.
The browser parses the HTML the same way you would read it: From left to right, and top to bottom.
If the browser encounters a regular <script> element, it halts parsing and first executes the JavaScript. Naturally, some elements may not yet be available in the DOM.
There are multiple ways to defer script execution:
Add attribute defer to <script>: Will execute once the DOM is fully built.
Add attribute type="module" to <script>: Similar to defer, but will also make your code be treated as a JS module. This will also make your code run in strict mode.
Use JS event DOMContentLoaded: Similar to defer, but encapsulated in your JS-file.
Use JS event load: Similar to DOMContentLoaded, but will additionally wait until all resources (e.g. images, videos) have loaded. Prefer DOMContentLoaded if applicable.
Move <script> to the bottom of the HTML: Effectively like defer. Scripts with defer will still load after scripts at the bottom.
The simplest solution would be to use defer, as with it you wouldn't have to change your JS code:
<script src="main.js" defer></script>
By the way: Don't be fooled by the StackOverflow snippets; when using the on-site snippets, the <script> for the code is moved to the bottom of the HTML!
The feature!
Variable lifetimes
Variables in JS only persist for as long as they are used.
Using a variable that is declared outside a function will create a closure around your function and variable. That means, that variable will persist for as long as the function exists that uses it:
let someVariable = 0;
function someFunction() {
// `someVariable` is used, so it will persist across calls!
return someVariable;
}
This means, to have the variable for "keeping track of what verse to show" persist, it has to be declared outside your function.
Show and hide!
By calculating the previous verse's index with the index of the next-to-show verse, we only have to keep one counter. With two counters, they might get out of sync if we don't handle them correctly.
let nextToShow = 0;
function showNextVerse() {
const previousIndex = nextToShow - 1;
// ...
++nextToShow; // Increase counter for next call
}
In our case, the user (or rather, their clicks) will play the role of the loop. They will cause our click handler (the function) to run occasionally, at which point we have to swap the verses.
Swapping the verses can be done in many ways, but we'll stick to your "inline style" way: (Final code)
document.addEventListener("click", showNextVerse);
const banner = document.querySelector(".banner");
const verses = document.getElementById("verses").children; // More on this later
let nextToShow = 0;
function showNextVerse() {
const previousIndex = nextToShow - 1;
// On every call, hide the banner
banner.style.display = "none"; // Use `.style` instead of `.innerHTML` to preserve its HTML!
// Hide previous if valid index
if (previousIndex >= 0 && previousIndex < verses.length) {
verses[previousIndex].style.display = "none";
}
// Show next if valid index
if (nextToShow >= 0 && nextToShow < verses.length) {
verses[nextToShow].style.display = "block";
}
++nextToShow;
}
<div class="banner">
<script src="main.js" defer></script>
<img alt="We are still building on our Website:-)">
</div>
<div id="verses">
<div style="display:none">Lorem Ipsum1</div>
<div style="display:none">Lorem Ipsum2</div>
<div style="display:none">Lorem Ipsum3</div>
<div style="display:none">Lorem Ipsum4</div>
<div style="display:none">Lorem Ipsum5</div>
</div>
Improvements?!
There is no need for the variable versesLength; you can directly replace each of its occurences with verses.length. It doesn't improve on the original name, and is one more potential source for bugs if not synchronized with the original variable.
Correctly use class and id
Currently, your verses use class as if it was id; they each use a different class. This is not wrong, but semantically I would use id for this purpose.
To use the class attribute effectively, you should give each verse the class verse. This way, you can select them more easily via JS (see next section).
Easier getting of elements
As with everything in the coding world, there are many solutions to a problem. You solved getting the elements in a rather tedious way, but there are alternatives: (Non-exhaustive list)
Use document.querySelectorAll().
Rename verses to use same class, and use document.getElementsByClassName().
Use Element.children.
You may have already noticed how I get all the verses. In fact, verses (in the final code) doesn't even reference an array, but an HTMLCollection. It is very similar to an array, with the exception of it updating live to changes:
const elementsWrapper = document.getElementById("elements");
const collection = elementsWrapper.children;
const array = Array.from(elementsWrapper.children);
document.getElementById("bt-add").addEventListener("click", function() {
const newElement = document.createElement("div");
newElement.textContent = "Added later";
elementsWrapper.appendChild(newElement);
});
document.getElementById("bt-log").addEventListener("click", function() {
console.log("collection.length:", collection.length);
console.log("array.length:", array.length);
});
<button id="bt-add">Add new element</button>
<button id="bt-log">Log <code>.length</code></button>
<p>
Try logging first! Then try to add elements, and log again! See the difference?
</p>
<div>Elements:</div>
<div id="elements">
<div>Initially existent</div>
<div>Initially existent</div>
</div>
Alternative way of hiding
Here are ways of hiding the elements:
Use inline styling (this is what you did!).
Use CSS classes.
Use the HTML attribute hidden.
For small style changes I too would use inline styling. But for only hiding elements I would use the hidden attribute.
Also, there are multiple CSS ways of hiding elements:
Using display: none: Will hide the element as if it doesn't exist.
Using opacity: 0: Will hide the element by making it invisible; it still takes up space, and should still be part of the accessibility tree (opinionated).
Moving it off-site with position: fixed and top, left, etc. properties: (Please don't.)
Will move the element off-site, visually. It will still be part of the accessibility tree, and will only work for languages with the intended writing direction (e.g. it won't work for right-to-left languages).
Setting width, height, margin, padding and border to 0: Will hide the element only visually; it will still be part of the accessibility tree, and will stop margin collapse. Screen-reader only classes use this for non-visual elements, very useful.
Let's say I want to create a custom element which bolds every other character. For example, <staggered-bold>Hello</staggered-bold> would become "Hello, where the H, l, and o are all bolded.
There's no nth-letter CSS selector, so as far as I know the only way to achieve this effect is to wrap each individual character with a span programmatically. To do that, I have an implementation that clones the text content into the Shadow Dom, so that the child content as specified by the user is not changed.
Unfortunately, by doing so, something like <staggered-bold><span class="red">red</span></staggered-bold> no longer works, because by cloning the content into the Shadow Dom, the class CSS declarations for the wrapped span no longer apply.
Here's a proof-of-concept implementation, showcasing that the red and blue text are in fact not red and blue:
customElements.define('staggered-bold', class extends HTMLElement {
constructor() {
super()
this
.attachShadow({ mode: 'open' })
.appendChild(document.getElementById('staggered-bold').content.cloneNode(true))
}
connectedCallback() {
// this is a shadow dom element
const text = this.shadowRoot.getElementById('text')
this.shadowRoot.querySelector('slot').assignedNodes().forEach(node => {
const content = node.textContent.split('').map((char) => {
return `<span class="char">${char}</span>`
}).join('')
const newNode = node.nodeType === Node.TEXT_NODE ? document.createElement('span') : node.cloneNode(true)
newNode.innerHTML = content
text.appendChild(newNode)
})
}
})
.red { color: red; }
.blue { color: blue; }
<p><staggered-bold>Some text</staggered-bold></p>
<p><staggered-bold><span class="red">Red</span> <span class="blue">Blue</span></staggered-bold></p>
<template id="staggered-bold">
<style>
.hide { display: none; }
.char:nth-child(odd) {
font-weight: bold;
}
</style>
<span class="hide"><slot></slot></span>
<span id="text"></span>
</template>
My question is this: what is a good approach to styling each character in a custom element while preserving characteristics provided in the light dom?
One approach I've considered is to manipulate the light dom directly, but I have been avoiding that since I think of the light dom as being in full control of the usage-site (ie. things get complicated very quickly if external JS is manipulating the child of staggered-bold). I'm open to being convinced otherwise, especially there's no real alternative.
I've also considered cloning the content into a named slot so that the original text is preserved, and yet the content continues to live in the light dom. However, I feel like this is still kind of icky for the same reason as the previous paragraph.
You can't have the cake and eat it
Global CSS does NOT style shadowDOM (unless you use CSS properties)
Easier to not use shadowDOM at all.
With an extra safeguard: store the state so the element is properly redrawn on DOM moves.
Note: The setTimeout is always required,
because the connectedCallback fires early on the opening tag;
there is no parsed (innerHTML) DOM yet at that time.
So you have to wait for that DOM to be there.
If you do need a TEMPLATE and shadowDOM, dump the whole .innerHTML to the shadowRoot; but Global CSS still won't style it. Or <slot> it.
Do read: ::slotted CSS selector for nested children in shadowDOM slot
If you go with <slot> consider the slotchange Event
but be aware for an endless loop; changing lightDOM will trigger the slotchange Event again
<staggered-bold>Some text</staggered-bold>
<staggered-bold><span class="red">Red</span> <span class="blue">Blue</span></staggered-bold>
<style>
staggered-bold { display: block; font: 21px Arial }
staggered-bold .char:nth-child(even) { color: blue }
staggered-bold .char:nth-child(odd) { color: red; font-weight: bold }
</style>
<script>
customElements.define('staggered-bold', class extends HTMLElement {
connectedCallback() {
setTimeout(() => { // make sure innerHTML is all parsed
if (this.saved) this.innerHTML = this.saved;
else this.saved = this.innerHTML;
this.stagger();
})
}
stagger(node=this) {
if (node.children.length) {
[...node.children].forEach( n => this.stagger(n) )
} else {
node.innerHTML = node.textContent
.split('')
.map(ch => `<span class="char">${ch}</span>`)
.join('');
}
}
})
document.body.append(document.querySelector("staggered-bold"));//move in DOM
</script>
In the end I attempted a strategy I'm calling the mirror node. The idea is the custom element actually creates an adjacent node within which the split characters are placed.
The original node remains exactly as specified by the user, but is hidden from view
The mirror node actually displays the staggered bold text
The below implementation is incomplete, but gets the idea across:
class StaggeredBoldMirror extends HTMLElement {
constructor() {
super()
}
}
customElements.define('staggered-bold', class extends HTMLElement {
constructor() {
super()
this
.attachShadow({ mode: 'open' })
.appendChild(document.getElementById('staggered-bold').content.cloneNode(true))
}
connectedCallback() {
setTimeout(() => {
const mirror = new StaggeredBoldMirror()
mirror.innerHTML = this.divideIntoCharacters()
this.parentNode.insertBefore(mirror, this)
})
}
divideIntoCharacters = (node = this) => {
return [...node.childNodes].map(n => {
if (n.nodeType === Node.TEXT_NODE) {
return n.textContent
.split('')
.map(ch => `<span class="char">${ch}</span>`)
.join('')
} else {
const nn = n.cloneNode(false)
nn.innerHTML = this.divideIntoCharacters(n)
return nn.outerHTML
}
}).join('')
}
})
customElements.define('staggered-bold-mirror', StaggeredBoldMirror)
.red {
color: red;
}
.blue {
color: blue;
}
staggered-bold-mirror .char:nth-child(odd) {
font-weight: bold;
}
<p><staggered-bold>Some text</staggered-bold></p>
<p><staggered-bold><span class="red">Red</span> <span class="blue">Blue</span></staggered-bold></p>
<template id="staggered-bold">
<style>
.hide { display: none; }
</style>
<span class="hide"><slot></slot></span>
</template>
The vanilla component can be outfitted with a slotchange listener in order to rebuild its mirror whenever its inner content changes. The disconnectedCallback method can also ensure that when one node is removed, the other is too.
Of course, there are downsides to this approach, such has potentially having to also mirror events and the fact that it still manipulates the light dom.
Depending on the use case, either this or Danny's answer works.
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.