How to make web component to redender specific elements upon property update - javascript

class MyElement extends HTMLElement {
constructor() {
super();
// Props
this._color = this.getAttribute("color");
this._myArray = this.getAttribute("myArray");
// data
// Shadow DOM
this._shadowRoot = this.attachShadow({ mode: "open" });
this.render();
}
template() {
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
display: block;
}
span {color: ${this.color}}
</style>
<p>Notice the console displays three renders: the original, when color changes to blue after 2 secs, and when the array gets values</p>
<p>The color is: <span>${this.color}</span></p>
<p>The array is: ${this.myArray}</p>
`;
return template;
}
get color() {
return this._color;
}
set color(value) {
this._color = value;
this.render();
}
get myArray() {
return this._myArray;
}
set myArray(value) {
this._myArray = value;
this.render();
}
render() {
// Debug only
const props = Object.getOwnPropertyNames(this).map(prop => {
return this[prop]
})
console.log('Parent render; ', JSON.stringify(props));
// end debug
this._shadowRoot.innerHTML = '';
this._shadowRoot.appendChild(this.template().content.cloneNode(true));
}
}
window.customElements.define('my-element', MyElement);
<!DOCTYPE html>
<head>
<script type="module" src="./src/my-element.js" type="module"></script>
<!-- <script type="module" src="./src/child-element.js" type="module"></script> -->
</head>
<body>
<p><span>Outside component</span> </p>
<my-element color="green"></my-element>
<script>
setTimeout(() => {
document.querySelector('my-element').color = 'blue';
document.querySelector('my-element').myArray = [1, 2, 3];
}, 2000);
</script>
</body>
I have a native web component whose attributes and properties may change (using getters/setters). When they do, the whole component rerenders, including all children they may have.
I need to rerender only the elements in the template that are affected.
import {ChildElement} from './child-element.js';
class MyElement extends HTMLElement {
constructor() {
super();
// Props
this._color = this.getAttribute("color");
this._myArray = this.getAttribute("myArray");
// Shadow DOM
this._shadowRoot = this.attachShadow({ mode: "open" });
this.render();
}
template() {
const template = document.createElement("template");
template.innerHTML = `
<style>
span {color: ${this.color}}
</style>
<p>The color is: <span>${this.color}</span></p>
<p>The array is: ${this.myArray}</p>
<child-element></child-element>
`;
return template;
}
get color() {
return this._color;
}
set color(value) {
this._color = value;
this.render(); // It rerenders the whole component
}
get myArray() {
return this._myArray;
}
set myArray(value) {
this._myArray = value;
this.render();
}
render() {
this._shadowRoot.innerHTML = '';
this._shadowRoot.appendChild(this.template().content.cloneNode(true));
}
}
window.customElements.define('my-element', MyElement);
window.customElements.define('child-element', ChildElement);
Because each setter calls render(), the whole component, including children unaffected by the updated property, rerenders.

Yes, if you go native you have to program all reactivity yourself.
(but you are not loading any dependencies)
Not complex, Your code can be simplified;
and you probably want to introduce static get observedAttributes and the attributeChangedCallback to automatically listen for attribute changes
customElements.define('my-element', class extends HTMLElement {
constructor() {
super().attachShadow({ mode: "open" }).innerHTML = `
<style id="STYLE"></style>
<p>The color is: <span id="COLOR"/></p>
<p>The array is: <span id="ARRAY"/></p>`;
}
connectedCallback() {
// runs on the OPENING tag, attributes can be read
this.color = this.getAttribute("color");
this.myArray = this.getAttribute("myArray"); // a STRING!!
}
get color() {
return this._color;
}
set color(value) {
this.setDOM("COLOR" , this._color = value );
this.setDOM("STYLE" , `span { color: ${value} }`);
}
get myArray() {
return this._myArray;
}
set myArray(value) {
this.setDOM("ARRAY" , this._myArray = value );
}
setDOM(id,html){
this.shadowRoot.getElementById(id).innerHTML = html;
}
});
<my-element color="green" myArray="[1,2,3]"></my-element>
<my-element color="red" myArray="['foo','bar']"></my-element>

You are missing a certain level of abstraction. You are trying to emulate the Vue/React way of doing things where whenever the props change, the render() function is repeatedly called. But in these frameworks, render function doesn't do any DOM manipulation. It is simply working on Virtual DOM. Virtual DOM is simply a tree of JS object and thus very fast. And, that's the abstraction we are talking about.
In your case, it is best if you rely on some abstraction like LitElement or Stencil, etc. - any web component creation framework. They would take care of handling such surgical updates. It also takes care of scheduling scenarios where multiple properties are changed in a single event loop but render is still called exactly once.
Having said that, if you want to do this by yourself, one important rule: Never read or write to DOM from the web component constructor function. Access to DOM is after the connectedCallback lifecycle event.
Here is the sample that you can try and expand:
import { ChildElement } from './child-element.js';
class MyElement extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
this.attached = false;
}
connectedCallback() {
// Check to ensure that initialization is done exactly once.
if (this.attached) {
return;
}
// Props
this._color = this.getAttribute("color");
this._myArray = this.getAttribute("myArray");
// Shadow DOM
this.render();
this.attached = true;
}
template() {
const template = document.createElement("template");
template.innerHTML = `
<style>
span {color: ${this.color}}
</style>
<p>The color is: <span id="span">${this.color}</span></p>
<p>The array is: ${this.myArray}</p>
<child-element></child-element>
`;
return template;
}
get color() {
return this._color;
}
set color(value) {
this._color = value;
this.shadowRoot.querySelector('#span').textContent = this._color;
this.shadowRoot.querySelector('style').textContent = `
span { color: ${this._color}; }
`;
}
get myArray() {
return this._myArray;
}
set myArray(value) {
this._myArray = value;
this.shadowRoot.querySelector('#span').innerHTML = this._myArray;
}
render() {
this._shadowRoot.innerHTML = '';
this._shadowRoot.appendChild(this.template().content.cloneNode(true));
}
}
window.customElements.define('my-element', MyElement);
window.customElements.define('child-element', ChildElement);
The above example is a classical vanilla JS with imperative way of updating the DOM. As said earlier to have reactive way of updating the DOM, you should build your own abstraction or rely on library provided abstraction.

Related

Custom element not picking up attributes

I am trying to have a look at custom elements and how they work and while the examples on MDN work fine I'm seemingly unable to replicate them myself.
The MDN article is here.
This is a working example from MDN.
My problem is that I can't ever seem to pass attributes into my component, they always come out as null instead of passing over the value of the parameter.
My JS is (test.js)
class PopUpInfo extends HTMLElement {
constructor() {
// Always call super first in constructor
super();
// Create a shadow root
const shadow = this.attachShadow({mode: 'open'});
// Create spans
const wrapper = document.createElement('span');
const info = document.createElement('span');
// Take attribute content and put it inside the info span
const text = this.getAttribute('foo'); // <-- this always returns null
info.textContent = `(${text})`;
shadow.appendChild(wrapper);
wrapper.appendChild(info);
}
}
// Define the new element
customElements.define('popup-info', PopUpInfo);
And my Html:
<html>
<head>
<script src="test.js"></script>
</head>
<body>
<hr>
<popup-info foo="Hello World"></popup-info>
<hr>
</body>
</html>
What I'm expecting to see on screen is the text
(Hello World)
but all I ever see is
(null)
When I debug I can see that this.attributes has a length of 0 so it's not being passed in.
Has anyone seen this before when creating custom elements?
Keep Emiel his answer as the correct one.
Just to show there are alternative and shorter notations possible:
customElements.define('popup-info', class extends HTMLElement {
static get observedAttributes() {
return ['foo'];
}
constructor() {
const wrapper = document.createElement('span');
super().attachShadow({mode:'open'})// both SETS and RETURNS this.shadowRoot
.append(wrapper);
this.wrapper = wrapper;
}
attributeChangedCallback(name, oldValue, newValue) {
switch(name) {
case 'foo':
this.wrapper.textContent = `(${newValue})`;
break;
}
}
});
<popup-info
foo="Hello World"
onclick="this.setAttribute('foo','Another world')"
>
</popup-info>
Although your example seems to run fine when I try to run it here in a snippet, I still want to make a suggestion to improve it.
Use the observedAttributes static getter to define a list of attributes which the component should keep an eye on. When the value of an attribute has been changed and the name of the attribute is in the list, then attributeChangedCallback callback is called. In there you can assert logic on what to do whenever you attribute value has been changed.
In this case you could build your string that you desire. This also has the side effect that whenever the attribute value is changed again, the string will be updated.
class PopUpInfo extends HTMLElement {
/**
* Observe the foo attribute for changes.
*/
static get observedAttributes() {
return ['foo'];
}
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open'
});
const wrapper = document.createElement('span');
const info = document.createElement('span');
wrapper.classList.add('wrapper');
wrapper.appendChild(info);
shadow.appendChild(wrapper);
}
/**
* Returns the wrapper element from the shadowRoot.
*/
get wrapper() {
return this.shadowRoot.querySelector('.wrapper')
}
/**
* Is called when observed attributes have a changed value.
*/
attributeChangedCallback(attrName, oldValue, newValue) {
switch(attrName) {
case 'foo':
this.wrapper.textContent = `(${newValue})`;
break;
}
}
}
// Define the new element
customElements.define('popup-info', PopUpInfo);
<html>
<head>
<script src="test.js"></script>
</head>
<body>
<hr>
<popup-info foo="Hello World"></popup-info>
<hr>
</body>
</html>
You're missing a defer attribute in your script import within the HTML and it is not loading properly, thats the problem. The defer attribute allows the script to be executed after the page is parsed
class PopUpInfo extends HTMLElement {
constructor() {
// Always call super first in constructor
super()
// Create a shadow root
const shadow = this.attachShadow({ mode: 'open' })
// Create spans
const wrapper = document.createElement('span')
const info = document.createElement('span')
// Take attribute content and put it inside the info span
const text = this.getAttribute('foo') // <-- this always returns null
info.textContent = `(${text})`
shadow.appendChild(wrapper)
wrapper.appendChild(info)
}
}
// Define the new element
customElements.define('popup-info', PopUpInfo)
<html>
<head>
<script src="app.js" defer></script>
</head>
<body>
<hr />
<popup-info foo="Hello World"></popup-info>
<hr />
</body>
</html>

How to properly visually nest HTML Custom Elements

I'm trying to write a simple HTML Custom Element for putting trees on pages, using simple code like:
<html-tree title="root">
<tree-node title="child1">
<tree-node title="leaf1"></tree-node>
<tree-node title="leaf2"></tree-node>
</tree-node>
<tree-node title="child2">
<tree-node title="leaf3"></tree-node>
<tree-node title="leaf4"></tree-node>
</tree-node>
</html-tree>
Each element is basically a shadow dom with a single unnamed <slot></slot> for putting children in, so I'd expect a nice, nested structure. Instead, if I assign the standard debug style :host>* { border:1px red solid; } to these custom elements, each element shows up on its own line, with a border around it, rather than showing them as being nested.
How do I preserve the markup-specified nesting in a way that CSS plays nice?
A snippet:
/**
* Main tree node class
*/
class GenericNode extends HTMLElement {
constructor() {
super();
this._shadow = enrich(this.attachShadow({mode: `open`}));
this._shadow.addSlot = () => this._shadow.add(create(`slot`));
if (!this.get) {
this.get = e => this.getAttribute(e);
}
this.setupDOM(this._shadow);
}
setupDOM(shadow) {
this.setStyle(`:host>* { border:1px red solid; }`)
if (this.leadIn) this.leadIn(shadow);
shadow.addSlot();
if (this.leadOut) this.leadOut(shadow);
}
setStyle(text) {
if (!this._style) {
this._style = create(`style`, text);
this._shadow.add(this._style);
} else {
this._style.textContent = text;
}
}
}
/**
* "not the root" element
*/
class Node extends GenericNode {
constructor() {
super();
}
leadIn(shadow) {
shadow.add(create(`p`, this.get(`title`)));
}
}
// register the component
customElements.define(`tree-node`, Node);
/**
* "the root" element, identical to Node, of course.
*/
class Tree extends Node {
constructor() {
super();
}
}
// register the component
customElements.define(`html-tree`, Tree);
/**
* utility functions
*/
function enrich(x) {
x.add = e => x.appendChild(e);
x.remove = e => {
if (e) x.removeChild(e);
else e.parentNode.removeChild(e);
};
x.get = e => x.getAttribute(x);
return x;
}
function find(qs) {
return Array.from(
document.querySelectorAll(qs).map(e => enrich(e))
);
}
function create(e,c) {
let x = enrich(document.createElement(e));
x.textContent = c;
return x;
};
<html-tree title="root">
<tree-node title="child1">
<tree-node title="leaf1"></tree-node>
<tree-node title="leaf2"></tree-node>
</tree-node>
<tree-node title="child2">
<tree-node title="leaf3"></tree-node>
<tree-node title="leaf4"></tree-node>
</tree-node>
</html-tree>
Turns out the default styling of a shadow dom and its content is "nothing", so to effect real nesting, you need to force display:block or be similarly explicit.
In the above code, rather than merely setting a border on the :host>*, the :host, and the <slot> also need to be explicitly marked as blocks:
setupDOM(shadow) {
this.setStyle(`
:host {
display: block;
border: 1px red solid;
}
:host > slot {
display: block;
border: 1px red solid;
margin-left: 1em;
}
`);
...
}

How to add style after web component stable state?

I have a parent component, it generates another 60 child component on render. after the render completed, and my parent component added with body, I would like to adjust the margin, paddings. because i require the length of child components.
when i try now getting output as 'null' any one help me please?
here is my code :
import './../components/avatar.component';
import RandomEmails from './../services/random-email-service';
export default class AvatarContainer extends HTMLElement {
shadowObj;
imageProps = [];
imageURL = `http://www.gravatar.com/avatar/6288f2a2679a0242771aa6cc02e85980?d=identicon&s=200`;
constructor(){
super();
this.shadowObj = this.attachShadow({mode: 'open'});
}
connectedCallback() {
this.imageProps = RandomEmails();
this.render();
this.setStyleByRequired();
}
render() {
let rows = this.imageProps.map((data,index) => {
return this.getTemplate(data);
});
this.shadowObj.innerHTML = `<div class="avatars-holder">${rows.join('')}</div>`;
//how to call after completion of this?
}
getTemplate(data) {
return(
`
<avatar-block link="${data.link}" email="${data.email}"></avatar-block>
${this.getStyle()}
`
)
}
setStyleByRequired() {
console.log('set now', document.querySelector('.avatars-holder')) //getting null
}
getStyle() {
return(
`
<style>
.avatars-holder {
display:flex;
flex-direction:row;
flex-wrap: wrap;
overflow:auto;
height:100%;
}
</style>
`
)
}
}
customElements.define('avatar-container', AvatarContainer);
connectedCallback does not guarantee the element (and thus, its children) has been parsed. If you need guaranteed child access, add your webcomponent bundle like this:
<script src="/path/to/bundle.js" defer></script>
defer makes sure your bundle is not executed before DOMContentLoaded occurs, and delays the end of that event until after the bundle has been executed (all of the synchronous code). This forces the upgrade process to be applied to your webcomponents, at a point in time when the browser guarantees that alll the elements in the DOM have been parsed.
Alternatively, use HTMLParsedElement (which I helped create), which addresses specifically this problem.
https://github.com/WebReflection/html-parsed-element
As a sidenote:
this.shadowObj = this.attachShadow({mode: 'open'});
is unnecessary,
this.attachShadow({mode: 'open'});
is sufficient and the shadow root is accessible in
this.shadowRoot
automatically.
it works fine for me:
const avatarHolder = this.shadowObj.querySelector('.avatars-holder');
thanks.

