Extended custom elements not working in Angular 2+ - javascript

I can't get Angular (2+, not AngularJS) to play nice with my extended custom element, which is defined like so:
class FancyButton extends HTMLButtonElement {
connectedCallback() {
this.innerText = `I'm a fancy-button!`;
this.style.backgroundColor = 'tomato';
}
}
customElements.define("fancy-button", FancyButton, {
extends: "button"
});
And used like so:
<button is="fancy-button">Fancy button here</button>
The definition is fully compliant to web standards according to this Google Developer resource:
https://developers.google.com/web/fundamentals/web-components/customelements#extend
It's working fine in a vanilla web setup and in React, but Angular ignores it and shows a standard button, apparently ignoring the is="fancy-button" attribute.
Here is a stackblitz showing this in action.
One fancy-button is outside the Angular scope (index.html) and is working fine.
The other button is inside the Angular scope (app.component.html) and is NOT working.
Why oh why?

There are two types of Custom Elements:
Autonomous Custom Elements, which are classes that extend HTMLElement
Customized Built-in Elements, which are classes that extend a
specific type of element, such as extend HTMLButtonElement.
Customized Built-in Elements are not supported in Angular (more details
on this below). They are also still not supported in Safari (as of
Sep 2020: https://caniuse.com/#search=custom%20elements
).
Your example is a Customized Built-in Element. The workaround that has
worked for me is to rewrite any Customized Built-in Element as an Autonomous
Custom Element wrapped around a Built-in Element. This gives me the
encapsulation I want, it gives me a way to customize the built-in, and it works with Angular and Safari.
For your example above, the translation is:
class FancyButtonToo extends HTMLElement {
connectedCallback() {
const buttonElement = document.createElement('button');
this.appendChild(buttonElement);
buttonElement.innerText = "Fancy button #2 here!";
buttonElement.style.backgroundColor = 'tomato';
}
}
customElements.define("fancy-button-too", FancyButtonToo);
(The project also needed schemas: [ CUSTOM_ELEMENT_SCHEMAS] added
to app.module.ts). Full code here: stackblitz,
and it renders like this (original "fancy-button" left in for comparison):
Additional Info
Q: Are we certain Angular cannot support Customized Built-in Elements (as
opposed to, say, there being some obscure configuration we are not aware of)?
A: We are certain: The spec document at https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-autonomous-example
goes into deep detail on the differences between Autonomous Custom Elements
and Customized Built-in Elements. One important difference is in
programmatic construction of elements:
// Built-in Elements and Autonomous Custom Elements are created like this:
el = createElement(name);
// examples
el = createElement("button"); // normal built-in button
el = createElement("fancy-text"); // a custom element
// Customized Built-in Elements are created like this:
el = createElement(built-in-name, { is: custom-built-in-name });
// example
el = createElement("button", { is: "fancy-button" });
The relevant Angular template-rendering code is found at
https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/dom_renderer.ts
and as of the current version of Angular, you will find:
class DefaultDomRenderer2 implements Renderer2 {
/* ... */
createElement(name: string, namespace?: string): any {
if (namespace) {
return document.createElementNS(NAMESPACE_URIS[namespace] || namespace, name);
}
return document.createElement(name);
}
/* ... */
}
The Angular renderer currently does not have the extra code
needed to pass in the 2nd argument to createElement(); it cannot
create Custom Built-in Elements.

class DefaultDomRenderer2 implements Renderer2 {
// ...
create(name: string, namespaceOrOptions?: string | ElementCreationOptions) {
if (namespaceOrOptions && typeof namespaceOrOptions === 'string') {
// In cases where Ivy (not ViewEngine) is giving us the actual namespace, the look up by key
// will result in undefined, so we just return the namespace here.
return document.createElementNS(NAMESPACE_URIS[namespaceOrOptions] || namespaceOrOptions, name);
}
if (namespaceOrOptions && namespaceOrOptions.hasOwnProperty('is')) {
return document.createElement(name, namespaceOrOptions as ElementCreationOptions);
}
return document.createElement(name)
}
}

You can add the polyfill javascript file with your project and then it should work:
https://github.com/ungap/custom-elements

Related

Use parameterised functions defined in ES6 module directly in html

