Open Swivelgames opened 2 years ago
Correct me if I am wrong - importDoc
refers to #645 spec but ultimately we are ignoring it.
The funny part to me is that this issue made me aware that this is actually not possible. As a regular developer, I expected the below code to work.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello World!</title>
<script src="/script.js" type="module"></script>
</head>
<body>
<template id="buttonTemplate">
<button onclick="this.clickHandler()">My button</button>
</template>
<my-button>Button</my-button>
</body>
</html>
customElements.define('my-button',
class extends HTMLElement {
constructor() {
super();
let template = document.getElementById('buttonTemplate');
let templateContent = template.content;
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(templateContent.cloneNode(true));
}
clickHandler() {
console.log(true)
}
}
);
I think this is a great idea. That should allow to write less code in the component's class (making them look cleaner) and result in expected (?) behavior.
EDIT: okay I can see in the title now that it explicitly intends to refer to the HTML Modules. I still think that the code above should work.
I think in this example this
refers to the element itself so I guess this.context.clickHandler()
could do the trick
From @Jamesernator in #645:
This would alleviate under-utilization of inline JavaScript as it is right now. Being able to use
on*=
attributes, and others, while referencing the included class would be incredibly useful.This sort've concept is interesting, but web specs don't add features that have special powers to be able to do things in other JS scopes. Like in order to resolve
this.#handleClick
somehowshadowRoot.appendChild(template.content.cloneNode(true))
would have to be able to look into the constructor ofthis
to find private fields, this isn't a capability the language offers.A general rule of thumb, is that web objects don't really have more power than JS objects, they are just (potentially) implemented in a different language. In this case there would be no way to implement something like what you were describing:
class ShadowRoot extends DocumentFragment { appendChild(node) { if (node.hasAttribute("onclick")) { // No way to access this.#handleClick in this method // so web specs can't either } } }
The exact reasons for this general rule of thumb are varied, some reasons include the ability to separate the JS engine from the browser, some are to allow the engine to optimize things it otherwise couldn't, some are just to respect the intended design of features (i.e. private means private), some are to allow JS to faithfully mock APIs in tests, etc.
@Jamesernator made a good point about private members. However, I still feel that this may still be viable for public members.
From @clshortfuse in #645:
You can already create this monstrosity:
<template id="myCustomElementTemplate"> <button onclick="this.getRootNode().host.constructor.handleClick.call(this, event)">Click me!</button> </template>
And yes, it works and is cloneable. Though
on*
has the browser construct a new function and bind it with a EventListener that cannot be removed from JS. That doesn't scale well for very large DOMs (eg: table grid cells withclick
).
Quoting this in here for posterity's sake.
@clshortfuse The only problem with this is that it still scopes the event to the element/node, and not the class itself. At that point, any reference to this
inside handleClick
(like this.showLoadingSpinner()
as an example) would fail because the <button>
element does not an element this.showLoadingSpinner()
.
I also wanted to address one point that someone else brought up in a different conversation. The concern was that you would lose access to the <button>
element via this
. But given the fact that you have access to event.target
, this won't be a problem.
The general problem right now is that there is no intuitive way to reference the class associated with the custom element inside the DOM of the custom element. The class can reference the DOM, but the DOM cannot reference the class instance. This makes libraries like React, Vue.js, and others far more attractive to developers, which becomes problematic when trying to make the argument for leveraging agnostic web components.
In my personal opinion, for whatever its worth, this is probably one of the single biggest pieces that could be introduced to the web component standards to bolster the adoption of pure web components: Intuitive access to the class instance via this
from within the DOM.
@Swivelgames in your case you can just do this.getRootNode().host.showLoadingSpinner()
.
My use case was ButtonComponent.onButtonClick(this: HTMLButtonElement)
which is a static function. But yeah, it makes more sense to use a method since JS will add it to the prototype once anyway (wasn't sure when I wrote that).
With events this
is always the element the listener is attached to, which is the same as event.currentTarget
. Also, in that inline code (event
) is available in the scope, even if you never declared it.
Given any ShadowDOM element, you can get the ShadowRoot of the Web Component in question with this.getRootNode()
. After that you can now get the ShadowRoot's host element with .host
.
If you want to access the Web Component's static fields, you access .constructor
. But if you want to access the prototype (class fields), .host
would suffice.
Still, I don't recommend cloning templates like that. I moved to setting up a list of events to be bound on constructor()
. There's no clear way to not iterate all the event listeners related to your ShadowRoot tree while having all instances share bind to the same function. It's either iterate or browser built anonymous functions.
@clshortfuse You're absolutely correct. Oops. Thanks for calling me out on that haha.
So, really, to boil down the request, it would be to change the context (this
) of children of a ShadowDOM element to the ShadowRoot. This would make it more intuitive to reference public class members.
This would actually simplify your previous example down to:
<template id="myCustomElementTemplate">
<button onclick="this.handleClick.call(event.currentTarget, event)">Click me!</button>
</template>
But yeah, I would agree that just using it as a prototype method would be more intuitive, like
<template id="myCustomElementTemplate">
<button onclick="this.handleClick(event)">Click me!</button>
</template>
I don't think any proposal here should be part of HTML modules, which don't really have a lot to do with custom elements other than being a container for templates and possibly declarative custom element declarations.
Binding expressions in templates to a JS objects should be done at the template instantiation and declarative custom elements layers.
Direct import of HTML templates as DocumentFragment
would be fine for me and my use cases. I do similar for CSS imports. Right now mine are written like this (I've stripped class extensions to a declarative functional style), so here's a real-world example of how it would help me:
import styles from './Card.css' assert { type: 'css' };
+import template from './Card.html' assert { type: 'html' };
import Container from './Container.js';
export default Container
.extend()
.css(styles)
- .html`
- <slot id=primary-action name=primary-action></slot>
- <div id=outline></div>
- `
+ .html(template)
.autoRegister('mdw-card');
Perhaps extending onclick
to export host
as a parameter could be a tangential solution. Currently, it exposes this
and event
. Why not host
as well? this
is actually unclear anyway (element, static, or instance?), and I personally try to avoid the reference whenever possible.
<template id="myCustomElementTemplate">
- <button id=button><slot></slot></button> // Manually bind in constructor
- <button on-click="{handleClick}"><slot></slot></button> // Custom attribute requiring interpolation
- <button onclick="this.getRootNode().host.handleClick()"><slot></slot></button> // Unintuitive way to auto-bind
+ <button onclick="host.handleClick()"><slot></slot></button> // Clear
</template>
<script type="module">
/* ... */
class myCustomElement extends HTMLElement {
static {
customElements.define('x-button', this);
}
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: "open" });
const template = document.getElementById("myCustomElementTemplate");
shadowRoot.appendChild(template.content.cloneNode(true));
- // Manual bind
- shadowRoot.getElementById('button').addEventListener('click', this.handleClick);
- // Interpolate
- for (const el of shadowRoot.querySelectorAll('[on-click]')) {
- el.addEventListener('click', this[
- /^{(.+)}$/.exec(el.getAttribute('on-click'))[1]
- ]);
- }
}
handleClick() {
window.alert('You clicked me!');
}
}
/* ... */
</script>
<x-button>Click me</x-button>
This way the browser doesn't really have to parse anything or walk the tree. It just uses the standard onclick
. Browser implementers can choose to optimize by sharing a single anonymous function as long as the template being cloned is the same. You still don't have the ability to remove the event listener, but I'll be honest, authors can tap into connectedCallback()
or constructor()
if they want that level of control. Same with passive events.
Edit: Removing the event listener is simply done by removing the attribute, or setting element.onclick = null
, which is standard anyway. Just leaves passive listeners...
host
in scope can be polyfilled like this:
+ <template id="hostScopeTemplate">
+ <div>
+ <script>var host = null</script>
+ <button hidden onclick="host = this.getRootNode().host" />
+ </div>
+ </template>
<template id="myCustomElementTemplate">
<button onclick="host.handleClick()"><slot></slot></button>
</template>
<script type="module">
+ let scopeTemplate;
+ function setHostInScope(shadowRoot) {
+ scopeTemplate = scopeTemplate ?? document.getElementById('hostScopeTemplate');
+ shadowRoot.appendChild(scopeTemplate.content.cloneNode(true));
+ shadowRoot.querySelector('button').click()
+ shadowRoot.querySelector('div').remove();
+ }
/* ... */
class myCustomElement extends HTMLElement {
static {
customElements.define('x-button', this);
}
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: "open" });
+ setHostInScope(shadowRoot);
const template = document.getElementById("myCustomElementTemplate");
shadowRoot.appendChild(template.content.cloneNode(true));
}
handleClick() {
window.alert('You clicked me!');
}
}
/* ... */
</script>
<x-button>Click me</x-button>
Hacky, but it works. It's probably cleaner to have myCustomElement
extend something other than HTMLElement
for multiple components. There's no access to private fields though.
The click workaround is because scripts can't know it's element/context.
https://github.com/whatwg/html/issues/1013
If it wasn't for that, we could eliminate the hidden click and it would be one liner that doesn't need to be removed.
In practice, I can strip out of a bunch of JS code related to interpolation (no passive though).
Edit:
Turns out scripts aren't sandboxed in shadow DOM.
Related: https://discourse.wicg.io/t/script-tags-scoped-to-shadow-root-script-scoped-src/4726/12
This may require its own champion and proposal/explainer, but I feel like it falls well enough in line that it might be worth mentioning in relation to HTTP Modules.
The idea is, essentially, a mechanism to allow children of a ShadowRoot to access the instance of the element intuitively. This may include waiting to evaluate JS references in attributes for children of a
template
tag until the ShadowDom is registered. Even if all we do is set the context (this
) to the currently relevant instance, or even provide agetter
on all children for retrieving the instance of the element. (i.e.,this.shadowRoot
,this.root
,this.context
,this.parent
, or something similar).This would alleviate what is, in my opinion, under-utilization of inline JavaScript as it is right now. Being able to use
on*=
attributes, and others, while referencing the included class would be incredibly useful.Again, this may very well need to be in its own proposal.
This could potentially pave the way for other mechanisms, including (one which is certainly out of the scope of this proposal): binding attribute values to instance properties, and so on.
Originally posted by @Swivelgames in https://github.com/WICG/webcomponents/issues/645#issuecomment-1114372075