Stenciljs: What is the right way to prepend/append Slot elements? - javascript

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>

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

Is there a way to add custom attributes to custom elements

While messing around with custom elements I wondered if one could use custom attributes within the elements (and possibly within their children too). I know VueJS does something similar with attributes like v-bind, v-for, etc; and I know there's probably a lot more going on under the hood there than I realize. I've tried registering custom elements and attempting to retrieve them like so:
<new-element cool="Awesome!"> </new-element>
class NewElement extends HTMLElement {
constructor() {
super();
this.coolAttr = this.getAttribute("cool");
}
}
customElements.define("new-element", NewElement);
However, when loading the page (in Google Chrome for me) the "custom" attributes disappear, and any attempt at getting them retrieves null. Is there a way to "register" these custom attributes, or do I have to stick with data- attributes?
Attributes become available in the connectedCallback,
they are not available yet in the constructor
Unless the Custom Element is PARSED (in the DOM) BEFORE the Element is defined!!
Also be aware the attributeChangedCallback runs before the connectedCallback
for Observed attributes
Also see: https://andyogo.github.io/custom-element-reactions-diagram/
.as-console-row-code {
font: 12px Arial!important;
background:yellow;
color:darkred;
}
.as-console-row:after{ display:none!important }
<before-element cool="Awesome?">FOO</before-element>
<script>
class NewElement extends HTMLElement {
log( ...args ){
console.log(this.nodeName, `cool:${this.getAttribute("cool")}`,"\t\t\t",...args );
}
static get observedAttributes() {
return ["cool"];
}
constructor() {
const name = "constructor"; // CAN! run code BEFORE super()!
// super() sets AND returns the 'this' scope
super().log(name);
}
connectedCallback() {
this.log("connectedCallback", this.innerHTML || "innerHTML not parsed yet");
// be aware this.innerHTML is only available for PARSED elements
// use setTimeout(()=>{...},0) if you do need this.innerHTML
}
attributeChangedCallback(name, oldValue, newValue) {
this.log(`attributeChangedCallback name:${name}, old:${oldValue}, new:${newValue}`);
}
}
customElements.define("before-element", class extends NewElement {});
customElements.define("after-element", class extends NewElement {});
</script>
<after-element cool="Awesome!!">BAR</after-element>
It can be easily solved by adding the name of the attributes after "data-".
<New-element data-*="Anything you want" />
for example, you can have:
<element data-cool="value">
You can have as many custom attributes as you want.

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

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

Create WebComponent through createElement

