Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A new attribute similar to is="", but would allow multiple behaviors to be attached to a given element. #662

Closed
trusktr opened this issue Sep 6, 2017 · 35 comments

Comments

@trusktr
Copy link
Contributor

trusktr commented Sep 6, 2017

Currently, is="" is spec'd to define ways in which a Custom Element can extend from a native element but without having to write the Custom Element's name inside the DOM because it would otherwise break ancient parsing rules and therefore cause unexpected breaking behavior which leads to confusion. See issue #509 detailing the problems with the currently-spec'd behavior.

For example, one of the great uses for is="" is that it solves problems with tables and other elements, where using a custom element the normal way will not work:

<table>
  <my-tr></my-tr>
</table>

because in that example the engine specifically expects a <tr> element, and does not look at the composed tree as a source of truth (like it should).

The current spec allows a solution using the is="" attribute,

<table>
  <tr is="my-tr"></tr>
</table>

so that the table can be parsed and rendered correctly, but this currently requires awkward and confusing inheritance patterns as mentioned in #509.


Here's a very simple proposal that would repurpose is="" (or an attribute with a new name) for allowing developers to attach any number of behaviors ("components") to an Element (originally described in #509 (comment)).


The main idea of that original comment is that we can add any number of behaviors to an element while not being required to extend from the element's original interface/class.

F.e.:

components.define('foo', class {
  constructor(el) {
    // do something with el
  }
  disconnectedCallback() {}
  // Or similar life cycle methods
})

components.define('lorem', class {
  constructor(el) {
    // do something with el
  }
  connectedCallback() {}
  attributeChangedCallback() {}
  // Or similar life cycle methods
})
<table>
  <tr is="foo lorem">...</tr>
</table>

Note that these "components" do not directly or indirectly extend (and are required not to extend from) Element. (This limitation may need to be further fine-tuned; perhaps they are not allowed to extend from Node? This is just an example.) It wouldn't make sense, for example, for the <tr> to be both HTMLTableRowElement and HTMLButtonElement... or would it? This can be discussed later.

In the example, the foo and lorem components are simple classes that don't inherit from any other class, and using lifecycle hooks similarly to Custom Elements we can still do interesting things with the target element.

We can also attach more than one component to a given element. And components don't need to be exclusively limited to a single type of element like with the current is="" attribute!


As an idea to prevent possible confusion with the existing natively-implemented is="" in some browsers, this attribute might be named something else, f.e. maybe components="", which is nice, but more typing. Maybe comps for short? Etc. is="" is still nicest.

<table>
  <tr components="foo lorem">...</tr>
</table>

is="" is still the nicest because it is short and simple.


IMHO, this new is="" attribute functionality is much more desirable than the currently confusing is="" attribute, and follows the proven approach that well-known libraries (old or new) take today (jQuery, Vue, etc).

@trusktr
Copy link
Contributor Author

trusktr commented Sep 6, 2017

If an approach like this were to be taken, it would be worth thinking up front about component encapsulation. For example, maybe component names can be defined on a per-root basis rather than globally:

root.components.define('bar', class {
  // ...
})

root.components.define('baz', class {
  // ...
})

This would be similar to the idea for encapsulated per-shadow-root custom element names, but the idea here would be a brand new idea to the web so there wouldn't be naming conflicts with existing components (because none exist, unless the web wants to own components and release native ones, but I don't see why it should if it never has before).


We can also let component names not have hyphens, as opposed to Custom Elements.

@trusktr trusktr changed the title Repurpose the is="" attribute (or make a new attribute with a new name) Repurposing the is="" attribute to allow multiple behaviors to be attached to a given element Sep 6, 2017
@trusktr
Copy link
Contributor Author

trusktr commented Sep 6, 2017

@rniwa You said in the other thread

Yeah, that's pretty much mixins, and it makes a lot more sense than the current inheritance model of is. One major challenge there is defining which one wins when the two define conflicting behaviors and properties.

Properties would normally be defined on the component instances. F.e. an instance of the foo component could have a bar property on it's this, and so can an instance of the lorem component. There'd be no conflict in this regard because they're separate instances. This would be the recommended way to write components.

However, there could be a conflict if components assign properties onto the Element instance that is passed into the component's constructor(el) {} of course. In this case, there's no way to prevent conflicts.

But in order to discourage developers blindly assigning properties onto an Element, there could be an API that makes it easy to get a component instance from the Element, f.e.

// .components is readonly, perhaps frozen or sealed
const {foo} = someElement.components

foo.someMethod()
console.log(foo.someProperty)

This way it would be possible for a component author to expose public methods and for them not to clash with methods of another component.

<div is="audio-player" src="./foo.mp3"></div>

<script>
  const audioDiv = document.querySelector('div[is="audio-player"]')
  const player = audioDiv.components['audio-player']
  // or maybe, const player = audioDiv.components.audioPlayer

  player.pause()
  // ...
  player.resume()
  // ...
  const audioNode = player.getNode() // WebAudio API
  // ... connect output to another node ...
</script>

