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.
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'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
The content of my Vue app is fetched from Prismic (an API CMS). I have a rich text block, some parts of which are wrapped inside span tags with a specific class. I want to get those span nodes with Vue and add to them an event listener.
With JS, this code would work:
var selectedSpanElements = document.querySelectorAll('.className');
selectedSpanElements[0].style.color = "red"
But when I use this code in Vue, I can see that it works just a fraction of a second before Vue updates the DOM. I've tried using this code on mounted, beforeupdate, updated, ready hooks... Nothing has worked.
Update: Some hours later, I found that with the HTMLSerializer I can add HTML code to the span tag. But this is regular HTML, I cannot access to Vue methods.
#Bruja
I was able to find a solution using a closure. The folks at Prismic reminded/showed me.
Of note, per Phil Snow's comment above: If you are using Nuxt you won't have access to Vue's functionality and will have to go old-school JS.
Here is an example where you can pass in component-level props, data, methods, etc... to the prismic htmlSerializer:
<template>
<div>
<prismic-rich-text
:field="data"
:htmlSerializer="anotherHtmlSerializer((startNumber = list.start_number))"
/>
</div>
</template>
import prismicDOM from 'prismic-dom';
export default {
methods: {
anotherHtmlSerializer(startNumber = 1) {
const Elements = prismicDOM.RichText.Elements;
const that = this;
return function(type, element, content, children) {
// To add more elements and customizations use this as a reference:
// https://prismic.io/docs/vuejs/beyond-the-api/html-serializer
that.testMethod(startNumber);
switch (type) {
case Elements.oList:
return `<ol start=${startNumber}>${children.join('')}</ol>`;
}
// Return null to stick with the default behavior for everything else
return null;
};
},
testMethod(startNumber) {
console.log('test method here');
console.log(startNumber);
}
}
};
I believe you are on the right track looking into the HTML Serializer. If you want all your .specialClass <span> elements to trigger a click event that calls specialmethod() this should work for you:
import prismicDOM from 'prismic-dom';
const Elements = prismicDOM.RichText.Elements;
export default function (type, element, content, children) {
// I'm not 100% sure if element.className is correct, investigate with your devTools if it doesn't work
if (type === Elements.span && element.className === "specialClass") {
return `<span #click="specialMethod">${content}</span>`;
}
// Return null to stick with the default behavior for everything else
return null;
};
I have a component ResultPill with a tooltip (implemented via vuikit) for the main container. The tooltip text is calculated by a getter function tooltip (I use vue-property-decorator) so the relevant bits are:
<template>
<div class="pill"
v-vk-tooltip="{ title: tooltip, duration: 0, cls: 'some-custom-class uk-active' }"
ref="container"
>
..some content goes here..
</div>
</template>
<script lang="ts">
#Component({ props: ... })
export default class ResultPill extends Vue {
...
get tooltip (): string { ..calcing tooltip here.. }
isContainerSqueezed (): boolean {
const container = this.$refs.container as HTMLElement | undefined;
if(!container) return false;
return container.scrollWidth != container.clientWidth;
}
...
</script>
<style lang="stylus" scoped>
.pill
white-space pre
overflow hidden
text-overflow ellipsis
...
</style>
Now I'm trying to add some content to the tooltip when the component is squeezed by the container's width and hence the overflow styles are applied. Using console, I can roughly check this using $0.scrollWidth == $0.clientWidth (where $0 is the selected element), but when I start tooltip implementation with
get tooltip (): string {
if(this.isContainerSqueezed())
return 'aha!'
I find that for many instances of my component this.$refs.container is undefined so isContainerSqueezed doesn't help really. Do I have to somehow set unique ref per component instance? Are there other problems with this approach? How can I check whether the element is overflown?
PS to check if the non-uniqueness of refs may affect the case, I've tried to add to the class a random id property:
containerId = 'ref' + Math.random();
and use it like this:
:ref="containerId"
>
....
const container = this.$refs[this.containerId] as HTMLElement | undefined;
but it didn't help: still tooltip isn't altered.
And even better, there's the $el property which I can use instead of refs, but that still doesn't help. Looks like the cause is this:
An important note about the ref registration timing: because the refs themselves are created as a result of the render function, you cannot access them on the initial render - they don’t exist yet! $refs is also non-reactive, therefore you should not attempt to use it in templates for data-binding.
(presumably the same is applicable to $el) So I have to somehow recalc tooltip on mount. This question looks like what I need, but the answer is not applicable for my case.
So, like I've mentioned in one of the edits, docs warn that $refs shouldn't be used for initial rendering since they are not defined at that time. So, I've made tooltip a property instead of a getter and calcuate it in mounted:
export default class ResultPill extends Vue {
...
tooltip = '';
calcTooltip () {
// specific logic here is not important, the important bit is this.isContainerSqueezed()
// works correctly at this point
this.tooltip = !this.isContainerSqueezed() ? this.mainTooltip :
this.label + (this.mainTooltip ? '\n\n' + this.mainTooltip : '');
}
get mainTooltip (): string { ..previously used calculation.. }
...
mounted () {
this.calcTooltip()
}
}
I've been doing the research on Slack and elsewhere, but I am not able to find an answer to my question. I feel I lack some basic knowledge of OOP, which probably will take me hours of researching and coding before I get to the answer. But somehow I am perplexed that it might be such a complex issue.
The question is:
I have two buttons with the same class on a page (this is just for example). I create JS to handle the behaviour via Class function. In constructor I define an element I want the Class to point to, namely child. However, I want the Class to point to one of two child separately when clicking on them. However, this.child in constructor always points to two child elements.
Can you please help and tell what I am doing wrong?
const selectors = {
childElement: '.child'
},
$ = jQuery;
class Child {
constructor() {
this.child = $(selectors.childElement);
this.bindUiEvents();
}
bindUiEvents() {
$(this.child).on('click', this.addStyles);
}
addStyles() {
$(this).addClass('coloured');
}
}
new Child();
.child {
height: 100px;
width: 100px;
border: 1px solid black;
}
.child.coloured {
background: blue;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="parent">
<button class="child">First</button>
<button class="child">Second</button>
</div>
If you need to create an instance of class Child for each button, you have to get the elements outside the class.
First we modify the Child class to accept a button during instantiation.
class Child {
constructor(child) {
this.child = child; //you would want to pass a single element here
this.bindUiEvents();
}
bindUiEvents() {
$(this.child).on('click', this.addStyles);
}
addStyles() {
$(this.child).addClass('.coloured');
}
}
Then we iterate each button outside
const buttons = $('.child');
const arry = []; //we'll put each Child instance here
//iterate on each button
buttons.each((idx, b) => {
const clss = new Child(b); //pass each button element to their own Child class
arry.push(clss); //add in arry for later access;
})
As #Liam said in the comments, you can do all of these without all this class-based jibber jabber. But I'm just gonna go assume you have other reasons for this extra complexities