I'm having an issue creating a Web Component using createElement. I'm getting this error:
Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children
at appendTodo
class TodoCard extends HTMLElement {
constructor() {
super()
this.innerHTML = `
<li>
<div class="card">
<span class="card-content">${this.getAttribute('content')}</span>
<i class="fa fa-circle-o" aria-hidden="true"></i>
<i class="fa fa-star-o" aria-hidden="true"></i>
</div>
</li>
`
}
}
window.customElements.define('todo-card', TodoCard)
const todoList = document.getElementById('todo-list')
const todoForm = document.getElementById('todo-form')
const todoInput = document.getElementById('todo-input')
function appendTodo(content) {
const todo = document.createElement('todo-card')
todo.setAttribute('content', content)
todoList.appendChild(todo)
}
todoForm.addEventListener('submit', e => {
e.preventDefault()
appendTodo(todoInput.value)
todoInput.value = ''
})
any ideas?
Thanks.
A Custom Element that sets DOM content in the constructor
can never be created with document.createElement()
You will see many examples (including from me) where DOM content is set in the constructor.
Those Elements can never be created with document.createElement
Explanation (HTML DOM API):
When you use:
<todo-card content=FOO></todo-card>
The element (extended from HTMLElement) has all the HTML interfaces (it is in a HTML DOM),
and you can set the innerHTML in the constructor
But, when you do:
document.createElement("todo-card");
The constructor runs, without HTML interfaces (the element may have nothing to do with a DOM),
thus setting innerHTML in the constructor produces the error:
Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children
From https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-conformance:
The element must not gain any attributes or children, as this violates the expectations of consumers who use the createElement or createElementNS methods.
In general, work should be deferred to connectedCallback as much as possible
shadowDOM is a DOM
When using shadowDOM you can set shadowDOM content in the constructor:
constructor(){
super().attachShadow({mode:"open"})
.innerHTML = `...`;
}
Correct code (no shadowDOM): use the connectedCallback:
<todo-card content=FOO></todo-card>
<script>
window.customElements.define(
"todo-card",
class extends HTMLElement {
constructor() {
super();
//this.innerHTML = this.getAttribute("content");
}
connectedCallback() {
this.innerHTML = this.getAttribute("content");
}
}
);
try {
const todo = document.createElement("todo-card");
todo.setAttribute("content", "BAR");
document.body.appendChild(todo);
} catch (e) {
console.error(e);
}
</script>
You have another minor issue: content was a default attribute, and FireFox won't stop warning you:
Or don't use createElement
const todo = document.createElement("todo-card");
todo.setAttribute("content", "BAR");
document.body.appendChild(todo);
can be written as:
const html = `<todo-card content="BAR"></todo-card`;
document.body.insertAdjacentHTML("beforeend" , html);
The connectedCallback can run multiple times!
When you move DOM nodes around:
<div id=DO_Learn>
<b>DO Learn: </b><todo-card todo="Custom Elements API"></todo-card>
</div>
<div id="DONT_Learn">
<b>DON'T Learn!!! </b><todo-card todo="React"></todo-card>
</div>
<script>
window.customElements.define(
"todo-card",
class extends HTMLElement {
connectedCallback() {
let txt = this.getAttribute("todo");
this.append(txt);// and appended again on DOM moves
console.log("qqmp connectedCallback\t", this.parentNode.id, this.innerHTML);
}
disconnectedCallback() {
console.log("disconnectedCallback\t", this.parentNode.id , this.innerHTML);
}
}
);
const LIT = document.createElement("todo-card");
LIT.setAttribute("todo", "Lit");
DO_Learn.append(LIT);
DONT_Learn.append(LIT);
</script>
connectedCallback runs for LIT
when LIT is moved
disconnectedCallback runs (note the parent! The Element is already in the new location)
connectedCallback for LIT runs again, appending "Learn Lit" again
It is up to you the programmer how your component/application must handle this
Web Component Libraries
Libraries like Lit, HyperHTML and Hybrids have extra callbacks implemented that help with all this.
I advice to learn the Custom Elements API first, otherwise you are learning a tool and not the technology.
And a Fool with a Tool, is still a Fool

Is it bad practice to create a web-component inside another and append it to said other?

Essentially I have a web component "x" and I dynamically create a form component inside the "x" which will be appended to "x".
I could just do it in the place I create "x", after creating "x", of course.
Basically this:
class X extends LitElement {
render() {
return html`
<div>
<slot name="form-component">${this.appendFormComponent()}</slot>
</div>
<slot></slot>
`
}
appendFormComponent() {
const formComponent = document.createElement('input')
formComponent.slot = "form-component"
this.append(formComponent)
}
// side note, is running this append inside the render function a terrible
// idea and where should I do it instead? I mean doing it in the render
// function does appear to work...
}
As you suspected, this is definitely a terrible idea because you are mixing imperative paradigm with declarative paradigm. However, if you really need to do this and since you are using LitElement, you can nicely abstract the declarative and imperative UI code using appropriate lifecycle methods:
class X extends LitElement {
render() {
return html`
<div>
<slot name='form-component'></slot>
</div>
<slot></slot>
`;
}
// Executed only once
firstUpdated() {
const formComponent = document.createElement('input');
formComponent.slot = 'form-component';
this.append(formComponent);
}
}
Also, the approach you are attempting is probably problematic. Your problem would be easily solved by render function only:
class X extends LitElement {
render() {
return html`
<div>
<slot name='form-component'>
<!-- Notice the use of INPUT TAG here -->
<input type='text' />
</slot>
</div>
<slot></slot>
`;
}
}
Using something like firstUpdated with document.createElement should be used to create UI components which have offset elements that break the UI as Function of State notion. Such components are date pickers, multi select dropdown, dialog boxes, etc. which directly append DOM elements to the body for managing Z-index and fixed positioning accurately.
Further, as per your comments, if you have a dynamic function which needs to be assigned to the input text, simply create a wrapper function like:
class X extends LitElement {
// Input change event handler
onChange() {
// A guard to check presence of dynamic function
if (this.someDynamicFuction) {
this.someDynamicFuction();
}
}
render() {
return html`
<div>
<slot name='form-component'>
<!-- Notice the use of INPUT TAG here -->
<input type='text' #change=${this.onChange} />
</slot>
</div>
<slot></slot>
`;
}
}

Categories

Resources