@trusktr
Copy link
Contributor Author

trusktr commented Sep 6, 2017

Another idea could be that the Element reference passed into a component's constructor could actually be a Proxy to the Element, which forbids the component from writing to the element. The component could read properties or call methods, or even set certain properties that are part of the element's API (f.e. setting .value of an input element), but the Proxy would prevent components from creating new properties. This might prevent some much clashing, though components could still clash if they both set .value of an input for example.


Or maybe there could be a way for a component to statically define which properties it will control, and the first component to be added which controls such properties takes precedence. f.e.

class SomeComponent {
  static controlledProperties = ['value']
  static elementTypes = ['input'] // limit the component to `input` elements.

  // ...
}

Then this component would control the value of value in whatever way it wants. If a second component was added that also wanted to control the same property, it could perhaps receive an error whenever it tries to do so, and act accordingly. f.e.

class ComponentTwo {
  static controlledProperties = ['value']
  static elementTypes = ['input'] // limit the component to `input` elements.

    // ... in some method ...
    try { this.el.value = "blah" } catch(error) {
      // ... react accordingly, error might have useful info, f.e.
      // "Component "foo" already manipulates "value" property."...
    }
}

Would some sort of conflict-avoidance feature like that be too complicated of a to add to such a spec if it were spec'd?

@wiredearp
Copy link

wiredearp commented Sep 6, 2017

Couldn't you just use another attribute instead of is and not rewrite the entire specification?

<table is="very-generic" also="bar lorem"><table>
const behaviors = {
  bar: { connectedCallback(elm) { elm.classList.add('bar'); } }
  lorem: { connectedCallback(elm) { elm.classList.add('lorem'); } }
};
const getbehaviors(elm) => {
  return elm.getAttribute('also').split(' ').map(name => behaviors[name]);
}
CustomElements.define('very-generic', class {
  connectedCallback() {
    getbehaviors(this).forEach(b => b.connectedCallback(this));
  }
});

– or solve it entirely in the JavaScript layer with some kind of mixin (or plugin) strategy? It seems like the high level frameworks could facilitate something like this in a number of ways without requiring a change to the low level spec, which I assume should be either based on inheritance with all the imagined problems it can cause or mixins with all the actual confusement it will cause; certainly not both via the same attribute?

@tomalec
Copy link
Contributor

tomalec commented Sep 6, 2017

I really like the concept of such components, which are just adding functionality on top of existing elements, by plugging in into CEReactions, It would allow enhancing problematic native elements like <tr>, elements with specific parsing context <template>, and do more progressive enhancement.


However speaking of details in encapsulation and conflicts resolution, I'm thinking about simpler and maybe more naive approach.
I, as a developer, use Custom Elements to enrich the functionality of actual elements, not their properties (like someElement.components.['audio-player']). To react to element changes and manipulating a separated object, addEventListener and few additional Events should do the job. What I like the most about CE is that I work on the exact element, its properties, and attributes which are exposed as for any other native element. (For a <button> element you do element.checkValidity() not element.button.checkValidity() that's why I'd like to be able to do the same for <tr is="foo-button">)

Then to answer @rniwa

One major challenge there is defining which one wins when the two define conflicting behaviors and properties.

What if those components/mixins have access directly to the element instance, but the order of names in is value matters? That will be ordered list of mixins, and CEReactions would be called in that order. Then it's the responsibility of the author that mixes many behaviors, to mix such ones and in such order, they would not collide.

If both mixins are defining a property setter with the same name, it will result in TypeError as it is in a regular case.
I would expect, some good practices would evolve around, for example, to check for existence of .shadowRoot before attaching one, or to prefix custom event names.

Even, if we limit ourselves to the case of only one component mixed by is to avoid collisions between mixins as described above, I believe the idea of enhancing the native element without actually extending its class, just by plugging into the CEReactions is worth keeping.

We would have "autonomous custom elements" that does class MyElement extends HTMLElement {connectedCallback:/*...*/}, and "custom element enhancements" that does just define {connectedCallback:/*...*/} which is plugged in via is (or other) attribute

The definition of such custom enhancement does not have to specify what it would extend, or it could be just an optional feature.

Then consistently we could have "enhanced autonomous custom element".

Like:

<third-party-element is="my-enhancement">
customElements.define('third-party-element', class ThirdPartyElm extends HTMLElement{connectedCallback:/*...*/});
// ...
customElements.defineEnhancement('my-enhancement', {connectedCallback:/*...*/});

@wiredearp
Copy link

wiredearp commented Sep 6, 2017

I don't want to come off as cynical, but you do realize that the current spec is based on decades worth proto-specification [1] which after an all out and still unfinished interstellar war between browser vendors has culminated in the is attribute as we know it today? Even if it was still up for discussion, which I guess is fair and right, your specific suggestion would break the web in production, so why not ask instead: If Vue or some other framework can provide the developer experience you enjoy today, and without using Custom Elements, why should the spec be changed to emulate this pattern? It is always the frameworks job to provide an awesome API for folks building websites while it is the specifications job to provide a tedious API for folks building frameworks, which is different. In my unaffiliated opinion, the is attribute should only be changed at this late stage if it prevents you somehow from building a component based framework that can work just like you imagine. You also wouldn't change how appendChild and insertBefore works just because React provides a cooler API for generating DOM structure, since after all it was implemented using these exact methods.

