Initialisation of Custom Elements Inside Document Fragment - javascript

Consider this HTML template with two flat x-elements and one nested.
<template id="fooTemplate">
<x-element>Enter your text node here.</x-element>
<x-element>
<x-element>Hello, World?</x-element>
</x-element>
</template>
How to initialise (fire constructor) all custom elements in cloned from fooTemplate document fragment without appending it to DOM, neither by extending built-in elements with is="x-element"; either entire fragment.
class XElement extends HTMLElement {
constructor() { super(); }
foo() { console.log( this ); }
} customElements.define( 'x-element', XElement );
const uselessf = function( temp ) {
const frag = window[ temp ].content.cloneNode( true );
/* Your magic code goes here:
*/ do_black_magic( frag );
for (const e of frag.querySelectorAll('x-element') )
e.foo(); // This should work.
return frag;
};
window['someNode'].appendChild( uselessf('fooTemplate') );
Note that script executes with defer attribute.

We can initialise template with this arrow function:
const initTemplate = temp =>
document.createRange().createContextualFragment( temp.innerHTML );
const frag = initTemplate( window['someTemplate'] );
Or with this method defined on template prototype (I prefer this way):
Object.defineProperty(HTMLTemplateElement.prototype, 'initialise', {
enumerable: false,
value() {
return document.createRange().createContextualFragment( this.innerHTML );
}
});
const frag = window['someTemplate'].initialise();
In any case in result this code will work fine:
for (const elem of frag.querySelectorAll('x-element') )
elem.foo();
window['someNode'].appendChild( frag );
I'm not sure if these methods are the most effective way to initialise custom elements in template.
Also note that there is no need for cloning template.

TLDR:
Use document.importNode(template.content, true); instead of template.content.cloneNode(true);
Read more about document.importNode() here.
Explanation:
Since the custom element is created in a different document/context (the DocumentFragment of the template) it doesn't know about the custom elements definition in the root/global document. You can get the document an element belongs to by reading the Node.ownerDocument property (MDN) which in this case will be different to the window.document element.
This being said you need to create the custom element in the context of the global document in order to "apply" the custom element. This can be done by calling document.importNode(node, [true]) (MDN) which works like node.cloneNode([true]), but creates a copy of the element in the global document context.
Alternatively you can also use document.adoptNode(node) (MDN) to first adopt the DocumentFragment to the global document and then create copies of it via node.cloneNode([true]). Note though if you use adoptNode() on an HTML element it will be removed from its original document.
Illustrative Example Code:
class XElement extends HTMLElement {
constructor() { super(); console.log("Custom Element Constructed") }
}
customElements.define( 'x-element', XElement );
const externalFragment = fooTemplate.content;
console.log(
"Is ownerDocument equal?",
externalFragment.ownerDocument === document
);
console.log("import start");
const importedFragment = document.importNode(externalFragment, true);
console.log("import end");
console.log(
"Is ownerDocument equal?",
importedFragment.ownerDocument === document
);
<template id="fooTemplate">
<x-element>Hello, World?</x-element>
</template>
Note: Appending an element from one document to another document forces an implicit adoption of the node. That's why appending the element to the global DOM works in this case.

You can avoid the "createContextualFragment" hack from the previous answer by simply adding the template clone to the document immediately before processing it.
Assuming we have these two variables defined...
const containerEl = document.querySelector('div.my-container')
const templateEl = document.querySelector('#fooTemplate')
...instead of doing this (where frag contains uninitialised custom elements)...
const frag = templateEl.content.cloneNode(true)
manipulateTemplateContent(frag)
containerEl.appendChild(frag)
...append the template clone to the document first, then manipulate it. The user won't notice any difference - it's all synchronous code executed within the same frame.
const frag = templateEl.content.cloneNode(true)
containerEl.appendChild(frag)
manipulateTemplateContent(containerEl)

Related

Custom properties on Custom Elements

After a custom element has been parsed and added to the DOM, can I get access to a custom property I created at the constructor time, like:
const props = []
return new CustomElement(props)
// later in code
class CustomElement {
...
constructor(props) {
this.customProperty = props
}
toString() {
return this.outerHTML
}
...
}
// later in code
const ce = document.querySelector("custom-element")
console.log( ce.customProperty ) // []
Yes, no different than regular DOM elements.
And with document.createElement("custom-element") your property is there too.
Note with the latter only the constructor was executed.
So props you add in the connectedCallback won't be set yet.
For these type of questions try your own code in a JSFiddle or CodePen or https://webcomponents.dev

