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...
Related
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.
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>
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'm trying to use Svelte to do some conditional styling and highlighting to equations. While I've been successful at applying a global static style to a class, I cannot figure out how to do this when an event occurs (like one instance of the class is hovered over).
Do I need to create a stored value (i.e. some boolean that gets set to true when a class is hovered over) to use conditional styling? Or can I write a function as in the example below that will target all instances of the class? I'm a bit unclear why targeting a class in styling requires the :global(classname) format.
App.svelte
<script>
// import Component
import Katex from "./Katex.svelte"
// math equations
const math1 = "a\\htmlClass{test}{x}^2+bx+c=0";
const math2 = "x=-\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}";
const math3 = "V=\\frac{1}{3}\\pi r^2 h";
// set up array and index for reactivity and initialize
const mathArray = [math1, math2, math3];
let index = 0;
$: math = mathArray[index];
// changeMath function for button click
function changeMath() {
// increase index
index = (index+1)%3;
}
function hoverByClass(classname,colorover,colorout="transparent")
{
var elms=document.getElementsByClassName(classname);
console.log(elms);
for(var i=0;i<elms.length;i++)
{
elms[i].onmouseover = function()
{
for(var k=0;k<elms.length;k++)
{
elms[k].style.backgroundColor=colorover;
}
};
elms[i].onmouseout = function()
{
for(var k=0;k<elms.length;k++)
{
elms[k].style.backgroundColor=colorout;
}
};
}
}
hoverByClass("test","pink");
</script>
<h1>KaTeX svelte component demo</h1>
<h2>Inline math</h2>
Our math equation: <Katex {math}/> and it is inline.
<h2>Displayed math</h2>
Our math equation: <Katex {math} displayMode/> and it is displayed.
<h2>Reactivity</h2>
<button on:click={changeMath}>
Displaying equation {index}
</button>
<h2>Static math expression within HTML</h2>
<Katex math={"V=\\pi\\textrm{ m}^3"}/>
<style>
:global(.test) {
color: red
}
</style>
Katex.svelte
<script>
import katex from "katex";
export let math;
export let displayMode = false;
const options = {
displayMode: displayMode,
throwOnError: false,
trust: true
}
$: katexString = katex.renderToString(math, options);
</script>
<svelte:head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex#0.12.0/dist/katex.min.css" integrity="sha384-AfEj0r4/OFrOo5t7NnNe46zW/tFgW6x/bCJG8FqQCEo3+Aro6EYUG4+cU+KJWu/X" crossorigin="anonymous">
</svelte:head>
{#html katexString}
If I understand it correctly you have a DOM structure with arbitrary nested elements and you would want to highlight parts of the structure that share the same class.
So you would have a structure like this:
<div>
<p>This is some text <span class="a">highlight</span></p>
<span class="a">Another highlight</span>
<ul>
<li>Some listitem</li>
<li class="a">Some listitem</li>
<li class="b">Some listitem</li>
<li class="b">Some listitem</li>
</ul>
</div>
And if you select an element with class="a" all elements should be highlighted regardles where they are in the document. This arbitrary placement makes using the sibling selector in css not possible.
There is no easy solution to this, but I will give you my attempt:
This is the full code with some explanation
<script>
import { onMount } from 'svelte'
let hash = {}
let wrapper
onMount(() => {
[...wrapper.querySelectorAll('[class]')].forEach(el => {
if (hash[el.className]) return
else hash[el.className] = [...wrapper.querySelectorAll(`[class="${el.className}"]`)]
})
Object.values(hash).forEach(nodes => {
nodes.forEach(node => {
node.addEventListener('mouseover', () => nodes.forEach(n => n.classList.add('hovered')))
node.addEventListener('mouseout', () => nodes.forEach(n => n.classList.remove('hovered')))
})
})
})
</script>
<div bind:this={wrapper}>
<p>
Blablabla <span class="a">AAA</span>
</p>
<span class="a">BBBB</span>
<ul>
<li>BBB</li>
<li class="a b">BBB</li>
<li class="b">BBB</li>
<li class="b">BBB</li>
</ul>
</div>
<style>
div :global(.hovered) {
background-color: red;
}
</style>
The first thing I did was use bind:this to get the wrapping element (in your case you would put this around the {#html katexString}, this will make that the highlight is only applied to this specific subtree.
Doing a querySelector is a complex operation, so we will gather all the related nodes in a sort of hashtable during onMount (this kind of assumes the content will never change, but since it's rendered with #html I believe it's safe to do so).
As you can see in onMount, I am using the wrapper element to restrict the selector to this section of the page, which is a lot faster than checking the entire document and is probably what you want anyway.
I wasn't entirely sure what you want to do, but for simplicity I am just grabbing every descendant that has a class and make a hash section for each class. If you only want certain classes you could write out a bunch of selectors here instead:
hash['selector-1'] = wrapper.querySelectorAll('.selector-1');
hash['selector-2'] = wrapper.querySelectorAll('.selector-2')];
hash['selector-3'] = wrapper.querySelectorAll('.selector-3');
Once this hashtable is created, we can loop over each selector, and attach two event listeners to all of the elements for that selector. One mouseover event that will then again apply a new class to each of it's mates. And a mouseout that removes this class again.
This still means you have to add hovered class. Since the class is not used in the markup it will be removed by Svelte unless you use :global() as you found out yourself. It is indeed not that good to have global classes because you might have unintended effect elsewhere in your code, but you can however scope it as I did in the code above.
The line
div > :global(.hovered) { background-color: red; }
will be processed into
div.svelte-12345 .hovered { background-color: red; }
So the red background will only be applied to .hovered elements that are inside this specific div, without leaking all over the codebase.
Demo on REPL
Here is the same adapted to use your code and to use a document-wide querySelector instead (you could probably still restrict if wanted by having the bind one level higher and pass this node into the component)
Other demo on REPL
Hi I am new to Web Components concept. I wanted to know if we can access a slotted element using shadowRoot.querySelector
I have implemented an input field as slot for setting some values dynamically. And have a class added to it 'column-title'. I am trying to access this element in connectedCallback() like this
var titleInput = this.shadowRoot.querySelector('.column-title')
But it returns null.
Please help.
Going off of #royyko 's response. The better answer here is instead of giving a class or id with the purpose to grab the slot... use the already provided identifier, its name. To do this you would do the following this.shadowRoot.querySelector('slot[name=interior]')
I'm not sure what the issue is, but you should be able to get a slot using querySelector and its class. I've mocked up an example below.
The console will print out references to the slot. If you're attaching a class just for the purpose of finding the slot then it's probably better to set an id (assuming that it's within a shadow root) and find it with this.shadowRoot.getElementById('my-slot-id')
customElements.define('blue-box',
class extends HTMLElement {
constructor() {
super();
var template = document
.getElementById('blue-box')
.content;
const shadowRoot = this.attachShadow({
mode: 'open'
})
.appendChild(template.cloneNode(true));
}
connectedCallback() {
console.log(this.shadowRoot.querySelector('slot.target-slot'));
}
});
<template id="blue-box">
<style>
.blue-border {
border: 2px solid blue;
padding: 5px;
margin: 5px;
}
</style>
<div class='blue-border'>
<slot name='interior' class='target-slot'>NEED INTERIOR</slot>
</div>
</template>
<blue-box>
<span slot='interior'>My interior text</span>
</blue-box>
<blue-box>
<span slot='interior'>
Check Here: <input type='checkbox'>
</span>
</blue-box>