[1] See https://www.w3.org/TR/sXBL/ and https://www.w3.org/TR/xbl/ and https://msdn.microsoft.com/en-us/library/ms531079(v=vs.85).aspx

@jimmont
Copy link

jimmont commented Sep 6, 2017

If this is intended for mixins consider adding a new, non-conflicting attribute as has= <any has="this, that, more, extensions"></any> and <any has="stuff"></any> to associate something with any existing element. And this simply extends the existing classes, hooking into the existing lifecycle of the element. This might address all the cynicism and skepticism while at the same time addressing the practical needs and realities of today.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 7, 2017

@wiredearp @jimmont I think you guys missed the part in which I mentioned an alternate name might be better considering is="" already exists and does what it does:

As an idea to prevent possible confusion with the existing natively-implemented is="" in some browsers, this attribute might be named something else, f.e. maybe components="", which is nice, but more typing. Maybe comps for short? Etc. is="" is still nicest.

<table>
  <tr components="foo lorem">...</tr>
</table>

is="" is still the nicest because it is short and simple.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 7, 2017

@jimmont Your new issue #663 is effectively the same idea, but with alternate naming. I agree, maybe using is="" could be confusing.

Spec authors, WDYT?

@trusktr
Copy link
Contributor Author

trusktr commented Sep 7, 2017

@wiredearp

your specific suggestion would break the web in production

Not necessarily, because not all browsers even support is="" natively yet (Safari for example). Some apps might break, but those apps are currently relying on not-yet-fully-official functionality (it is spec'd, but it isn't official if not all browsers want to implement it).

If we were to change the spec, and all browsers wanted to implement the new spec, then it'd be official.

That said, I'm open to the idea of using an alternate name other than is="". THe concept itself is the main idea here, which I think is much better than what the current is="" spec provides.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 7, 2017

@wiredearp

It is always the frameworks job to provide an awesome API for folks building websites while it is the specifications job to provide a tedious API for folks building frameworks, which is different.

I disagree. It should be very easy for people to use native web tech to easily build apps, without necessarily needing a framework. I think this is a good goal.

Your example,

<table is="very-generic" also="bar lorem"><table>
const behaviors = {
  bar: { connectedCallback(elm) { elm.classList.add('bar'); } }
  lorem: { connectedCallback(elm) { elm.classList.add('lorem'); } }
};
const getbehaviors(elm) => {
  return elm.getAttribute('also').split(' ').map(name => behaviors[name]);
}
CustomElements.define('very-generic', class {
  connectedCallback() {
    getbehaviors(this).forEach(b => b.connectedCallback(this));
  }
});

is much too verbose. This isn't relying on any new native behavior, it's just mapping some "behaviors" to CE callbacks manually, which is a good conceptual of making a polyfill for the idea here. And it's easy to make such a polyfill.

I guess maybe you want to show that a feature like the one I proposed in the original post can easily be implemented in JavaScript. You're right, it probably can be, and this is due to the fact that behaviors are not required to extend from native classes like HTMLElement. This fact alone makes everything just easy to work with.


The thing is, if it were spec'd and became standard, people could rely on it, and not have to choose between a gazillion different frameworks and libraries, and it'd work in every single web application without compatibility issues (or with the least amount of compatibility issues) because it would be API guaranteed to exist in every browser.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 7, 2017

As @jimmont suggested, maybe has="" is the best name as it doesn't conflict with existing is="" that people may currently expect, however limited and confusing the feature is.

Here's an example based on what I like best so far from the three discussions (#509, #662, #663):

class Foo {
  connectedCallback(el) {
    // do something with el
  }
}


class Bar {
  attributeChangedCallback(el, attr, oldValue, newValue) {
    // do something with el's changed attribute
  }
}
behaviors.define('foo', Foo)
behaviors.define('bar', Bar)
<!-- it "has" these behaviors -->
<any-element has="foo bar" />

But the defining part could also be

components.define('foo', Foo)
components.define('bar', Bar)
<!-- it "has" these *components* -->
<any-element has="foo bar" />

If components or behaviors are properties on the global, maybe it would be better to name them more specifically:

elementBehaviors.define('foo', Foo)
elementBehaviors.define('bar', Bar)

or

elementComponents.define('foo', Foo)
elementComponents.define('bar', Bar)

Going with "behaviors", here's what it looks like on a shadow root:

root.elementBehaviors.define('foo', Foo)
root.elementBehaviors.define('bar', Bar)

And here's what getting those components from an element and calling a method looks like:

anyElement.behaviors.foo.someMethod()

Here's an entity-component example. Imagine some game made with Custom Elements (rendering to WebGL for the sake of awesome):