is it possible to update the OUTER html of a node using javascript?

I'm building an audio website.
It uses custom components (for the tracklists, the tracks, the track sources, the player..., but I'm stuck on something.
When the user clicks on a track, the track HTML eventually needs to be refreshed - this allows me, among others, to query the database for informations on that track (eg. track sources) that would be too long to load at the initialization.
Thus, I need to replace my track node by the updated HTML.
But I only find documentation about replacing the content of a node (.innerHTML), not the node itself.
This doesn't work for me since I need to get the attributes of the new node.
I don't want to delete the old node and add the new one at the same place because I need to keep the reference to the first node.
What I want to achieve (simplified)
JS
<?php
class myCustomEl extends HTMLElement{
constructor() {
super(); //required to be first
}
connectedCallback(){
this.render();
}
disconnectedCallback(){
}
attributeChangedCallback(attrName, oldVal, newVal){
}
adoptedCallback(){
}
static get observedAttributes() {
}
///
///
render(){
}
reload(){
var self = this;
var success = $.Deferred();
/*
Here we would make an ajax request to return the new content
*/
var newContent = '<my-custom expires="XXXX">New Content</my-custom>';
success.resolve();
return success.promise();
}
}
$( document ).ready(function() {
$('my-custom').on('click', '.wpsstm-source-title', function(e) {
var mynode = this;
mynode.reload().then(
function(success_msg){
console.log("RELOADED!");
console.log(mynode); //here I would like to be able to get mynode with its updated content
},
function(error_msg){
console.log(error_msg);
}
);
});
});
window.customElements.define('my-custom', myCustomEl);
HTML
<my-custom expires="XXXX">Old Content</my-custom>
What I actually do
(because I can't get it to work)
copy the new node .innerHTML to the old node .innerHTML,
remove all the attributes of the old node,
copy all the attributes of the new node to the old node.
It seems to work but I think it's quite hackish, and was wondering how I could achieve this differently.
function swapNode(oldNode,newHTML){
//create new node from HTML
var template = document.createElement('template');
newHTML = newHTML.trim(); // Never return a text node of whitespace as the result
template.innerHTML = newHTML;
var newNode = template.content.firstChild;
//check both nodes have the same tag
if (oldNode.tagName !== newNode.tagName){
console.log("wpsstmSwapNode - tags do not match, abord.");
return false;
}
//remove all old attributes
while(oldNode.attributes.length > 0){
oldNode.removeAttribute(oldNode.attributes[0].name);
}
//add new attributes
let attr;
let attributes = Array.prototype.slice.call(newNode.attributes);
while(attr = attributes.pop()) {
oldNode.setAttribute(attr.nodeName, attr.nodeValue);
}
//switch HTML
oldNode.innerHTML = newNode.innerHTML;
return true;
}
I also tried this
var parent = self.parentNode;
var newContent = '<my-custom>NEWCONTENT</my-custom>';
var newNode = $(newContent).get(0);
var oldNode = parent.removeChild(self);
parent.appendChild(newNode);
newNode.appendChild(oldNode);
Thanks !
Detach from the parent, add a new child node to the parent for what you want then add what you detached as a child to the new node.
At first I thought you wanted something like the raw workings of jQuery.replaceWith.
Essentially using node.replaceChild with a copy of your target a la Creating a new DOM element from an HTML string using built-in DOM methods or Prototype.
var content = target.outerHTML.trim();
// manipulate content...
var replacement = document.createElement('div');
replacement.innerHTML = content;
target
.parentNode
.replaceChild( replacement.firstChild, target );
But if you don't want to replace the existing node itself (which is basically what manipulating outerHTML would do), then you have to either extract the parts you want to keep and append them to the replacement, or vice versa and extract the parts from the replacement and merge them to the original.

Iterate over HTMLCollection in custom element

How can I iterate over instances of one custom element within the shadow dom of another custom element? HTMLCollections don't seem to behave as expected. (I'm a jQuerian and a novice when it comes to vanilla js, so I'm sure I'm making an obvious error somewhere).
HTML
<spk-root>
<spk-input></spk-input>
<spk-input></spk-input>
</spk-root>
Custom Element Definitions
For spk-input:
class SpektacularInput extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define('spk-input', SpektacularInput);
For spk-root:
let template = document.createElement('template');
template.innerHTML = `
<canvas id='spektacular'></canvas>
<slot></slot>
`;
class SpektacularRoot extends HTMLElement {
constructor() {
super();
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(template.content.cloneNode(true));
}
update() {
let inputs = this.getElementsByTagName('spk-input')
}
connectedCallback() {
this.update();
}
}
window.customElements.define('spk-root', SpektacularRoot);
Here's the part I don't understand. Inside the update() method:
console.log(inputs) returns an HTMLCollection:
console.log(inputs)
// output
HTMLCollection []
0: spk-input
1: spk-input
length: 2
__proto__: HTMLCollection
However, the HTMLCollection is not iterable using a for loop, because it has no length.
console.log(inputs.length)
// output
0
Searching SO revealed that HTMLCollections are array-like, but not arrays. Trying to make it an array using Array.from(inputs) or the spread operator results in an empty array.
What's going on here? How can I iterate over the spk-input elements within spk-root from the update() method?
I'm using gulp-babel and gulp-concat and using Chrome. Let me know if more info is needed. Thanks in advance.
Edit: To clarify, calling console.log(inputs.length) from within the update() outputs 0 instead of 2.
The reason will be that connectedCallback() of a custom element in certain cases will be called as soon as the browser meets the opening tag of the custom element, with children not being parsed, and thus, unavailable. This does e.g. happen in Chrome if you define the elements up front and the browser then parses the HTML.
That is why let inputs = this.getElementsByTagName('spk-input') in your update() method of the outer <spk-root> cannot find any elements. Don't let yourself be fooled by misleading console.log output there.
I've just recently taken a deep dive into this topic, and suggested a solution using a HTMLBaseElement class:
https://gist.github.com/franktopel/5d760330a936e32644660774ccba58a7
Andrea Giammarchi (the author of document-register-element polyfill for custom elements in non-supporting browsers) has taken on that solution suggestion and created an npm package from it:
https://github.com/WebReflection/html-parsed-element
As long as you don't need dynamic creation of your custom elements, the easiest and most reliable fix is to create the upgrade scenario by putting your element defining scripts at the end of the body.
If you're interested in the discussion on the topic (long read!):
https://github.com/w3c/webcomponents/issues/551
Here's the full gist:
HTMLBaseElement class solving the problem of connectedCallback being called before children are parsed
There is a huge practical problem with web components spec v1:
In certain cases connectedCallback is being called when the element's child nodes are not yet available.
This makes web components dysfunctional in those cases where they rely on their children for setup.
See https://github.com/w3c/webcomponents/issues/551 for reference.
To solve this, we have created a HTMLBaseElement class in our team which serves as the new class to extend autonomous custom elements from.
HTMLBaseElement in turn inherits from HTMLElement (which autonomous custom elements must derive from at some point in their prototype chain).
HTMLBaseElement adds two things:
a setup method that takes care of the correct timing (that is, makes sure child nodes are accessible) and then calls childrenAvailableCallback() on the component instance.
a parsed Boolean property which defaults to false and is meant to be set to true when the components initial setup is done. This is meant to serve as a guard to make sure e.g. child event listeners are never attached more than once.
HTMLBaseElement
class HTMLBaseElement extends HTMLElement {
constructor(...args) {
const self = super(...args)
self.parsed = false // guard to make it easy to do certain stuff only once
self.parentNodes = []
return self
}
setup() {
// collect the parentNodes
let el = this;
while (el.parentNode) {
el = el.parentNode
this.parentNodes.push(el)
}
// check if the parser has already passed the end tag of the component
// in which case this element, or one of its parents, should have a nextSibling
// if not (no whitespace at all between tags and no nextElementSiblings either)
// resort to DOMContentLoaded or load having triggered
if ([this, ...this.parentNodes].some(el=> el.nextSibling) || document.readyState !== 'loading') {
this.childrenAvailableCallback();
} else {
this.mutationObserver = new MutationObserver(() => {
if ([this, ...this.parentNodes].some(el=> el.nextSibling) || document.readyState !== 'loading') {
this.childrenAvailableCallback()
this.mutationObserver.disconnect()
}
});
this.mutationObserver.observe(this, {childList: true});
}
}
}
Example component extending the above:
class MyComponent extends HTMLBaseElement {
constructor(...args) {
const self = super(...args)
return self
}
connectedCallback() {
// when connectedCallback has fired, call super.setup()
// which will determine when it is safe to call childrenAvailableCallback()
super.setup()
}
childrenAvailableCallback() {
// this is where you do your setup that relies on child access
console.log(this.innerHTML)
// when setup is done, make this information accessible to the element
this.parsed = true
// this is useful e.g. to only ever attach event listeners once
// to child element nodes using this as a guard
}
}
The HTMLCollection inputs does have a length property, and if you log it inside the update function you will see it's value is 2. You can also iterate through the inputs collection in a for loop so long as it's inside the update() function.
If you want to access the values in a loop outside of the update function, you can store the HTMLCollection in a variable declared outside of the scope of the SpektacularInput class.
I suppose there are other ways to store the values depending on what you're trying to accomplish, but hopefully this answers your initial question "How can I iterate over the spk-input elements within spk-root from the update() method?"
class SpektacularInput extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define('spk-input', SpektacularInput);
let template = document.createElement('template');
template.innerHTML = `
<canvas id='spektacular'></canvas>
<slot></slot>
`;
// declare outside variable
let inputsObj = {};
class SpektacularRoot extends HTMLElement {
constructor() {
super();
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(template.content.cloneNode(true));
}
update() {
// store on outside variable
inputsObj = this.getElementsByTagName('spk-input');
// use in the function
let inputs = this.getElementsByTagName('spk-input');
console.log("inside length: " + inputs.length)
for(let i = 0; i < inputs.length; i++){
console.log("inside input " + i + ": " + inputs[i]);
}
}
connectedCallback() {
this.update();
}
}
window.customElements.define('spk-root', SpektacularRoot);
console.log("outside length: " + inputsObj.length);
for(let i = 0; i < inputsObj.length; i++){
console.log("outside input " + i + ": " + inputsObj[i]);
}
<spk-root>
<spk-input></spk-input>
<spk-input></spk-input>
</spk-root>
Hope it helps,
Cheers!

Functions in javascript

I am new to javascript and I was messing around with it. I was checking out jquery and I wanted to see if I could create something to do the same things. This is my js file
//returns a dom
var $ = function(selector) {
return document.querySelector(selector);
};
//adds something to the dom
var append = function(value) {
this.innerHTML += value;
};
//clears dom
var empty = function() {
while (this.lastChild) this.removeChild(this.lastChild);
};
When I call $('#test') I get the dom with id test. When I call $('#test').append('hi there') it works. However when I try $('#test').empty() I get a Uncaught TypeError: $(...).empty is not a function Any idea why? If I am doing something comletely wrong please let me know. Thanks
Your functions aren't added to the prototype chain of DOM elements, (that wouldn't be a good idea), so they can't be called as methods of DOM elements.
append works, because the DOM node already had a method called append, and it has nothing to do with the function you stored in a variable called append.
jQuery works by creating a container object that holds a collection of DOM elements and has it's own methods. EG:
var $ = function(selector) {
var objects = document.querySelectorAll(selector);
return {
append: function ( ) {
// do something to `objects` here
},
empty: function ( ) {
},
};
};

Why does my HTML constructor object return [object Object] instead of [HTMLElementElement]?

Background
I'm trying to make a completely pure JavaScript GUI for creating HTML content for learning purposes. The only real html in the file will be one <script></script> element. I'm almost finished, I think.
I made a custom HTML element constructor, but when I create an object, [object Object] is displayed instead of [object HTMLWhateverElement] when I do alert(whatever); (see the example below). I think that's preventing me from appending child elements to parent elements when both are made by the constructor.
The main problem
If I could get HTML instances to append to HTML tags and element instances, then I would be very happy.
Constructor
function element(tagName) {
var element = document.createElement(tagName);
this.setText = function(elementText) {
if(element.childNodes.length < 1) {
var text = document.createTextNode(elementText);
element.appendChild(text);
} else {
element.childNodes[0].nodeValue = elementText;
}
}
this.removeText = function() {
element.removeChild(element.childNodes[0]);
}
this.setAttribute = function(attribute,value) {
element.setAttribute(attribute,value);
}
this.removeAttribute = function(attribute) {
element.removeAttribute(attribute);
}
this.appendTo = function(parent) { // Works but not on element instances of the constructor
parent.appendChild(element);
}
this.details = function(){
alert(
"Type: " + element +
"\n\"typeof\": " + typeof(element) // Shouldn't need this right?
);
}
}
Example
ul = new element("ul");
ul.appendTo(body); // works fine if BODY is actually an HTML tag
alert(body); // gives me [object HTMLBodyElement] :)
alert(ul); // gives me [object Object] >(
li = new element("li"); // works :)
li.setText("list item text"); // works :)
li.appendTo(ul); // doesn't work >(
If I could just figure out how to append JavaScript-created (child) elements to other JavaScript-created (parent) elements, I'd be golden. I think it has to do with the return value of instantiated elements made by the constructor.
EDIT
1) Possible answer
#carter-sand Thank you.
Adding a new this.appendChild method works but reinvents the wheel
of HTML object's built in appendChild method.
2) Possible answer
#s4mok Thanks for the hint.
Changing var element to this.elem works but creates a
funky interface:
li.appendTo(ul.elem);
vs.
li.appendTo(ul);
I'm trying to emulate a private member.
Both answers work but neither return the value [object
HTMLWhateverElement] when I do :
alert(li);
Is there a way to reap the benefits of all of the above?
ul = new element("ul");
The above line is instantiating the function element() and the instance which is assigned to ul is an object and not HTMLWhateverElement
ul.appendTo(body); // works fine if BODY is actually an HTML tag
The above works because your code is :
this.appendTo = function(parent) { // Works but not on element instances of the constructor
parent.appendChild(element);
}
whereas the parent is body and an HMTL Element and has a method appendChild, and the element that you are appending is the element from this line:
var element = document.createElement(tagName);
Why the below code does not work?
li = new element("li"); // works :)
li.setText("list item text"); // works :)
li.appendTo(li); // doesn't work >(
The answer to that is first of all li is not a HTML Element which the code
this.appendTo = function(parent) { // Works but not on element instances of the constructor
parent.appendChild(element);
}
will fail since the instance li is not an html element but rather an object instance of function element()
Another error in this code is that you have a circular li.appendTo(li); which if you inspect your code, you are appending as child li to itself. something like a paradox or a circular dependency.
EDIT:
My Recommendation:
First of all, maybe change the name of your "element" inside the function element to avoid confusion. :D
Expose the var element = document.createElement(tagName); so that you could access it from the parameter parent of your method appendTo which will allow you to use parent.getTheExposedElement().appendChild ()
Your function returns an instance of itself [object Object] when created using the "new" keyword. You can override that by specifically returning your element. However you will then need to change "this" to your element instead because "this" refers to the function new instance. Also as suggested it may be less confusing if you changed "element" to something else like "newElement".
function element(tagName) {
var element = document.createElement(tagName);
element.setText = function(elementText) {
if(element.childNodes.length < 1) {
var text = document.createTextNode(elementText);
element.appendChild(text);
} else {
element.childNodes[0].nodeValue = elementText;
}
}
element.removeText = function() {
element.removeChild(element.childNodes[0]);
}
element.setAttribute = function(attribute,value) {
element.setAttribute(attribute,value);
}
element.removeAttribute = function(attribute) {
element.removeAttribute(attribute);
}
element.appendTo = function(parent) { // Works but not on element instances of the constructor
parent.appendChild(element);
}
element.details = function(){
alert(
"Type: " + element +
"\n\"typeof\": " + typeof(element) // Shouldn't need this right?
);
}
return element;
}
ul = new element("ul");
ul.appendTo(document.body); // works fine if BODY is actually an HTML tag
alert(document.body);
alert(ul);
li = new element("li");
li.setText("list item text");
li.appendTo(ul);
If I run your example, I notice the following error message in the console:
Uncaught TypeError: Object #<element> has no method 'appendChild'
This is because your custom element instance does not have the appendChild method that you're using in appendTo. You need to add one to your constructor, like this:
this.appendChild = function(child) {
element.appendChild(child);
}

Categories