How to select sub-classed Custom Elements with CSS - javascript

Consider the following:
class MyElem extends HTMLElement {};
customElements.define('my-element', MyElem);
class MyMoreSpecificElem extends MyElem {};
customElements.define('my-more-specific-element', MyMoreSpecificElem);
In the parlance of object inheritance, instances of the second class have an 'is-a' relationship with the first: a MyMoreSpecificElem is a MyElem.
This relationship is captured in JavaScript:
let subclassInstance = document.querySelector('my-more-specific-element');
let parentClass = document.querySelector('my-element').constructor;
subclassInstance instanceof parentClass; // true
But I can't think of any way to select all MyElems (including the subclass MyMoreSpecificElem) using CSS selectors. A tag selector would only get the superclass or subclass, and all of the relationship description selectors I'm aware of (e.g. ~, >) are about position in the document, not class hierarchy.
I mean, sure, I can add a CSS class in the constructor and select by that, the call to super would ensure that even subclass instances could be selected that way. But that's gnarly. Is there a way to do this in pure CSS?

Why not have the base component class add either a className value or an attribute that will be set for both the base component class and all of the sub-component classes. Then your CSS can be set based on this className value or attribute.
class MyBaseEl extends HTMLElement {
connectedCallback() {
this.classList.add('my-base-el');
this.innerHTML = '<div>Base El</div>';
}
}
customElements.define('my-base-el', MyBaseEl);
class MySubEl extends MyBaseEl {
connectedCallback() {
super.connectedCallback();
this.innerHTML = '<div>Sub El</div>';
}
}
customElements.define('my-sub-el', MySubEl);
.my-base-el {
background-color: #FF0;
display: block;
outline: 1px dashed black;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.22/webcomponents-lite.js"></script>
<my-base-el></my-base-el>
<my-sub-el></my-sub-el>
This uses a className value to allow the CSS to get at all of the elements that are MyBaseEl or a subclass.
Or like this:
class MyBaseEl extends HTMLElement {
connectedCallback() {
this.setAttribute('my-base-el', '');
this.innerHTML = '<div>Base El</div>';
}
}
customElements.define('my-base-el', MyBaseEl);
class MySubEl extends MyBaseEl {
connectedCallback() {
super.connectedCallback();
this.innerHTML = '<div>Sub El</div>';
}
}
customElements.define('my-sub-el', MySubEl);
[my-base-el] {
background-color: #FF0;
display: block;
outline: 1px dashed black;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.22/webcomponents-lite.js"></script>
<my-base-el></my-base-el>
<my-sub-el></my-sub-el>
This uses an attribute to allow the CSS to get at all of the elements that are MyBaseEl or a subclass.
UPDATE
The only other way of setting CSS for multiple element tags is to know what all of them are and make the css work for all of them like this:
my-base-el, my-sub-el, my-other-sub-el, the-third-sub-el {
background: #000;
color: #fff;
}
But, if someone else can create additional sub-classes then you would need to add to the list.
UPDATE 2
Why not just set the :host in the base class and have it use a CSS variable for the styles you want the user to be able to change. Then everything will inherit that :host css and display like you want.

Related

Combine :host() with :has() - not possible?

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...

shadow dom not attaching on class that inherit from HTMLButtonElement

I am trying to attach shadow dom to this but it does not attach and gives a error DOMExpection operation is not supported but when inherit from HTMLElement or HTMLSpanElement it works!
So question is can i attach shadow dom to this when inherit from HTMLButtonElement if yes then how else not why!
here is my code :-
class Home_Button extends HTMLButtonElement {
constructor() {
super();
this._dom = this.attachShadow({ mode: "open" });
this.createButton();
}
createButton() {
this.classList.add("contents");
const style = document.createElement("style");
style.textContent = `
.sentToHome{
margin: 1%;
padding: 1%;
border-radius: 5px;
border: 1px solid black;
}
.homeLink{
text-decoration: none;
color: green;
}
`;
const anchor = document.createElement("a");
anchor.innerText = "<< Home";
anchor.setAttribute("href", "/");
anchor.setAttribute("class", "homeLink");
const button = document.createElement("button");
button.setAttribute("class", "sentToHome");
button.appendChild(anchor);
this._dom.append(style, button);
}
}
customElements.define("simple-btn", Home_Button, { extends: "button" });
const sentToHomeBtn = new Home_Button();
document.body.appendChild(sentToHomeBtn);
This will never work in Safari, because Apple has, since 2016, stated they will never implement Customized Built-In Elements, they have only implemented extends HTMLElement
Trigger createButton from the connectedCallback; you can't do DOM operations in the constructor (besides shadowDOM content) because the element (this) doesn't exist in the DOM yet.
Also note this._dom isn't required, you get this.shadowRoot for free from attachShadow()
To be clear this.classList is the problem
Well, #danny-365csi-engelman is also right but
I found this answer.
According to the answer we can attach shadow dom on few elements and button does not exits in those elements.
I just inherit from HTMLElement class instead of HTMLButtonElement class.
After that everything works fine!.
If you want to check current source code go here.

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.

Approaches for Styling Individual Letters in a Web Component

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.

How can I style elements used to construct custom elements?

class MyElement extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({mode: 'open'})
shadow.appendChild(document.createTextNode('Yadda blah'))
const span = document.createElement('span')
span.appendChild(document.createTextNode('Can I style U?'))
shadow.appendChild(span)
}
}
customElements.define('my-element', MyElement)
my-element {
border: 1px solid black;
}
span {
font-weight: bold;
}
<my-element></my-element>
As you can see, my-element is styled, however, the span used within my-element is not. Not even saying my-element span {font-weight: bold;} in the stylesheet makes any styles effective.
Is there any way to apply styles to this span?
That is the expected behavior of shadow dom. Since your HTML looks like <my-element></my-element>, you can target my-element from your css, but the span is not part of the actual dom, so you can't target it.
You can only apply styles to the span from within the shadow dom context.
There are better ways of defining css in this case, but just to make a point, the following apply styles to the span, but not to my-element:
class MyElement extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({mode: 'open'})
shadow.appendChild(document.createTextNode('Yadda blah'))
const span = document.createElement('span')
span.appendChild(document.createTextNode('Can I style U?'))
shadow.appendChild(span)
const styleTag = document.createElement('style')
styleTag.innerHTML = `
my-element {
border: 1px solid black;
}
span {
font-weight: bold;
}
`
shadow.appendChild(styleTag)
}
}
customElements.define('my-element', MyElement)
<my-element></my-element>
You have to apply the style to the shadow DOM. A common but soon-to-be-outdated technique is to add a <style> element to the shadow DOM and set its text contents as the CSS you want to apply within the custom element.
The snippet below does just that. The style is saved to a variable, but you can place it anywhere as long you're conscious of how often its context is run. Following Intervalia's advice, it's best to apply a style to the custom element using :host.
It's also possible to add a <link> element instead, but that may cause the element to pop-in without a style (FOUC)
const myElementStyle = `
:host {
border: 1px solid black;
}
span {
font-weight: bold;
}
`
class MyElement extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({mode: 'open'});
const style = document.createElement('style');
style.textContent = myElementStyle;
shadow.appendChild(style);
shadow.appendChild(document.createTextNode('Yadda blah'));
const span = document.createElement('span');
span.appendChild(document.createTextNode('Can I style U?'));
shadow.appendChild(span);
}
}
customElements.define('my-element', MyElement)
<my-element></my-element>
Another option is to use Constructable Stylesheets, which should soon be available in modern browsers. These let you create a stylesheet object that can embed styles or import styles from external resources.
I wrote up an answer here, so I'll just post a snippet that answers your question:
const customSheet = new CSSStyleSheet();
customSheet.replaceSync(`
:host {
border: 1px solid black;
}
span {
font-weight: bold;
}
`);
class MyElement extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({
mode: 'open'
})
shadow.adoptedStyleSheets = [customSheet];
shadow.appendChild(document.createTextNode('Yadda blah'))
const span = document.createElement('span')
span.appendChild(document.createTextNode('Can I style U?'))
shadow.appendChild(span)
}
}
customElements.define('my-element', MyElement)
<my-element></my-element>

Categories

Resources