<ender-man has="player-aware holds-block" holds="dirt" position="30 30 30">
</ender-man>
<play-er position="40 40 30">
</play-er>

then later the player gets away and the ender man behaves differently:

<ender-man has="holds-block" holds="sand" position="30 30 30">
</ender-man>
<play-er has="diamond-armor horse-inventory" position="100 150 40">
</play-er>

One might think, why not just use attributes, like the following?

<ender-man player-aware="true" holds="dirt" position="30 30 30">
</ender-man>

Well, then this means that there can only be one class associated with the ender-man element, and this is just a Custom Element.

The downside of this is to handle multiple piece of logic, they need to be encapsulated in a single class (f.e. EnderMan). If those multiple behaviors are to be re-usable independently of other behaviors, then they would have to somehow be mixed into the single class, and now we're introducing more complexity and more likelihood for conflicts.

So the approach where behaviors can be entirely separate classes (but with Custom Element callbacks) is a win because they can all be decoupled from each other more-so than mixing stuff into a single class.


Behaviors can be added and removed. In the above example, when we removed the player-aware behavior (which made the Ender Man try to attack the player), you can imagine that just like Custom Elements, perhaps the detachedCallback was called.

Or, maybe for behaviors, there could be something else like a removedCallback which is similar to detachedCallback except that it represents when the behavior is removed from the element, not when the element is removed from DOM.

It would be possible, for example, to have logic specifically for when the behavior is removed, and logic for specifically when the element is detached (but the element still have the behavior).

@trusktr trusktr changed the title Repurposing the is="" attribute to allow multiple behaviors to be attached to a given element A new attribute similar to is="", but would allow multiple behaviors to be attached to a given element Sep 7, 2017
@trusktr trusktr changed the title A new attribute similar to is="", but would allow multiple behaviors to be attached to a given element A new attribute similar to is="", but would allow multiple behaviors to be attached to a given element. Sep 7, 2017
@prushforth
Copy link

@trusktr So are you proposing a) <web-map mixin="map mymapbehaviour"> or b) <map mixin="map mymapbehaviour"> where behaviour named "map" is the native behaviour that I was getting from inheriting from HTMLMapElement ? (Using @rniwa 's terminology here, since he's the one objecting to single inheritance). Use tr / my-tr if you have to run with that in your explanation.

@treshugart
Copy link

It might be worth looking at custom attributes. @matthewp has written something that might be a good basis for discussion: https://github.com/matthewp/custom-attributes. It would fit the custom element model quite well.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 8, 2017

@treshugart That's an interesting concept, but I think it may fulfill a different purpose. I believe that that concept would be usable in tandem with this concept, but it doesn't explicitly replace the concept here. That concept, for example, doesn't seem like a good fit to solve table > tr problems, whereas this one seems like a much better fit.

That one hooks into the life cycle for specific attributes, which might be useful in some ways, but this one uses the custom-element life cycle methods, which are a bit different, and in fact these "behaviors" can observe changes to attributes of an element, including custom global attributes.

I believe these two concepts can live exclusively from each other, and it may be nice to have both.

I didn't have much time write this response, I'd like to perhaps make a simple polyfill for this idea and then show the mix of the two ideas, later...

@trusktr
Copy link
Contributor Author

trusktr commented Sep 8, 2017

@prushforth

So are you proposing a) or b) where behaviour named "map" is the native behaviour that I was getting from inheriting from HTMLMapElement ?

Not quite. The behaviors can be applied to absolutely any element (unless there's a way to limit which elements a behavior can be applied to, but I'll skip that idea for now).

So, for example, suppose we have behaviors foo and bar defined:

class Foo { ... }
class Bar { ... }
elementBehaviors.define('foo', Foo)
elementBehaviors.define('bar', Bar)

They can be used on any element:

<div has="foo bar"></div>
<div has="foo"></div>
<map has="bar"></map>
<any-element has="foo bar"></any-element>

It's merely a way to instantiate a specific class for any element, so that the instantiated class can react to the lifecycle of the target element.

In

<div has="foo bar"></div>

There's at least three things happening:

  1. An instance of HTMLDivElement is created, with whatever logic/properties it has. this inside of it's methods refers to the element itself.
  2. An instance of Foo is created, with whatever logic/properties it has. this inside of it's methods refers to the the instance of Foo, with a reference to the div element passed into its constructor and/or other methods. The this in the methods of the Foo instance do not refer to the element.
  3. Similar for bar as in point 2, just replace Foo with Bar.

How you use these behaviors and for what purpose is up to you (you choose which behaviors to apply to which elements).

In your examples, <web-map mixin="map mymapbehaviour"> and <map mixin="map mymapbehaviour">, both are valid.

In the

<web-map mixin="map mymapbehaviour">