How can I conditionally show title tooltip if CSS ellipsis is active within a React component

Problem:
I'm looking for a clean way to show a title tooltip on items that have a CSS ellipsis applied. (Within a React component)
What I've tried:
I setup a ref, but it doesn't exist until componentDidUpdate, so within componentDidUpdate I forceUpdate. (This needs some more rework to handle prop changes and such and I would probably use setState instead.) This kind of works but there are a lot of caveats that I feel are unacceptable.
setState/forceUpdate - Maybe this is a necessary evil
What if the browser size changes? Do I need to re-render with every resize? I suppose I'd need a debounce on that as well. Yuck.
Question:
Is there a more graceful way to accomplish this goal?
Semi-functional MCVE:
https://codepen.io/anon/pen/mjYzMM
class App extends React.Component {
render() {
return (
<div>
<Test message="Overflow Ellipsis" />
<Test message="Fits" />
</div>
);
}
}
class Test extends React.Component {
constructor(props) {
super(props);
this.element = React.createRef();
}
componentDidMount() {
this.forceUpdate();
}
doesTextFit = () => {
if (!this.element) return false;
if (!this.element.current) return false;
console.log(
"***",
"offsetWidth: ",
this.element.current.offsetWidth,
"scrollWidth:",
this.element.current.scrollWidth,
"doesTextFit?",
this.element.current.scrollWidth <= this.element.current.offsetWidth
);
return this.element.current.scrollWidth <= this.element.current.offsetWidth;
};
render() {
return (
<p
className="collapse"
ref={this.element}
title={this.doesTextFit() ? "it fits!" : "overflow"}
>
{this.props.message}
</p>
);
}
}
ReactDOM.render(<App />, document.getElementById("container"));
.collapse {
width:60px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<script crossorigin src="https://unpkg.com/react#16/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#16/umd/react-dom.production.min.js"></script>
<div id="container"></div>
Since a lot of people are still viewing this question. I did finally figure out how to do it. I'll try to rewrite this into a working example at some point but here's the gist.
// Setup a ref
const labelRef = useRef(null);
// State for tracking if ellipsis is active
const [isEllipsisActive, setIsEllipsisActive] = useState(false);
// Setup a use effect
useEffect(() => {
if(labelRef?.current?.offsetWidth < labelRef?.current?.scrollWidth) {
setIsEllipsisActive(true);
}
}, [labelRef?.current, value, isLoading]); // I was also tracking if the data was loading
// Div you want to check if ellipsis is active
<div ref={labelRef}>{value}</div>
I use this framework agnostic snippet to this. Just include it on your page and see the magic happen ;)
(function() {
let lastMouseOverElement = null;
document.addEventListener("mouseover", function(event) {
let element = event.target;
if (element instanceof Element && element != lastMouseOverElement) {
lastMouseOverElement = element;
const style = window.getComputedStyle(element);
const whiteSpace = style.getPropertyValue("white-space");
const textOverflow = style.getPropertyValue("text-overflow");
if (whiteSpace == "nowrap" && textOverflow == "ellipsis" && element.offsetWidth < element.scrollWidth) {
element.setAttribute("title", element.textContent);
} else {
element.removeAttribute("title");
}
}
});
})();
From:
https://gist.github.com/JoackimPennerup/06592b655402d1d6181af32def40189d

Trying to use React.DOM to set body styles

How can I use React.DOM to change styles on HTML body?
I tried this code and it's not working:
var MyView = React.createClass({
render: function() {
return (
<div>
React.DOM.body.style.backgroundColor = "green";
Stuff goes here.
</div>
);
}
});
If you execute this from the browsers console it works (but I need it working in ReactJS code):
document.body.style.backgroundColor = "green";
Also see this question for similar but different solution:
Change page background color with each route using ReactJS and React Router?
Assuming your body tag isn't part of another React component, just change it as usual:
document.body.style.backgroundColor = "green";
//elsewhere..
return (
<div>
Stuff goes here.
</div>
);
It's recommended to put it at componentWillMount method, and cancel it at componentWillUnmount:
componentWillMount: function(){
document.body.style.backgroundColor = "green";
}
componentWillUnmount: function(){
document.body.style.backgroundColor = null;
}
With functional components and useEffect hook :
useEffect(() => {
document.body.classList.add('bg-black');
return () => {
document.body.classList.remove('bg-black');
};
});
A good solution to load multiple atributes from a js class to the document body can be:
componentWillMount: function(){
for(i in styles.body){
document.body.style[i] = styles.body[i];
}
},
componentWillUnmount: function(){
for(i in styles.body){
document.body.style[i] = null;
}
},
And after you write your body style as long as you want:
var styles = {
body: {
fontFamily: 'roboto',
fontSize: 13,
lineHeight: 1.4,
color: '#5e5e5e',
backgroundColor: '#edecec',
overflow: 'auto'
}
}
The best way to load or append extra classes is by adding the code in componentDidMount().
Tested with react and meteor :
componentDidMount() {
var orig = document.body.className;
console.log(orig); //Just in-case to check if your code is working or not
document.body.className = orig + (orig ? ' ' : '') + 'gray-bg'; //Here gray-by is the bg color which I have set
}
componentWillUnmount() {
document.body.className = orig ;
}
This is what I ended up using.
import { useEffect } from "react";
export function useBodyStyle(style: any){
useEffect(()=>{
for(var key in style){
window.document.body.style[key as any] = style[key];
}
return () => {
window.document.body.style[key as any] = '';
}
}, [style])
}
Even if you can set body styles from react with the provided answer, I prefer if a component is only responsible for setting its own style.
In my case there was an alternative solution. I needed to change the body backgroundColor. This could easily be achieved without changing the body style in a react component.
First I added this style to the index.html header.
<style>
html, body, #react-app {
margin: 0;
height: 100%;
}
</style>
Then, in my outermost component, I set the backgroundColor to the needed value and the height to 100%.

Categories

Resources