Functions defined inside an ES6 module embedded in an HTML script are not available to that script. Thus if you have a statement such as:
<button onclick="doSomething();">Do something</button>
in your HTML and your doSomething() function lives inside an ES6 module embedded in the HTML script, you will get a "doSomething() is undefined" error when you run the script.
Use functions defined in ES6 module directly in html suggests a great solution to the immediate problem, recommending that you "bind" your function to the button by amending your HTML thus:
<button id="dosomethingbutton">Do something</button>
and using the module itself to create a linkage thus:
document.getElementById('dosomethingbutton').addEventListener('click', doSomething);
This works fine, but what if your original button was a bit more sophisticated and was parameterised? For example:
<button onclick="doSomething('withThisString');">Do Something with String</button>
The most that the "binding" can provide seems to be limited to the circumstances relating to the event - I can find no way of associating it with data. I'm completely stuck trying to find a solution to this one and assistance would be much appreciated.
I'd like to add that, while this problem might seem a bit obscure, I think it will be of interest to anyone migrating to Firebase 9. Amongst other changes, migration requires you to move your javascript code into ES6 modules (where the namespace is not directly available to the HTML DOM) and so it's likely that the simplest HTML will immediately hit these issues. Advice would be most welcome.
This works fine, but what if your original button was a bit more sophisticated and was parameterised?
There are a couple of solutions to that:
A data-* attribute:
<button id="the-button" data-string="withThisString">Do Something with String</button>
document.getElementById("the-button").addEventListener("click", function() {
doSomething(this.getAttribute("data-string"));
});
(More on this below.)
or
Binding the string when you bind the event
<button id="the-button">Do Something with String</button>
document.getElementById("the-button").addEventListener("click", () => {
doSomething("withThisString");
});
There are lots of variations on the above, and if you use doSomething with multiple buttons with different strings you can do #1 with a class and a loop rather than with an id, but that's the general idea.
Re the data-* attribute thing: If you wanted to, you could make this process entirely HTML-driven via data-* attributes and a single function that hooks things up. For instance, say you had these buttons:
<button data-click="doThisx#module1">Do This</button>
<button data-click="doThat#module2">Do That</button>
<button data-click="doTheOther#module3">Do The Other</button>
You could have a single reusable function to hook those up:
class EventSetupError extends Error {
constructor(element, msg) {
if (typeof element === "string") {
[element, msg] = [msg, element];
}
super(msg);
this.element = element;
}
}
export async function setupModuleEventHandlers(eventName) {
try {
const attrName = `data-${eventName}`;
const elements = [...document.querySelectorAll(`[${attrName}]`)];
await Promise.all(elements.map(async element => {
const attrValue = element.getAttribute(`data-${eventName}`);
const [fname, modname] = attrValue ? attrValue.split("#", 2) : [];
if (!fname || !modname) {
throw new EventSetupError(
element,
`Invalid '${attrName}' attribute "${attrValue}"`
);
}
// It's fine if we do import() more than once for the same module,
// the module loader will return the same module
const module = await import(`./${modname}.js`);
const fn = module[fname];
if (typeof fn !== "function") {
throw new EventSetupError(
element,
`Invalid '${attrName}': no '${fname}' on module '${modname}' or it isn't a function`
);
}
element.addEventListener(eventName, fn);
}));
} catch (error) {
console.error(error.message, error.element);
}
}
Using it to find and hook up click handlers:
import { setupModuleEventHandlers } from "./data-attr-event-setup.js";
setupModuleEventHandlers("click")
.catch(error => {
console.error(error.message, error.element);
});
It's one-time plumbing but gives you the same attribute-based experience in the HTML (the event handlers could still get parameter information from another data-* attribute, or you could bake that into your setup function). (That example relies on dynamic import, but that's supported by recent versions of all major browsers and, to varying degrees, bundlers.
There are a couple of dozen ways to spin that and I'm not promoting it, just giving an example of the kind of thing you can readily do if you want.
But really, this is where libraries like React, Vue, Ember, Angular, Lit, etc. come into play.
Although T.J Crowder has already answered this question I thought I might add a few points that are difficult to squeeze in as comments.
Once I got further into my Firebase V9 conversion I began to find that some of the consequences of the "module namespacing" issue were quite profound. The example cited in my initial question is easily dealt with, as above, but I found that I also needed to work out what to do about "dynamic" HTML responding to variable circumstances derived from a database. In this case, where my javascript would originally have created a string containing a block of HTML such as:
realDiv = `
<div>
<button onclick = "function fn1 (param1a, param1b,...);">button1</button>
<button onclick = "function fn2 (param2a, param2b,...);">button2</button>
etc
</div>
`
and then thrown this into a "real" realdiv defined in the HTML skeleton of the application with a
document.getElementById("realdiv") = realDiv;
Now, for the reasons described above, once the javascript is in a module, this arrangement no longer works.
The pattern I learnt to adopt (thanks, once again to T.J Crowder) went along the following lines:
realDiv = `
<div>
<button id = "button1" data-param1a="param1a" data-param1b="param1b";">button1</button>
<button id = "button2" data-param2a="param2a" data-param2b="param2b";">button2</button>
etc
</div>
`
I would then throw the generated code into my HTML skeleton as before with
document.getElementById("realdiv") = readlDiv;
and now that you've got the code embedded into the DOM (and assuming that I've kept a count of the number of buttons you've generated) I would create bindings for them with a final bit of javascript like so:
for (let i = 0; i>buttonCount; i++) {
document.getElementById('button' + i).onclick = function() { fn1 (this.getAttribute('data-param1a'), this.getAttribute('data-param1b') };
etc
}
I found that creating onclicks with this pattern was particularly helpful in maintaining clarity when I needed to make the onclick launch a number of different functions.

Odoo Override Javascript Class Method

First off, my apologies - I'm a complete novice when it comes to javascript so this is a bit above my head. I'm also fairly new to Odoo and have mostly stuck with python and XML customization thus far.
I'm trying to override a javascript method within a class to replace it completely with my own version. From the Odoo documentation (https://www.odoo.com/documentation/14.0/reference/javascript_reference.html#patching-an-existing-class) this should be a simple matter of using the .include() method to patch the original class with my new method. But when I do this I get an error Error while loading mymodule.CustomControlPanelModelExtension: TypeError: ControlPanelModelExtension.include is not a function
The original Odoo code that I'm trying to override:
odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", function (require) {
"use strict";
// a bunch of code here ...
class ControlPanelModelExtension extends ActionModel.Extension {
// more code here ...
// this is the method I'm trying to override
_getAutoCompletionFilterDomain(filter, filterQueryElements) {
// original method body here
}
// more code
}
// more code
});
Below is what I came up with based on the documentation but this gives me the error Error while loading mymodule.CustomControlPanelModelExtension: TypeError: ControlPanelModelExtension.include is not a function (this error is reported in browser dev tools console).
odoo.define('mymodule.CustomControlPanelModelExtension', function(require) {
"use strict";
var ControlPanelModelExtension = require('web/static/src/js/control_panel/control_panel_model_extension.js');
ControlPanelModelExtension.include({
// override _getAutoCompletionFilterDomain
_getAutoCompletionFilterDomain: function(filter, filterQueryElements) {
// my custom implementation here
},
});
});
Any ideas what I'm doing wrong here? I've tried various other things with extends and such but I don't think I want to extend - that won't replace the function in existing instances.
The problem here is that the include function is available only for the classes that inherit from OdooClass and in this case the class you are trying to inherit is a native JavaScript class.
Then, to add a property or method to a class, the prototype property of the object class must be modified.
odoo.define('mymodule.CustomControlPanelModelExtension', function(require) {
"use strict";
const ControlPanelModelExtension = require('web/static/src/js/control_panel/control_panel_model_extension.js');
function _getAutoCompletionFilterDomain(filter, filterQueryElements) {
// your custom implementation here
}
ControlPanelModelExtension.prototype._getAutoCompletionFilterDomain = _getAutoCompletionFilterDomain;
return ControlPanelModelExtension;
});