example, if web-map is a Custom Element, then we have the following happening:

  1. An instance of WebMap (or similarly named, which extends from HTMLElement for example) is created, with whatever logic/properties it has. this inside of it's methods refers to the element itself, just like in the div example. Only in this case the instance is a Custom Element, not a built-in element.
  2. An instance of the class associated with the "map" behavior is created, with whatever logic/properties it has. Note that the "map" behavior is completely unrelated to the <map> element. this inside of it's methods refers to the the instance of the class associated with the "map" behavior, with a reference to the web-map element passed into its constructor and/or other methods. The this in the methods of the class associated with"map" do not refer to the element.
  3. Similar for mymapbehaviour as in point 2, just replace "map" with "mymapbehaviour".

Finally, in my example, the Foo and Bar classes can specify methods similar to Custom Elements, and again, these are for any purpose that the authors of Foo and Bar might imagine:

class Foo {
  constructor(el) {
    // A behavior is constructed when it's name is added to an element's has=""
    // attribute, or when the parser first encounters an element and creates it
    // and that element already had the name it its "has" attribute.

    console.log(el) // a reference to the element
    console.log(this) // a reference to this class instance
    console.log(el === this) // false
  }

  removedCallback(el) {
    // This is called when the behavior's name is removed from the element it
    // was instantiated for.
    //
    // For example, some other code might have called `el.setAttribute('has',
    // 'bar')` which no longer contains the name "foo", so removedCallback() is
    // called.
  }

  connectedCallback(el) {
    // do something with el anytime that el is added into the DOM.
  }

  disconnectedCallback(el) {
    // do something with el anytime that el is removed from the DOM.
  }

  attributeChangedCallback(el, attr, oldVal, newVal) {
    // do something anytime that one of el's attributes are modified.
  }
}

class Bar {
  constructor(el) { /* same description as with Foo */ }
  removedCallback(el) { /* same description as with Foo */ }
  connectedCallback(el) { /* same description as with Foo */ }
  disconnectedCallback(el) { /* same description as with Foo */ }
  attributeChangedCallback() { /* same description as with Foo */ }
}

Even more lastly, a new instance of a behavior is created for each element it is assigned to. If we have

<div has="foo"></div>
<div has="foo"></div>
<div has="foo"></div>

then just like there are three instances of HTMLDivElement, there are also three instances of Foo, one Foo per div.


It could be possible to add an additional feature, where a singleton class can be specified, so that only one instance of it is instantiated for all elements it is assigned to. Suppose the API was like this:

class Baz {
  constructor(el) { /* same description as with Foo */ }
  removedCallback(el) { /* same description as with Foo */ }
  connectedCallback(el) { /* same description as with Foo */ }
  disconnectedCallback(el) { /* same description as with Foo */ }
  attributeChangedCallback(el, attr, oldVal, newVal) { /* same description as with Foo */ }
}

// Let's use this behavior only inside a given ShadowDOM root (another feature).
shadowRoot.elementBehaviors.define('baz', Baz, {singleton: true})

and that we had this markup (inside the shadow root):

<any-element has="baz"></any-element>
<other-element has="baz"></other-element>
<div has="baz"></div>

In this case there'd be only one instance of Baz, and anytime any of those elements are connected, disconnected, or attributes change, the methods of the single instance of Baz would be called, with the element passed in. If we, for example, modified an attribute on the div and other-element, then the single instance's attributeChangedCallback would be fired twice, and each time el would be a reference to a different element (the div first, then the other-element second).

The Baz constructor would only be called once, the first time the singleton behavior is used on any element, and removedCallback would only be called once, when the baz behavior is removed from the last element to have it.


There's probably more considerations to be ironed out, like for example, what if an element is removed from DOM and never added back. This would obviously call a behavior's disconnectedCallback. But would it also call the removedCallback? Perhaps both methods are called in that case, and it would be possible for removedCallback to be called by not disconnectedCallback when only the behavior is removed from the has attribute but the element wasn't disconnected.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 8, 2017

I mentioned

(unless there's a way to limit which elements a behavior can be applied to, but I'll skip that idea for now).

So here's that idea. The API might look like this:

class Foo { ... }
elementBehaviors.define('foo', Foo, {limit: [HTMLMapElement, WebMap]})

Suppose we have this markup

<web-map has="foo"></web-map>
<map has="foo"></map>
<div has="foo"></div>

In this case, only two instances of the Foo class would be created, for the web-map and map elements based on the interfaces they are defined with (built in or not). There would not be a Foo instance created for the div element, and also not for any other element besides those two map elements.

And here's another interesting idea. Suppose we have

class SomeElement { ... }
customElements.define('some-element', SomeElement)
elementBehaviors.define('foo', Foo, {limit: [SomeElement]})
elementBehaviors.define('bar', Bar, {limit: [HTMLUnknownElement]})

This would apply the behavior only to any element that has no underlying class. For example, if there's no class defined for some-element,

<some-element has="bar"></some-element>

then the bar behavior will be created for that element.

If at some point the element gets upgraded to a SomeElement, the removedCallback of the bar behavior will be called, then a new Foo behavior will be constructed and passed in the upgraded element. Interesting possibility!

@treshugart
Copy link

@trusktr re lifecycles being different, what if custom attributes also had hooks for element lifecycles? I think these ideas are so close that it's worth considering how they might be merged. The custom attribute model is much closer to custom elements.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 8, 2017

It's true, perhaps the same thing can be achieved with Global Custom Attributes, but semantically custom attributes seems better for attributes that are meant (and will likely) be used on every type of element. For example, the style="" attribute is such an attribute. The behaviors idea is just semantically different (I think I like this one more due to semantics, although the other one is further along with a polyfill). Can they both co-exist for semantically-different uses?

@trusktr
Copy link
Contributor Author

trusktr commented Sep 8, 2017

what if custom attributes also had hooks for element lifecycles?

If we went that route, we'd see the following often, when only a behavior is needed and a value for the behavior might not be necessary:

<ender-man is-visible player-aware holding="dirt"></ender-man>

They both solve the same problem in a different way.

I'm leaning towards a special attribute though, because it could be that using a particularly-named attribute (f.e. has="") makes it more clear that the identifiers create class instances, while attributes would remain simply key-value pairs as before.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 8, 2017

Plus, naming. Custom Attributes would require hyphens, while behaviors wouldn't. Woot woot!

@wiredearp
Copy link

This effort could perhaps be pushed back to user land for prototyping if the issue was to be rebooted as some kind of "general mechanism for hooking into the lifecycle of custom elements". What if, for example, in addition to the current class constructor:

CustomElements.define('my-element', class extends HTMLElement {});

– the element registry was rigged to also accept a singleton object:

CustomElements.define('my-element', {
	constructedCallback(elm) {}
	connectedCallback(elm) {}
	disconnectedCallback(elm) {}
	attributeChangedCallback(elm, ...args) {}
	adoptedCallback(elm, ...args) {}
});

– which triggers on lifecycle events for all instances of my-element. The browser can create and register an anonymous Custom Element at least until the user does it, but the human user can choose one or the other approach to associate behavior with my-element, trading inheritance and local state for some kind of functional or stateless approach.

Or he can do both: Because you can register as many of these objects as you like, in addition to the single class constructor. The Custom Element will be associated with an infinite amount of behavior while the syntax in markup stays the same:

<my-element>
<td is="my-element">

The is attribute then becomes safe for use in WebKit and the mechanism can be used in JS libraries to support experimental declarative mixin strategies as discussed above, perhaps to be included in the standard once the questions are resolved, such as: Who get's to own the Shadow DOM and how should the different behaviors interoperate (or not *), which seems to be a big enough challenge for Custom Elements as it is.

[*] By message passing via non-bubbling CustomEvents from the Custom Element, one could perhaps suggest, so the the current class hierarchy doesn't just become a hierarchy of class-clusters made of hard dependencies.

Just to illustrate how this might reduce the number of specifications spawned.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 8, 2017

@wiredearp With that idea it is still not possible to associate multiple behaviors. In your example, is="" only accepts a single value. How do we assign more than one behavior? Plus, now we're mixing concepts together (f.e. the stuff from #509) which may cause even more confusion.

I think it'd be beneficial to have one simple clean new idea without mixing it with the confusing and limited existing functionality.

@wiredearp
Copy link

wiredearp commented Sep 8, 2017

Perhaps like this:

import { CustomBehaviors, CustomBehavior } from 'myproposal';
CustomBehaviors.define('one', class extends CustomBehavior {});
CustomBehaviors.define('two', class extends CustomBehavior {});
// and so on ...
CustomElements.define('my-element', {
  constructedCallback(elm) {
    if(elm.hasAttribute('has')) {
      CustomBehaviors.init(elm); // puts 5 new CustomBehavior() in WeakMap
    }
  }
});

The mechanism simply provides the necessary callbacks for you to experiment with the syntax:

<div is="my-element" has="one two three four five"/>

– until it becomes popular enough to be included in the standard. I just think this works better than the other way around.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 8, 2017

True, having an actual implementation definitely would help. This is a good place to determine what the features will be for that trial implementation.

I still this that

  constructedCallback(elm) {
    if(elm.hasAttribute('has')) {
      CustomBehaviors.init(elm); // puts 5 new CustomBehavior() in WeakMap
    }
  }

is too much work for authors writing custom elements. They shouldn't have to wire up this functionality. It should be baked in.

Literally, to make things easy, all they should need to do is

elementBehaviors.define('foo', Foo)

then the rest is automatic. If an element has the has="" attribute or whatever it would be called, then the instances will be automatically created without any further code needed at all:

<div has="foo"></div>

And that's all. Nothing else should be required.


(

Because, when you write

    if(elm.hasAttribute('has')) {
      CustomBehaviors.init(elm); // puts 5 new CustomBehavior() in WeakMap
    }

then this automatically makes you wonder, what happens if they just write

      CustomBehaviors.init(elm); // puts 5 new CustomBehavior() in WeakMap

without the conditional check, and the element doesn't have a has="" attribute? Now we are allowing for their to be situations where the answer is not clear. We just need it to be automatic, just like Custom Elements are.

)

@wiredearp
Copy link

wiredearp commented Sep 9, 2017