Getting error while creating a custom element which extends SVGCircleElement

I have the following Node class which I am using to create a custom element node-element.
class Node extends SVGCircleElement{
static get observedAttributes() {
return ["coordinates"];
}
constructor()
{
super();
this.attributeMap = {coordinates:(coordinates)=>this.updateCoordinates(coordinates)}
}
connectedCallback(){
this.initNode();
}
updateCoordinates(coordinates)
{
this.setAttribute("cx",`${coordinates.x}`);
this.setAttribute("cy",`${coordinates.y}`);
this.setAttribute("r",50);
}
initNode()
{
this.className="node";
}
attributeChangedCallback(name,oldValue,newValue)
{
if(oldValue!==newValue)
{
this.attributeMap[name](JSON.parse(newValue))
}
}
}
I register this element using:-
customElements.define('node-element',Node);
I am creating this element as follows:-
let newNode = document.createElement("node-element");
This is where I get the following error:-
Uncaught TypeError: Illegal constructor
at new Node (index.js:9)
at SVGSVGElement.drawNode (index.js:43)
Line 43 corresponds to the createElement code.
Would love to be proven wrong, just spent 2 months on an SVG project
AFAIK, you can NOT extend SVG elements
You can only create Custom Elements in the HTML Namespace http://www.w3.org/1999/xhtml
SVG Elements are in the SVG Namespace http://www.w3.org/2000/svg
From the docs: https://html.spec.whatwg.org/multipage/custom-elements.html#element-definition
If the element interface for extends and the HTML namespace is HTMLUnknownElement,
then throw a "NotSupportedError" DOMException.
and
if namespace is not the HTML namespace, return null
The ongoing W3C discussion on allowing other namespaces is here: https://github.com/w3c/webcomponents/issues/634
The HTML Namespace has restrictions too
Apple/Safari implemented the Autonomous Custom Elements (extend from HTMLElement)
but refuses to implement Customized Built-In Elements (extend any Built-In element from the HTML Namespace)
If you want to generate SVG, you have to extend HTMLElement and generate the whole SVG tag:
<svg xmlns='http://www.w3.org/2000/svg' viewBox='V'><circle cx='X' cy='Y' r='R'/></svg>
Related Custom Element SVG StackOverflow Question and Answers
javascript - change the background of an active icon on a menu
Getting error while creating a custom element which extends SVGCircleElement

Have JS APIs return a polyfilled DOM node

I am using the <data> element in HTML, which has decent support, but not sufficient for my purposes. The only extra functionality in HTMLDataElement is a getter for value, which returns the respective attribute.
Of course this is trivial to implement, just use the following code (after feature detection, of course)
class HTMLDataElement extends HTMLElement {
constructor() {
super();
}
get value() {
return this.getAttribute(`value`);
}
}
This works great. Only one problem: when using native APIs such as getElementById, querySelector, etc., the returned Node is not of instance HTMLDataElement. How can I make it so, if this is even possible?
To be clear, I'd like to be able to do document.querySelector('foo').value, which would act the same with or without browser support for <data>.
(I'm well aware that I can just use .getAttribute('value') instead of .value. The point is I don't want to.)
After reaching out to Jonathan Neal* on Twitter, he provided a great example of how this can be done.
if (!this.HTMLDataElement) {
this.HTMLDataElement = this.HTMLUnknownElement;
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLDataElement.prototype, 'value');
Object.defineProperty(
HTMLDataElement.prototype,
'value',
valueDescriptor || {
get() {
return 'DATA' === this.nodeName && this.getAttribute('value');
}
}
);
}
console.log('data:', document.querySelector('data').value);
console.log('xdata:', document.querySelector('xdata').value);
<data value="this should work">
<xdata value="this should not work">
* For the unfamiliar, Jonathan Neal is large contributor of PostCSS plugins, and has created many JavaScript polyfills himself.

Custom Elements Illegal Constructor in FireFox

I have a CustomElement with the following constructor:
export default class SomeCustomElement extends HTMLElement {
constructor(templateId) {
super();
this.insertTemplateInstance(templateId);
}
...
}
I can register that Element in Chrome without any Problems.
But using Firefox with the polyfill loaded by webcomponents-loader.js from https://github.com/webcomponents/webcomponentsjs I get the ErrorMessage TypeError: Illegal constructor when calling super().
Does anybody know what is causing this?
Some more Background:
Registering of the custom Elements happens like this:
window.addEventListener("WebComponentsReady", function () {
customElements.define(elementName, SomeCustomElement);
});
Use webcomponents-lite.js instead of webcomponent-loader.js if you don't want to have this kind of error, which is caused by the fact that the polyfills will be loaded asynchronously if you use webcomponents-loader.js.
The example below works fine with Firefox (and every modern browser):
class SomeCustomElement extends HTMLElement
{
constructor()
{
console.log( 'created' )
super()
}
connectedCallback() {
console.log( 'connected' )
this.innerHTML = "Hello"
}
}
customElements.define( 'c-e', SomeCustomElement )
<script src=https://rawgit.com/webcomponents/webcomponentsjs/master/webcomponents-lite.js></script>
<c-e></c-e>
However if you still want to use webcomponents-loader.js, you'll have to insert your custom element definition in an external file, and load it with HTML Imports:
<link rel="import" href="my-element.html">
Caveat upfront: I'm not a huge fan of html imports. I stumbled across this trying to get ES 6 class-based custom elements to work in Firefox. For a conditional-polyfill-loading-no-html-import solution based on the accepted answer, read on...
To conditionally load the polyfills gets a little tricky. Per #Supersharp's answer/comments, for some reason the polyfill must be loaded synchronously (despite there being no mention of this in the official documentation). So now you have two unappealing options: include it unconditionally to get the necessary synchronous loading or...use document.write:
<script>
;(function() {
var str = '';
// You could make this more fine-grained if desired by doing
// more feature detection and loading the minimal polyfill file
if (!window.customElements) str += '<script src="./node_modules/#webcomponents/webcomponentsjs/webcomponents-lite.js"></script>';
str += '<script src="./elements.js"></script>';
document.write(str);
})();
</script>
<foo-bar></foo-bar>
Then in elements.js:
class FooBar extends HTMLElement {
constructor () {
console.log("constructing");
super();
}
connectedCallback () {
console.log("connecting");
}
disconnectedCallback () {
console.log("disconnecting");
}
};
// Note that because of the synchronous loading we don't
// need to listen for the event
customElements.define('foo-bar', FooBar);
document.write is widely disliked for good reasons but this is IMHO a legitimate use case. Note that most of the objections here (no pre-fetch, etc.) can be ameliorated through use of service workers (for browsers that support them).

Categories