Just to clarify that the only person who would write the potentially verbose and error prone code in question was you, the "polyfill" author, and everybody else could then write the JS and HTML exactly like you suggest. Before you release the library, you would of course move the part of the code into the module myproposal.js, so nobody has to see this code. Now we are ready to determine if the suggested syntax:

<table is="data-grid has="sortable clickable editable searchable filterable configurable"/>

– is preferable to what the current spec has to offer:

<table is="data-grid" sortable clickable editable searchable filterable configurable/>

– or some combination of the two:

<table is="data-grid" searchable filterable configurable has="sortable clickable editable"/>

We are also free to explore the concept of shared state and define recovery guidelines for separate entities to act on the same DOM. We can determine the best APIs for behaviors to communicate and assert each others existence. Perhaps some component authors in the end decide that a mixin strategy in pure JS is preferred, because it is easier to release an NPM module than it is to edit one hundred HTML files in one thousand websites whenever they release a new behavior "screenreadable", or perhaps a majority of components will simply not use mixins at all.

<table is="data-grid">

I can even imagine a movement against a "general mechanism for hooking into the lifecycle of custom elements" except for what the element decides to expose, because it violates some design pattern or justified concern. Or perhaps some other reimplementation of Custom Elements and/or Angular directives catches on, there seems to be no shortage of ideas. I think that however the specification is not the best place to sell an unopened can of worms, because people will be tempted to buy it just for that fact. A low level mechanism would allow them to try before you buy, and the example API I have suggested could by itself be used to extend the behavior of components even without adding new attributes. Perhaps in the end that turns out to be easier to maintain.

<table is="data-grid">

@snuggs
Copy link

snuggs commented Sep 9, 2017

And here's what getting those components from an element and calling a method looks like:

The following feels very 🚂 🚋 🚋 🚋 wreck pattern IMHO.

anyElement.behaviors.foo.someMethod()
class Foo { ... }
elementBehaviors.define('foo', Foo, {limit: [HTMLMapElement, WebMap]})

This pattern feels very 🇨🇭 Army Knife™ to me. Usually API confusion leads to footguns. Best to keep .define semantics as ergonomically friendly as possible. If i'm not mistaken a "small" change like this could increase the complexity of the upgrade routine at maximum, and mental gymnastics at minimum. Not sure if the value of a multitude of behaviors is worth it IMHO. (I could be wrong)

I think it'd be beneficial to have one simple clean new idea without mixing it with the confusing and limited existing functionality. @trusktr

I concur 💯 however in the "simple and clean" category these examples leave much to be desired.

I do like the concept of "enhanced" elements re: @wiredearp

<table is=custom-element>

And the ability to not have to extend from HTMLElement is a nice thought as well. But certainly feel this can be implemented at the library level with ease. Perhaps conventions for :undefined "enhanced" elements can be made around is=. For instance since there would be no need to .define () an upgrade for the element. The lifecycle callbacks are trivial to .call as well. But would steer clear away from having access to attributeChangedCallback. One thing jQuery got right is setting up a "module" container on DOMContentReady is a nice sandbox for even the most novice of developer to be productive. So an "enhancement" to me would be the convention of connectedCallback firing on ready. Perhaps not the best semantically, but gets the job done. Also can be implemented from a library perspective. Also allows the underlying is= spec to come to homeostasis.

This even leaves room for is=foo but but this could be potential bike shedding.

Gut instinct says a couple of nice conventions could arise from this at the library level. However, we should be cautious of breaking Ockham's Razor at the spec level.

My 2 Satoshi

P.S. @trusktr, skating since late 90's #rad 🤘 #stillRockTracker & 🇨🇭

@trusktr
Copy link
Contributor Author

trusktr commented Sep 21, 2017

I'd be fine removing the current is="" from spec and doing the rest at the library level.

However, things at the library level are not "standard" or "spec". 100 different implementations will pop up, and then we'll have a bunch of different ways of doing the same thing that are incompatible with each other; fragmentation.

If we had something standard, spec'd, and built into browser, we could rely on it in every single web app, not just specific web apps that use specific implementations.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 21, 2017

@prushforth Mind explaining your downvote?

@prushforth
Copy link

You are against "is", but it also seems like you are promoting what appears to me to be more complicated solutions (multiple inheritance / mixins). Now I won't say that your push back on is has singlehandedly delayed its implementation, but there are many people with operational systems based on is that are waiting on its delivery, so maybe we could just ease back on the ideas for a few months and let the standard as written get implemented. Finally, I'm not certain that github issues are the best place to float idea balloons: the WICG has a nice moderated forum for that where everyone can share ideas and get feedback on them from other experts where there is also a CLA in place for IP contributions. And I agree the little thumbs up/down is passive aggressive BS. There I said it.

@trusktr
Copy link
Contributor Author

trusktr commented Sep 23, 2017

@prushforth Thanks for the reply! I think you misunderstood what I proposed.

you are promoting what appears to me to be more complicated solutions (multiple inheritance / mixins).

On the contrary, the idea in this issue does not require inheritance at all; and they are not mixins.

In the examples from above:

class Baz {
  // ...
}

elementBehaviors.define('baz', Baz)

the Baz class does not inherit from anything, therefore it also doesn't have multiple inheritance.

The is="" attribute, in contrast, is more complex and limited in usage due to the inheritance requirement and the fact that only one class can be associated with an element.

The examples in this issue are not mixins; they are multiple classes whose standalone instances can operate on the same object (a DOM element). As with any sort of code reading or setting properties on the same object, there's room for collision, but that doesn't make them mixins.

Multiple jQuery plugins, for example, can operate on the same Element, but they aren't mixins.

Nothing's perfect, however the idea in this issue is more powerful than the current is="" attribute, and simpler.

@trusktr
Copy link
Contributor Author

trusktr commented Oct 27, 2017

For anyone interested, I've made an initial implementation of the has="" attribute (let's call it "element behaviors") on top of @matthewp's custom-attributes. I used custom-attributes to define the has="" attribute, which defines how the has attribute instantiates behaviors on a given element.

I plan to open a new and more concise issue with a standalone implementation with examples.

If you're curious to try it out, you can install custom-attributes in your project, then copy and run that HasAttribute file, then you can do something like

<script>
  class Foo {
    constructor(element) {
      // do something with element
    }
    connectedCallback(element) {
      // do something with element
    }
    disconnectedCallback(element) {
      // do something with element
    }
    attributeChangedCallback(element, attributeName, oldValue, newValue) {
      // do something with element's attribute change
    }
  }
  elementBehaviors.define('foo', Foo) // no hyphen required

  class SomeThing { ... }
  elementBehaviors.define('some-thing', SomeThing)
</script>

<div has="foo some-thing" ... ></div>


I'm currently using this to implement the WebGL elements in my project. For example it looks like this:

        <i-scene experimental-webgl="true">

            <i-node id="light" position="0 0 0">
                <i-point-light position="4 4 4">
                    <i-mesh
                        position="0 0 0"
                        size="4"
                        has="sphere-geometry"
                        color="0.5 0.5 0.5 1">
                    </i-mesh>
                </i-point-light>
            </i-node>

            <i-mesh
                id="first"
                size="2 2 2"
                rotation="30 30 30"
                has="sphere-geometry basic-material"
                color="0.5 0.5 0.5 1">

                <p> This is regular DOM content </p>
                <p>TODO throw helpful error when component not used on i-mesh</p>

                <i-mesh id="second"
                    size="1 1 1"
                    has="box-geometry basic-material"
                    rotation="30 30 30"
                    color="0.5 0.5 0.5 1"
                    position="1.5 1.5 0" >
                </i-mesh>

            </i-mesh>
        </i-scene>

trusktr added a commit to lume/lume that referenced this issue Dec 8, 2017
…from Mesh and specify a default box-geometry or sphere-geometry, respectively. This shows how easy it is to extend existing elements that specify new behaviors. It is similar in some ways to A-Frame's entity-component model, but ours are called element-behaviors, so as not to confuse with the word 'components' used in Web Components or React Components. For comparison, 'Element' is to this library as 'Entity' is to A-Frame, and 'Behavior' is to this library as 'Component' is to A-Frame. This "element-behaviors" idea was discussed on the w3c GitHub: WICG/webcomponents#662.
@trusktr
Copy link
Contributor Author

trusktr commented Feb 1, 2018

@prushforth

So are you proposing a) <web-map mixin="map mymapbehaviour"> or b) <map mixin="map mymapbehaviour"> where behaviour named "map" is the native behaviour that I was getting from inheriting from HTMLMapElement ? Use tr / my-tr if you have to run with that in your explanation.

I hadn't fully answered your question there.

With these element behaviors, you'd do this:

<table>
  <tr has="selectable"><td></td></tr>
</table>

where the <tr is the native element (not a "customized builtin"), and selectable is a behavior applied to the tr and which is defined in the application space.

If we tried to achieve this with Custom Elements, we'd try:

<table>
  <selectable-tr><td></td></selectable-tr>
</table>

which doesn't work for the reasons in #590.

has="" is nice because we can apply any number of functionalities (that do not extend native builtins) to the element:

  <tr has="selectable click-logger mouse-proximity-action" onproximity="..."><td></td></tr>

where tr is still just a native builtin tr element, and selectable, click-logger, and mouse-proximity-action are not extending native builtins, they are just simple behaviors defined in the application space. At some point, all three of those behaviors had to be defined in the application, with classes that do not extend from HTMLElement:

elementBehaviors.define('selectable', class { ... }) // simple class, no extending
elementBehaviors.define('click-logger', class { ... }) // simple class, no extending
elementBehaviors.define('mouse-proximity-action', class { ... }) // simple class, no extending

@trusktr
Copy link
Contributor Author

trusktr commented Feb 1, 2018

Alright everyone, I finally put up a standalone implementation of the "Element Behaviors" idea: #727

Basic codepen example.

@trusktr
Copy link
Contributor Author

trusktr commented Feb 10, 2018

Closing this, can continue in #727.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants