[go: nahoru, domu]

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

Proposal: Custom attributes for all elements, enhancements for more complex use cases #1029

Open
LeaVerou opened this issue Sep 13, 2023 · 34 comments

Comments

@LeaVerou
Copy link
LeaVerou commented Sep 13, 2023

This proposal introduces an API for defining custom attributes for both built-ins and custom elements, and design discussion for an API that can be used to define more complex enhancements that involve multiple attributes, methods, JS-only properties etc.

This came out of the TPAC extending built-ins breakout, and some follow-up discussions with @keithamus.

Defining attributes: The Attribute class

Use cases

  • Augmenting existing elements (either built-ins or custom elements) with custom attributes that trigger custom behaviors
  • Easier definition of a web component's own attributes, as this automatically takes care of property-attribute reflection, typing, default values etc, one of the biggest pain points of defining WCs without helpers

Prior art

Needs

  • Lifecycle hooks: connected, disconnected, changed
  • Default values
  • Optional typing

API sketch

  • Subclasses that extend a base Attribute class
  • Static HTMLElement.attributeRegistry property which is an AttributeRegistry object. Attributes can be registered generically on HTMLElement to be available everywhere, or on specific classes (built-in or custom element classes).
    • For non custom elements, attribute names MUST contain a hyphen and not match a blacklist (aria-*, SVG attributes etc). Or maybe this should be a validation restriction, and not actually enforced by the API?
  • Attribute should extend EventTarget so that authors can dispatch events on it.

Definition:

class ListAttribute extends Attribute {
	ownerElement; // element this is attached to
	value; // current value

	connectedCallback() { /* ... */ }

	disconnectedCallback() { /* ... */ }

	// Called whenever the attribute's value changes
	changedCallback() { /* ... */ }

	static dataType = AttributeType.IDREF;
	
	// Optional default value
	static defaultValue = null;
}

Usage on built-ins or existing custom elements:

HTMLInputElement.attributeRegistry.define("ac-list", ListAttribute);

An optional options dictionary allows customizing the registration, such as:

  • propertyName to override the automatic camelCase conversion
MyInput.attributeRegistry.define("ac-list", ListAttribute, {
	propertyName: "autocompleteList",
});

Usage on new custom elements:

class MyInput extends HTMLElement {
	...
}

MyInput.attributeRegistry.define("ac-list", ListAttribute);
MyInput.attributeRegistry.define("value", class ValueAttribute extends Attribute {
	dataType = AttributeType.NUMBER;
	defaultValue = 0;
});

We could also add a new static attributes property as syntactic sugar to simplify the code needed and keep the definition within the class:

class MyInput extends HTMLElement {
	// Or maybe an array?
	static attributes = {
		"ac-list": ListAttribute,

		// Creates a suitable Attribute subclass behind the scenes:
		value: { dataType: AttributeType.NUMBER, defaultValue: 0 }
	}
}

Types

In v0 types could only be predefined AttributeType objects, in the future these should be constructible (all they need is a parse() and stringify() function, and maybe an optional defaultValue to act as a base default value).

Open Questions

  • Should lifecycle hooks be called when the attribute is added, or when the element is connected/disconnected?

Complex Enhancements

Complex enhancements include:

  • Multiple attributes (references to Attribute objects)
  • Methods
  • Properties and accessors (that don't correspond to attributes)

This can be fleshed out at a later time, since Attribute already deals with a lot of use cases. That could give us time to get more data about what is needed.

Prior art

Needs & Design discussion

Referencing: How to associate enhancements with elements?

Element behaviors use a has attribute that takes a list of identifiers, and that is totally a viable path. The downside of this is that it introduces noise, and potential for error. E.g. imagine implementing a language like VueJS in this way, one would need to use has in addition to any v-* attribute, and would inevitably forget.

Associating enhancements with a selector would probably afford maximum flexibility and allows the association to happen implicitly (e.g. for an Enhancement implementing htmx, the "activation selector" could be [hx-get], [hx-post], [hx-swap], ..., without an additional has="htmx" being required eveyrwhere that these attributes are used.

Imperative association could allow us to explore functionality without committing to a particular declarative scheme. That way, individual attributes can still automatically activate behaviors, though it's a bit clunky:

class MyAttribute extends Attribute {
	connectedCallback () {
		this.ownerElement.behaviors.add(Htmx);
	}

	disconnectedCallback () {
		let hasOthers = this.ownerElement.getAttributeNames().some(n => n.startsWith("hx-");
		if (!hasOthers) this.ownerElement.behaviors.delete(Htmx);
	}
}

Flexibility

Should enhancements allow the same separation of names and functionality as custom elements and attributes? Given that they include multiple attributes, how would the association happen? I'm leaning towards that they'd also name the attributes they are including (since they are naming properties, methods etc already anyway).

// Should have the same syntax as HTMLElement.attributes above
attributes: {
	"v-if": VIf
	"v-else": VElse,
	"v-on": { value: VOn, propertyName: "vOnEvent" }
	...
}
@EisenbergEffect
Copy link

That was a fast turnaround....

There are a couple of things I like about this right off the bat:

  • I like that this can be used to simplify attribute definition on custom elements.
  • I like that this recognizes the problem of typing attributes and is even open to custom converters in the future.
  • I like that this considers supporting a default value.
  • I like that the programming model is pretty simple and harmonious with custom elements.

I agree on the need to hyphenate names, again matching the CE rules and the strict blocking of certain names altogether.

I need to think more about registration. That's the biggest open question for me. I think having registries on the individual types is interesting. I hadn't thought about doing it that way. I wonder how that will work with custom element registries, particularly WRT scoped registries. So, I wonder whether attribute registration should just be another API on the existing custom elements registry, so we can inherit all the work on scoping for attributes as well.

My personal preference...I don't particularly like using a "has" attribute vs. just using attribute names. It doesn't feel as "HTML-y" to me. That's not a deal breaker for me though.

I do think there should be a programmatic way to add and remove behaviors (and as a result, possibly callbacks for when that happens). I'm inspired a bit by Unity 3d, which has a generic scene graph with a behaviors system that is the mechanism for all node specialization. Behaviors are part of a collection and can be added, removed, searched, etc. This comes in handy if you want to build more complex systems of behaviors where one behavior looks for another and then if found, collaborates with it through its public API. You could imagine a custom drag/drop system working this way with custom attributes for draggables and drop targets, where the attributes locate one another and through their APIs coordinate the drag/drop.

Ok, that's just a few quick thoughts. I'm excited to see if we can make this happen!

@bahrus
Copy link
bahrus commented Sep 13, 2023

Thanks for the implicit feedback on my proposal. There are some nice ideas here I may steal if that's okay, with "attributions" (sorry for the bad pun) (and apologies for overlooking your original 2017 proposal, will add a link to that for sure). I remain unconvinced of this direction (but I will see if my reasons hold merit on reflection). It seems I am in a minority of one, but I will continue slogging along at my proposal, incorporating ideas such as these, in case we find a happy compromise.

For starters, it seems that the feedback is that my proposal is too complicated, so I will look for a better "on ramping" experience.

One concern:

// This also works:
MyInput.attributeRegistry.define("ac-list", ListAttribute, {
	propertyName: "autocompleteList" // if we want to override the default acList
});

This means that in order to register the attribute, then we need to wait for the MyInput class to be downloaded. But if we use whatever we call this thing-of-a-jiggy for doing cross cutting things (like binding during template instantiation, for example), I think that could be an issue.

I'm curious if you could provide insights on this question: When I look at this documentation, it seems that by default the "hooks" can apply to all elements by default. Likewise when I look at Vue and others (knockout.js) etc. My proposal was heavily influenced by what I see in industry (as that seems like a proven model?)

It seems that by default your proposal is deeply curtailing the developer's "reach" and I'm puzzled as to why? Is it parsing constraints?

I agree we want the developer to be able to curtail their own reach, but I'm a bit surprised that that is the direction this is taking?

@sorvell
Copy link
sorvell commented Sep 13, 2023

Thanks for writing this up so quickly! Some initial thoughts on this proposal only, still need to digest some of the referenced alternatives.

  1. This is probably obvious but seems like it often comes up: all the capabilities described here are mostly possible in the platform today via MutationObserver and Object.defineProperties. Therefore, the goal here needs to be clear and is likely something like making these capabilities (1) more ergonomic, (2) perform better, (3) reliably baked in.

  2. Experience with customElements and the scoped registries proposal suggests that scoping is a must and to avoid the pain custom elements has gone through, this feature shouldn't ship without it.

  3. Custom Element attribute reflection is indeed annoying, but it's not clear if this feature would be directly applicable. Perhaps instead, there's a lower level seralization/deserialization primitive that could be used here and also with observedAttributes.

  4. It's important to state clearly that the reason to use attributes is to make the augmented behavior declaratively configurable. While we (or at least I do) typically think of this as being synonymous with HTML, JSX is very popular and although it is html-like, it has the ability to set complex non-serializable data directly via properties. This suggests that basic version should support property setting. Here's a straw proposal: (1) use has to install a behavior (e.g. has="foo bar", (2) this makes a property ...DataSet (e.g. fooDataSet) available on the element and configurable via ...-data-X (e.g. foo-data-mood="happy"). Systems that set properties could then just set a property like e.g. fooDataSet.mood = 'happy'.

@LeaVerou
Copy link
Author

@EisenbergEffect

need to think more about registration. That's the biggest open question for me. I think having registries on the individual types is interesting. I hadn't thought about doing it that way. I wonder how that will work with custom element registries, particularly WRT scoped registries. So, I wonder whether attribute registration should just be another API on the existing custom elements registry, so we can inherit all the work on scoping for attributes as well.

@sorvell

Experience with customElements and the scoped registries proposal suggests that scoping is a must and to avoid the pain custom elements has gone through, this feature shouldn't ship without it.

Hm. I need to understand the use cases more where scoping comes into play with attributes.

Attribute registries are scoped to element classes already. Is the use case having different attributes for the same element class if registered differently, in different registries? Is the use case adding an attribute to a built-in only when used within a WC's Shadow DOM, without affecting the built-in outside that?

FWIW making scoping part of the custom elements registry is also problematic: First because registration is often decoupled from the custom element definition, so that consumers can do the registration. If now consumers also have to register all the element's attributes, that is too much work on the consumer side, for little benefit, plus it would break DOM methods to read/write attributes.
Second, because one of the primary driving use cases is adding custom attributes to built-ins, which are not registered on any CustomElementRegistry.


@sorvell

Custom Element attribute reflection is indeed annoying, but it's not clear if this feature would be directly applicable.

How so? It's designed with that as a primary use case.

Perhaps instead, there's a lower level seralization/deserialization primitive that could be used here and also with observedAttributes.

If there is, even better! Though I suspect use cases for observedAttributes would drop very significantly once this is available.

This suggests that basic version should support property setting.

It …already does? In fact, it entirely automates it 🙂
Perhaps you mean properties that don't correspond to attributes (akin to LitElement's attribute: false)? I can definitely see use cases for that, the reason that wasn't included was that this is not that difficult already.


@bahrus

One concern:

// This also works:
MyInput.attributeRegistry.define("ac-list", ListAttribute, {
	propertyName: "autocompleteList" // if we want to override the default acList
});

This means that in order to register the attribute, then we need to wait for the MyInput class to be downloaded. But if we use whatever we call this thing-of-a-jiggy for doing cross cutting things (like binding during template instantiation, for example), I think that could be an issue.

Not necessarily, that's what customElements.whenDefined() is for.
Also, I think adding custom attributes to other custom elements, while important, is probably one of the least prominent use cases (compared to adding them to built-ins, and adding them to your own components).

I'm curious if you could provide insights on this question: When I look at this documentation, it seems that by default the "hooks" can apply to all elements by default. Likewise when I look at Vue and others (knockout.js) etc. My proposal was heavily influenced by what I see in industry (as that seems like a proven model?)

It seems that by default your proposal is deeply curtailing the developer's "reach" and I'm puzzled as to why? Is it parsing constraints?

I agree we want the developer to be able to curtail their own reach, but I'm a bit surprised that that is the direction this is taking?

I …don't understand what you're asking here at all. 🤷🏽‍♀️ What's the relevance of Mavo hooks and Vue plugins? What is curtailing what developer reach? 🤔

@bahrus
Copy link
bahrus commented Sep 14, 2023

@LeaVerou

I agree very much with two things you have said:

Also, I think adding custom attributes to other custom elements, while important, is probably one of the least prominent use cases (compared to adding them to built-ins, and adding them to your own components).

and I try to abide by the principal you've brought up -- about making simple or common things, easy to do, while not making complex things impossible. So, thinking about it last night, here's my simple use case, and I will add to it my proposed solution to that simple case, that I think typifies the "common" use cases in my mind:

Say all you need to do is to create an isolated behavior/enhancement/hook/whatever associated with an attribute, say "log-to-console" anytime the user clicks on elements adorned with that attribute, where we can specify the message. Here's how that would be done with the custom enhancements proposal:

customEnhancements.define('log-to-console', class extends ElementEnhancement{
    attachedCallback(enhancedElement: Element){
        const msg = enhancedElement.getAttribute('log-to-console');
        enhancedElement.addEventListener('click', e => {
            console.log(msg);
        });
    }
});
<svg log-to-console="clicked on an svg"></svg>
    ...
<div log-to-console="clicked on a div"></div>

...

<some-custom-element enh-log-to-console="clicked on some custom element"></some-custom-element>

Done!

If your proposed alternative is as simple, then my question to you regarding vue and mavo was truly out of left field and irrevelant. (I really do want to peg my hopes on someone else's proposal, not my own, so hoping you can address this concern I have). How would your proposal solve this? How many lines of code would be required to cover all the DOM elements?

@sorvell , you had me in complete agreement until you proposed the has option -- it seems to me solving the problem above
with the has option would require introducing two attributes (one of them data-) which doubles the chances of conflicts with other developers.

And can we go to teams like HTMX and Alpine and Wordpress and say "sorry, to get the platform's blessings, what you've been doing is wrong -- you need two attributes to solve this problem, which I know is more complicated, few if anyone has or is doing that, and increases the chances of naming conflicts, but the benefits are..."

And that's where I'm drawing a blank, without attempting to mind read, and probably getting the benefits wrong. Could you explain what I'm missing?

@LeaVerou
Copy link
Author
LeaVerou commented Sep 14, 2023

@bahrus

Say all you need to do is to create an isolated behavior/enhancement/hook/whatever associated with an attribute, say "log-to-console" anytime the user clicks on elements adorned with that attribute, where we can specify the message.

Sure, here you go:

class LogAttribute extends Attribute {
	connectedCallback() {
		this.ownerElement.addEventListener("click", e => console.log(this.value));
	}
};

HTMLElement.attributeRegistry.define("log-to-console", LogAttribute);

Roughly the same amount of code I believe.

Note that because SVG elements do not inherit from HTMLElement, it will not work for SVG elements, but this is a matter of definition. I defined it this way since custom elements also inherit from HTMLElement. If adding attributes to SVG elements is desirable, it's trivial to hang attributeRegistry off of Element instead.

Here's how that would be done with the custom enhancements proposal:

Btw your code will log the old message even if the attribute changes (this is easily fixable by moving the msg declaration in the event listener).

When does attachedCallback fire? If it can fire multiple times for an element (e.g. if it's removed and re-added to the DOM multiple times), you will have multiple listeners, and thus log the message multiple times. Depending on how connectedCallback() works in Attribute, my example above may have the same issue, but I opted to keep it as close to your example as possible.

And can we go to teams like HTMX and Alpine and Wordpress and say "sorry, to get the platform's blessings, what you've been doing is wrong -- you need two attributes to solve this problem, which I know is more complicated, few if anyone has or is doing that, and increases the chances of naming conflicts, but the benefits are..."

+1 to that.

@bahrus
Copy link
bahrus commented Sep 14, 2023

You are correct, in your observations, all good points. I literally wanted to look at the simplest requirement to compare, which you've done in spades. Thanks!

You've easily convinced me that I was wrong, I misread what you are proposing, (I guess I was thrown by the HTMLInput.attributeRegistry.define, but on rereading what you wrote, I see what I missed). I hope you will address the more "complex" things I'm after (the ability for all these solutions to work well together) as the proposal progresses, and I feel a burden lifting from my shoulders as we speak!

@bahrus
Copy link
bahrus commented Sep 14, 2023

Hi @LeaVerou ,

I for one vote overwhelmingly to support svg (I was proposing that by extending "ElementEnhancement"), and I think it is up there in the top complaints / limitations for custom elements. So unless there's a solid reason not to, why wouldn't we?

(mis)reading your proposal further (apologies in advance):

Associating enhancements with a selector would probably afford maximum flexibility and allows the association to happen implicitly.

I'm getting a little lost here. Is your proposal heading in the direction of allowing each custom attribute to "opt-in" to be a behavior? Is that why you are considering the "has" attribute? Is basing access to the name of the behavior based either literally on the custom attribute, or the camelCase string problematic?

Either way, I'm not following some of the discussion (apologies, again), but have you yet settled on how third party vendors can access the behavior, what that looks like (and how to make that seamless), or is that a work in progress? Are attributes required to enhance an element? If so, why?

Feel free not to answer, just wanted to convey what I'm looking for to be 100% satisfied, and what I'm finding difficult to follow.

@LeaVerou
Copy link
Author

Hi @LeaVerou ,

I for one vote overwhelmingly to support svg (I was proposing that by extending "ElementEnhancement"), and I think it is up there in the top complaints / limitations for custom elements. So unless there's a solid reason not to, why wouldn't we?

I'm with you on that one!

(mis)reading your proposal further (apologies in advance):

Associating enhancements with a selector would probably afford maximum flexibility and allows the association to happen implicitly.

I'm getting a little lost here. Is your proposal heading in the direction of allowing each custom attribute to "opt-in" to be a behavior?

You're right that as currently described, each custom attribute would be "opting-in", but also note that you could e.g. activate the behavior for the entire subtree the attribute is on (e.g. imagine a declarative version of Vue where the selector would be [v-app], [v-app] *.
However, in the spirit of making common things easy, I think it would make sense to define a default (if no selector is provided) that simply makes all specified attributes "activate" the behavior.
Note that this cannot be the only activation mechanism, as then attributes would be required (or imperative association).

Is that why you are considering the "has" attribute?

I'm listing two potential association schemes. I don't think we should go with has, for the reasons I listed.

Is basing access to the name of the behavior based either literally on the custom attribute, or the camelCase string problematic?

Not 100% sure I fully understand what you're saying here, but perhaps the default I'm proposing above satisfies that?

Either way, I'm not following some of the discussion (apologies, again), but have you yet settled on how third party vendors can access the behavior, what that looks like (and how to make that seamless), or is that a work in progress?

Not 100% sure I understand what you're asking here either, but since these are regular objects (most likely classes, but I could see plain literals working too) you can just export them in the usual way. If that doesn't answer your question, I think some more specific use cases could help me understand it better.

Are attributes required to enhance an element? If so, why?

No, they are not.

Feel free not to answer, just wanted to convey what I'm looking for to be 100% satisfied, and what I'm finding difficult to follow.

This is great feedback on what I may not have explained sufficiently well, so please keep it coming!

@sorvell
Copy link
sorvell commented Sep 14, 2023

@LeaVerou

Hm. I need to understand the use cases more where scoping comes into play with attributes.

Unless I'm missing something, the basic scoping problem is the same for both custom elements and custom attributes.

The issue with custom elements is basically this: my-element-a might import and depend on fancy-list and my-element-b might import and depend on a different fancy-list. Custom elements are designed, in general, to have their details encapsulated but the dependence on fancy-list leaks since its existence is global.

The same issue could occur with my-element-a importing and depending on HTMLInputElement.attributeRegistry.define("ac-list", ListAttribute); and my-element-b importing and depending on a different ac-list attribute.

If there is, even better! Though I suspect use cases for observedAttributes would drop very significantly once this is available.

// Creates a suitable Attribute subclass behind the scenes:
value: { dataType: AttributeType.NUMBER, defaultValue: 0 }

The bit that isn't quite connecting with me is perhaps just not filled in yet. Having to configure an element's attributes' behavior via something other than the element class feels unnatural, but this part of the proposal may just need to be worked out more.

Perhaps you mean properties that don't correspond to attributes (akin to LitElement's attribute: false)?

There's 3 cases that I think are actually fairly common: (1) 2 way reflection: attribute <-> property, (2) 1 way reflection: attribute -> property, (3) property only.

  • Case (2), reflecting a property value back to the attribute is useful for serialization and for triggering platform actions that depend on the attribute (maybe just styling in this case). It's also done for only some platform properties, e.g. not Input.value or Button.onclick.
  • Case (3), no attribute is perhaps not something to encourage but it's done for some platform things like HTMLMediaElement.currentTime and is generally useful for data that just won't ever serialize. Even if it's simple to install via Object.defineProperty(someElementProto), it's probably good to include here so it's reliably baked in.

@sorvell
Copy link
sorvell commented Sep 14, 2023

Couple other points related to the basic design of the class:

  1. I think the MVP lifecycle would include ownerConnected/DisconnectedCallback so that behavior can be triggered based on the element being "in use" in the DOM. Quick example: there's a data subscription attribute and the attribute needs to be able to disconnect from some system when the element is no longer used in the tree.
  2. The one-to-one mapping between behavior enhancement and attribute name/value feels limiting/cumbersome. Contrived example:
    • <input has="happy sad bored" onhappy... onsad... onbored...> v.
    • <input has="moods" onhappy... onsad... onbored...>

@bahrus
Copy link
bahrus commented Sep 14, 2023

@sorvell , so I think you are advocating allowing one behavior to "own" multiple (non-data?) attributes.

Benefits I see:

  1. This would allow for the values associated with the behavior to be "spread out", not scrunched into a JSON object.
  2. It might be easier for the browser to "discover" the enhancements.
  3. The names of the behaviors could be a single word.

Dangers:

  1. I think the message on "namespacing" the attributes becomes cloudier -- more than one "owned" name associated with a package, basically. Maybe solvable with prefixes?
  2. It still complicates simpler use cases (such as the log to console example), behaviors that only really expect to have one value throughout their lifespan.

Maybe that approach should be optional?

@smaug----
Copy link

Attribute should extend EventTarget so that authors can dispatch events on it

What does that mean? What is this Attribute interface? And who would add listeners to it?

@iteriani
Copy link

I wonder if in practice registering custom attributes like this ends up in a situation where it becomes impossible to DCE or move this code to late modules because it might end up being used somewhere. Any way of avoiding this ends up putting explicit imports somewhere, which may be unintuitive.

This problem probably happens even with this is="" approach too, so I guess this isn't really making anything worse

@trusktr
Copy link
trusktr commented Sep 19, 2023

Hello you all, interesting ideas and conversation! I would love to get involved with F2Fs but I didn't have the chance this time around. It'd be great to meet you all in person if not in a call, hopefully next time.

Please bare with me, this reply is long, covering lots of topics, hopefully separately and easy to follow (just long).

@LeaVerou I found the scoping of attributes on element classes interesting.

2. Experience with customElements and the scoped registries proposal suggests that scoping is a must and to avoid the pain custom elements has gone through, this feature shouldn't ship without it.

@sorvell I think there's merit for both global and scoped attributes. F.e. a library might want to define certain attributes for functionality specific to the library and the elements those attributes are placed on (f.e. a material on a mesh), but another library might like to define attributes that work on any element (f.e. a click tracker on any element).

Perhaps there's a need for scoping per class and per shadow root? F.e.:

this.shadowRoot.attributes.define({
  "foo-attribute": {
    element: HTMLButtonElement,
    attribute: FooAttribute,
  }
})

I think at the very least we need ShadowRoot scoping even if not element class scoping. For global attributes, it could be window.attributes.define({...}) in a similar fashion if both types of scoping are adopted.

3. Perhaps instead, there's a lower level seralization/deserialization primitive that could be used here and also with observedAttributes.

This got me thinking that an another way to scope attributes to a class could be via the observedAttributes array (but this won't work for built-ins), although maybe it isn't possible without breakage? For example:

class MyAttr extends Attr {
  static name = "my-attr"
  connectedCallback() {...}
  valueChangedCallback(oldVal, newVal) {...}
  // ...
}

class MyEl extends HTMLElement {
  static observedAttributes = ['some-attr', MyAttr]
}

where MyEl observes both some-attr and my-attr attributes based on that array. In this approach, attribute scoping in shadow roots is implicit from element scoping in shadow roots.

There's 3 cases that I think are actually fairly common: (1) 2 way reflection: attribute <-> property, (2) 1 way reflection: attribute -> property, (3) property only.

I pondered this too. If a custom element receives a value via JS property, it must remember to reflect that back to the attribute instance or reactivity in that attribute instance will not happen (today custom elements systems tend to make reflection optional while still triggering reactivity, which is in contrast to this).

One way I've dealt with this is having a custom attribute (or behavior) use Object.defineProperty() on the host element to install a getter/setter to catch changes in a property that the custom element may not even be aware of, but this has not been ideal for type definitions in TypeScript (more below).

4. Here's a straw proposal: (1) use has to install a behavior (e.g. has="foo bar", (2) this makes a property ...DataSet (e.g. fooDataSet) available on the element and configurable via ...-data-X (e.g. foo-data-mood="happy"). Systems that set properties could then just set a property like e.g. fooDataSet.mood = 'happy'.

I made element-behaviors before I switched to TypeScript, and after switching to TypeScript I learned (and this applies to custom-attributes too) that if the dynamic set of behaviors (or attributes) on an element determines which additional attributes/properties can be interacted with on the host element (f.e. suppose adding a "foo" behavior to an element causes it to have a .fooDataSet property as you've describe @sorvell, but also suppose a behavior has observedAttributes for observing arbitrary attributes of the host element, similar to custom elements, needed if something like this (whether element-behaviors or custom-attributes, doesn't matter which pattern) may be an alternative for customized built-ins), then it becomes difficult to define a type definition for elements where these arbitrary properties are included for type safety and intellisense.

<div id="div" has="foo"></div>
const div = document.getElementById('div')
div.fooDataSet.bar = 123 // type error, unknown property (TypeScript has no idea a "foo" behavior was attached to the div)

If behaviors are defined globally, then it is possible that el.behavior.get('foo') could return an object of the correct type:

const div = document.getElementById('div')
div.behaviors.get('foo').bar = 123 // ok, we know the type of behavior object, we know it has a bar property.

and similar for attributes:

const div = document.getElementById('div')
div.attributes.getNamedItem('my-attr').bar = 123 // ok, we know the type of attribute object, we know it has a bar property.

(association of custom attributes into el.attributes is not implemented in the custom-attributes concept)

When I've previously defined a specific set of behaviors that should be used on specific types of elements, I had to do a type augmentation of the element class to add all the properties from the behaviors onto them. For example, if an element my-el could have behaviors foo and bar, it would look something like this:

export class MyEl extends HTMLElement {...}
customElements.define('my-el', MyEl)

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

// Augment the class with all the possible properties it could gain from a known set of behaviors.
export interface MyEl extends Partial<Pick<Foo, 'a' | 'b'> & Pick<Bar, 'c' | 'd'>> {}

where Foo observes properties a and b on MyEl, and Bar observes c and d on MyEl.

But the issue with this is:

  • all the properties of all possible behaviors exist on the augmented element definition, but they are all optional (| undefined) because we don't know which particular known behaviors will be attached, so during auto completion we see a lot of properties that might not be applicable, and for properties that are applicable they should not necessarily be | undefined.
  • if this is a library, and users would like to add new behaviors, they'd need to augment the element type as well, using a declare module "my-library" { interface MyEl extends {...} } declaration, adding to the set of possible properties.

Doing this augmentation is important however, because without it we get type awareness and intellisense on the elements in JSX, etc. I believe it is impossible to dynamically augment the type of a JSX element based on a value of one of its props. If we write the following JSX assuming that a behavior "foo" observes attributes "a" and "b" on a host element,

return <my-el has="foo" a={1} b={"2"} c={something} d={otherthing} />

there's no way for TypeScript to know that the my-el JSX "intrinsic element" (as per React's JSX type definition terminology) should have a and b properties based on the has="foo" attribute, and that it shouldn't have c and d, so the only thing we can do is make them all exist and be optional on the element, and it only works if the set of possible behaviors is known up front.

How do we do this in a way that will be the simplest to add type definitions for?

Right now, type definitions in frameworks typically read properties from an object type for a given element name. For example, JSX type defs come from IntrinsicElements['my-el'] for <my-el>, where IntrinsicElements['my-el'] is an object type. For a custom element in Solid.js, it may look like this:

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "my-el": Pick<MyEl,
        // pick some props, but not all (f.e. not methods).
        'prop1' | 'prop2'
        // pick properties augmented from behaviors
        | 'a' | 'b' | 'c' | 'd'
      >; 
    }
  }
}

It can be simplified with helpers (but doesn't changed the end result).

Most frameworks today simply work on a bag of properties for a given element.

For attributes or behaviors that need to observe separate attributes on the host element, I've been contemplating moving to this pattern, which would be both easy to define types for without bags of possible properties per element, and supported in all frameworks:

<my-el a-known-property="123">
  <foo-behavior another-known-property="456"></foo-behavior>
</my-el>

where for every custom element that is ever defined, using today's mechanisms we always know the set of properties per element in a clean way without performing type augmentation. The user of my-library can import my-el, and then do this, without my-el ever having to be couple to its definition of properties:

<my-el a-known-property="123">
  <third-party-behavior some-new-property="456"></third-party-behavior>
</my-el>

In this pattern, the "behaviors" would use this.parentElement as the "host element" that they'll interact with. The end user would independently define the type definition of ThirdPartyBehavior simply by defining the class and associating it into JSX.IntrinsicElements (and for DOM APIs, into HTMLElementTagNameMap for document.createElement('third-party-behavior') to return the expected object type, etc), without hacking into the types from upstream.

So a main thing I'm wondering is, if we were to introduce attributes/behaviors/enhancements that observe other attributes/properties on the host element, what sort of path can we enable for frameworks syntax wise and type wise?

For example, if adding a "foo" behavior means we now have foo-data-* attributes, I think syntax is not affected in that case, but would TypeScript need to come up with some way to map from has="*" attributes to *-data-* property lookups for JSX? And for custom attributes that observe host element attributes (f.e. <game-character has-hair hair-color="brown" hair-length="medium">)?

(I'm not a fan of the data- and dataSet verbosity, I've never used those APIs, just plain attributes and properties. Am I missing out?)

Apple's new 3D <model> element has similar issues because it introduces sub-objects on a model element instance for manipulating the 3D scene's camera in a way that web frameworks cannot access via the declarative patterns of today, so people need to get references to model elements and resort to vanilla JS to manipulate them. I hope we can avoid losing out on declarative syntax niceties like <model> currently does, and if anything come up with new HTML syntax for mapping to the behaviors/attributes/enhancements/directives in a way that most of today's frameworks could adopt. The only way to avoid needing frameworks to update is by hooking into attribute/prop syntax, but then we still have the type definition hardships.


Alternative to customized built-ins.

I think the proposal, in whichever form, needs to support an alternative to this:

<button is="cool-button" cool-foo="foo" cool-bar="bar">

and in behavior format or custom attribute format that looks like this, for sake of ensuring it is in our minds:

<button has="cool-button" cool-foo="foo" cool-bar="bar">

or

<button cool-button cool-foo="foo" cool-bar="bar">

In either case, to really be an close alternative to custom elements that extend built-ins, the behavior or the custom attribute needs to have a feature like static observedAttributes to make it easy to observe host element attributes, unless we want to leave that to userland with other patterns, such as:

  • let them use MutationObserver which would be by no means a nice alternative to custom elements with observedAttributes, forcing refactoring of their code in order to remove their customized built-ins polyfill
  • requiring the end user to define multiple custom attribute classes for each attribute, and communicating via the host element, which is by no means a nice alternative compared to what was previously a single class with a set of particular attributes

1. I think the MVP lifecycle would include ownerConnected/DisconnectedCallback so that behavior can be triggered based on the element being "in use" in the DOM. Quick example: there's a data subscription attribute and the attribute needs to be able to disconnect from some system when the element is no longer used in the tree.

@sorvell in both of Lume's element-behaviors and custom-attributes, connectedCallback and disconnectedCallback are the only connect/disconnect life cycle methods.

  • an attribute/behavior connectedCallback is called when the element is connected and the attribute is added.
    • f.e. if an element is disconnected, and we add an attribute/behavior to it, the attribute's/behavior's connectedCallback does not run. It runs once the element is finally connected, after the element's connectedCallback (a natural consequence of MutationObserver being async in the "polyfill").
    • If an element is already connected, and we then add the behavior/attribute, then the attribute's/behavior's connectedCallback will run (ideally this would be synchronous just as with custom elements, but due to limitations of MutationObserver, the "polyfill" is currently forced to be async for simplicity, as patching DOM APIs to make it sync would be a lot of complicated code)
  • an attribute/behavior is disconnected when either removed from an element, or when the element is removed from DOM.
    • f.e. if an element has an attribute/behavior, and the element is disconnected, then the attribute's/behavior's disconnectedCallback runs
    • If an element is already disconnected, this means an attribute's/behavior's disconnectedCallback will have already ran, and will not be called when the attribute/behavior is removed. If the element was never connected, the attribute's/behavior's connectedCallback will not have ran, and so removing the attribute/behavior in this state also does not run disconnectedCallback
    • if an element is not disconnected, but the attribute/behavior is removed from the element, then the attribute's/behavior's disconnectedCallback runs (and obviously it will not run later when the element is disconnected because the element no longer has the attribute/behavior).

This is a simple model: there's a creation hook, and a destruction hook, and that's really all that the end author of an attribute/behavior should worry about. Otherwise, things will get more complicated if we have elementConnectedCallback for specifically the element, connectedCallback for specifically the attribute/behavior, elementDisconnectedCallback for specifically the element, and disconnectedCallback for specifically the attribute/behavior.

2. The one-to-one mapping between behavior enhancement and attribute name/value feels limiting/cumbersome. Contrived example:

  • <input has="happy sad bored" onhappy... onsad... onbored...> v.
  • <input has="moods" onhappy... onsad... onbored...>

@sorvell this is covered with the observedAttributes idea above, for being an almost-one-to-one alternative for customized built-ins.

Here's an example impl with hypothetical custom attributes (imagine similar with behaviors):

class WithMoods extends Attr {
  static observedAttributes = ['onhappy', 'onsad', 'onbored']
}

customAttributes.define('with-moods', WithMoods)
<input with-moods onhappy... onsad... onbored...>

Also, on that note, why does it really matter if custom attribute names have dashes or not, considering that this is not a requirement for observedAttributes on custom elements? The only thing browsers are doing is reading attribute values, and so if custom attributes have values, then this does not impact the browser's ability to read the value of an attribute (regardless if it is custom or not).

Suppose I want to implement a custom onclick attribute. It might be nice to be able to do so within a ShadowRoot scope, because it is my scope:

constructor() {
  this.shadowRoot.attributes.define('onclick', MyOnclickAttributeThatMapsStringNamesToMethodsOfMyElement)
}

foo() {...}

connectedCallback() {
  this.shadowRoot.innerHTML = `
    <div ></div>
  `
}

It seems that the worse thing that a custom attribute could do, without dashes in the name, is provide an different value than the browser expectes. This is fundamentally not different than the end result of someone setting an invalid value on an attribute from outside of the element.

Maybe I want to define custom attributes, for use on certain elements (SomeElement.attributes.define()) or in certain shadow roots (this.shadowRoot.attributes.define()), that provide values for aria-valuenow, aria-valuemin, and aria-valuemax.

Why would it be a problem to define this.value for my version of the attribute in my own encapsulated way, for example based on values of some other attributes?

I don't see the issue this has compared to custom elements. With custom elements, we aren't just overriding a single string value that the browser reads, but we're replacing the whole implementation of a class that the browser uses (if we were to allow non-hyphenated custom elements), which could very much affect how the browser works. But in case of custom attributes, unless I missed something, it seems that the browser simply needs to read their values.

  1. I think the message on "namespacing" the attributes becomes cloudier -- more than one "owned" name associated with a package, basically. Maybe solvable with prefixes?

@bahrus why is namespacing required? Why not just leave that to userland, linters, etc? For example, in JavaScript, you can shadow an outer scope variable with a same-name variable in inner function scope, and the language doesn't care. Should HTML care? What problem will be solved? Can you show code examples of what namespacing solves?

Attribute should extend EventTarget so that authors can dispatch events on it

What does that mean? What is this Attribute interface? And who would add listeners to it?

@smaug---- We already have a window.Attr class for attributes. Should we extend from that? Should we add EventTarget to that?

We've also discovered simple patterns like class-factory mixins in modern JS using classes + functions. Why not introduce utility mixins natively? For example, leave Attr as is, and if someone wants to emit from their attribute instance, they can do so like with a mixin without Attr being modified:

class MyAttr extends EventTargetMixin(Attr) {
  connectedCallback() { this.dispatchEvent(new Event('foo')) }
}

but note that this means that now the attribute emits an event that does not capture/bubble/compose with element events. Also note that attributes can easily use their host element as an event target. Maybe having an EventTarget custom attribute instance would prevent the need for naming of events on elements being more unique?

Why hasn't the platform released any new classes in the form of mixins? Is there a major downside I am missing? Internally, for example, a browser could re-use implementation details for both EventTarget and EventTargetMixin.

@bahrus
Copy link
bahrus commented Sep 19, 2023

Hi @trusktr , I need to read through your ideas, but having just skimmed it, I'm kind of excited to see we might be converging in views -- I've added support for observedAttributes to my proposal, based on the "lightning bolt" that emanated from @sorvell 's comments.

However, I was just sitting down to correct my approach -- I think we want the list of observable attributes to be provided in the registration function, rather than a static property. I think the registration is the key place that should control the property name off of the enhancements property as well as the attributes, so we can concentrate all our scoped registry trickery in one spot. I also agree with you I was probably overthinking the namespacing a bit, I think we can probably trust developers to make such judgments for themselves (as long as we insist that attributes have dashes, and I still think we need a prefix when applying to third party custom elements).

@bahrus
Copy link
bahrus commented Sep 19, 2023

@trusktr, I haven't yet wrapped my brain around the scoped registry solution, and consequently can't foresee what impact it will have on namespacing. I'm waiting for the dust to settle. But my instinct tells me it isn't going to solve every problem under the sun. For example, I still think that we will want to reduce the need for complex and confusing mappings, so that developers will still want to strive to "namespace" their attributes based somewhat on their package name, document their libraries names based on this "canonical" naming, and that we will want to utilize scoped registries to rename things off of their canonical defaults, only when absolutely necessary, due to inevitable name collisions due to the limitations of relying on public npm packages exclusively for claiming "ownership" of a string.

If anyone's understanding is different, I would love to get a response if anything I said above is inaccurate, because I'm only guessing, and it is something I feel like I should understand better to be attempting to add my voice to this proposal space.

@bahrus
Copy link
bahrus commented Sep 20, 2023

I'm so sorry, but I think we need to start thinking of our custom attributes as a tuple of observable strings (rather than the more traditional name/value pair mental model), so that the names can be modified by the party registering the attributes, as well as within scoped registries.

Prove me wrong.

@jimmyfrasche
Copy link

I use data-* attributes a lot. (I'd probably use them even more if CSS attr() worked with non-strings). I could see this replacing most if not all of the uses I have for dataset. The most common thing (and the most irritating thing) I have to do with dataset is manually (de)serialize values whenever I get/set so being able to define an attribute with just parse() and stringify() functions would be enough for me most of the time.

@trusktr
Copy link
trusktr commented Sep 21, 2023

I'm so sorry, but I think we need to start thinking of our custom attributes as a tuple of observable strings (rather than the more traditional name/value pair mental model), so that the names can be modified by the party registering the attributes, as well as within scoped registries.

Prove me wrong.

There's absolutely nothing to be sorry for. Also I'm not sure what you mean yet.

@trusktr
Copy link
trusktr commented Sep 21, 2023

@rniwa I'm wondering about particular feedback you have specifically for WebKit's needs. Based on my thoughts regarding observedAttributes in my previous comment, what are your thoughts regarding

  • whether the API shape of behaviors/attributes/enhancements/whatchamacallem should be similar to custom elements (making this an almost-drop-in replacement for customized built-ins),
  • versus making this ergonomically different from custom elements (f.e. each custom attribute observing only a single attribute value, with users opting into using MutationObserver in a custom but much more cumbersome way to achieve the same as they did with observedAttributes in custom elements),

with the final use case being <button cool-button foo=".." bar=".." baz=".."> instead of <button is="cool-button" foo=".." bar=".." baz=".."> with foo, bar, and baz being attributes observed by the cool-button class?

Just wondering what your thought here is since making an alternative to customized built-ins was a primary reason for the conversation.

@bahrus
Copy link
bahrus commented Oct 6, 2023

Hi @sorvell and @LeaVerou

So we are all on board with supporting multiple attributes, which is great, excellent idea.

In my excitement, I didn't think to check in with you as far as your thoughts with observedAttributes vs has? I think @trusktr and I reached the same conclusion from different vantage points, but wanted to get a show of hands I guess. I'm concerned we may have a split.

Syntactically, is there a reason to prefer:

<input has="moods" onhappy... onsad... onbored...>

over

something like:

<input ismoody onhappy... onsad... onbored...>

or my preference (am I the only one)?

<input is-moody on-happy... on-sad... on-bored...>

?

Or is there something else I failed to see in addition to wanting to support multiple attributes?

Feel free to enjoy your weekend, no rush, I just felt a bit bad for jumping to conclusions I shouldn't have.

I've expressed my concerns before, perhaps too harshly, but I would like to understand how to sell something if I am to support it.

Equally important to me is whether the restriction I assumed would still hold, holds still -- no dashes. I think it's great if we don't need them. But couldn't the syntax @sorvell provide start triggering events unexpectedly with a new browser version? It seems @sorvell and @trusktr don't believe it matters. The way I look at it, yes, it does mean every attribute needs some sort of prefix. That's the worst aspect. For two words it adds only a slight amount of inconvenience, frankly I find it more readable, easier to type (on the keyboard) than camel case, but most importantly, it increases risk if we stop using them. It seems most globals and element specific attributes these days are getting long multiple syllable at least, so dashes can't hurt. As far as attributes on built-in elements, ff the industry had long ago abandoned such concerns about prefixing, that would be one thing. I know we have for custom elements, which I have mixed feelings about, but I came to peace with it long ago, especially for short words (especially if a little thought is put into it). But the industry generally has not for higher level elements, where the argument grows stronger, in my mind (maybe not so much now, but I believe it will with time). Why rock the boat on that question? If this is somehow magically resolved by scoped registry, that's all I need to know, and that would be a great surprise.

I will note that others have honestly reported encountering this issue with custom elements, quite recently, so it's a real issue in my mind.

@LeaVerou, I'm totally with you as far as wanting to link up attributes with properties, parsing, etc. I'm okay with requiring that be fixed as part of this proposal. I think your idea to filter on Element types is a great one, and I've added it to my proposal with attributions. But I just can't wrap my brain around this, and honestly, I doubt I will be alone in this:

class MyAttribute extends Attribute {
	connectedCallback () {
		this.ownerElement.behaviors.add(Htmx);
	}

	disconnectedCallback () {
		let hasOthers = this.ownerElement.getAttributeNames().some(n => n.startsWith("hx-");
		if (!hasOthers) this.ownerElement.behaviors.delete(Htmx);
	}
}

I think it is much easier to group attributes together, like developers are used to, like @trusktr and I agree, via a finite list of observed attributes, bundled together in a class that I think would make much more sense calling "ElementEnhancement", as I've done in my proposal. We need to be precise in which attributes are mixed together to form one enhancement. I just get brain fog when I look at that code.

Is it mutual? Do you get the same brain fog looking at my proposed alternative: I am purposely not hiding any of the complexity, to showcase how consumers of the enhancement can rename all the attribute within a scoped registry:

//canonical name of our "custom prop", accessible via oElement.enhancements[canonicalEnhancementName], 
//which is where we will find an instance of the class defined below.
export const canonicalEnhancementName = 'logger'; 
//canonical name(s) of our custom attribute(s)
export const canonicalObservedAttributes = ['log-to-console']; 
customEnhancements.define(canonicalEnhancementName, class extends ElementEnhancement {
    attachedCallback(enhancedElement: Element, enhancementInfo: EnhancementInfo){
        const {observedAttributes, enhancement} = enhancementInfo;
        const [msgAttr] = observedAttributes; 
        // in this example, msgAttr will simply equal 'log-to-console', 
        // but this code is demonstrating how to code defensively, so that
        // the party (or parties) responsible for registering the enhancement 
        // could choose to modify the name, either globally, or inside a scoped registry
        // in a different file.
        enhancedElement.addEventListener('click', e => {
            console.log(enhancedElement.getAttribute(msgAttr)); 
        });
    }
}, {
    observedAttributes: canonicalObservedAttributes
});

I think @trusktr and I are generally on the same page now on this.

BTW, I'm softening my stance on enh- if anyone cares.

@trusktr
Copy link
trusktr commented Nov 16, 2023

The problem with attributes/behaviors/enhancements and type safety

One thing I haven't gotten to write my thoughts on yet is type safety. I first created element-behaviors before I adopted TypeScript. In plain JavaScript, these dynamic mixin-like patterns are fine.

First, let me show problems in TypeScript with the the is="" attribute: it does not allow the same type safety as non-builtin-extends custom elements do (and similar with custom attributes, element behaviors, and enahncements):

First, here is a sample with is="":

class CoolButton extends HTMLButtonElement {
  foo = "bar" // suppose we add a new property "foo", and values from a "foo" attribute get assigned to it.
}
customElements.define('cool-button', CoolButton, {extends: 'button'})

const button = document.createElement('button') // TypeScript infers `button` to be `HTMLButtonElement`.

console.log(button instanceof HTMLButtonElement) // true
console.log(button instanceof CoolButton) // false (!)

// Here TypeScript does not change the type of the `button` variable from `HTMLButtonElement` to `CoolButton`.
// And, umm, is the element supposed to be upgraded when this attribute gets set?
button.setAttribute('is', 'cool-button')

console.log(button instanceof HTMLButtonElement) // still true
console.log(button instanceof CoolButton) // still false (!), and large ding against customized-builtins

const newVariable: CoolButton = button // Type error! (good in this case)

console.log(button.foo) // Type error! (good, because right now it is undefined)

Paste that into your browser console and hit enter after removing the type annotations. Then check out the type errors in TS playground.

Output:

true
false
true
false
undefined

(This shows a significant problem with is="".)

Now, let's suppose we do things differently so that is="" actually works:

class CoolButton extends HTMLButtonElement {
  foo = "bar" // suppose we add a new property "foo", and values from a "foo" attribute get assigned to it.
}
customElements.define('cool-button', CoolButton, {extends: 'button'})

document.body.insertAdjacentHTML('beforeend', '<button is="cool-button">btn</button>') 

const button = document.querySelector('button')! // TypeScript infers `button` to be `HTMLButtonElement`.

console.log(button instanceof HTMLButtonElement) // true
console.log(button instanceof CoolButton) // true

const newVariable: CoolButton = button // Type error! (bad, it actually is a CoolButton!)

console.log(button.foo) // Type error! (bad, the property exists!)

Try it in console after stripping types, and here's the TS playground showing type errors.

A similar problem can manifest itself with custom attributes, behaviors, and enhancements, when they both listen to additional attributes on an element and they observe those attribute changes via JavaScript property values (very common in custom element libraries, and libraries similar to custom element libraries (customattributes/behaviors/enhancements) where similar patterns are replicated).

For sake of example, suppose we define a behavior (but similar applies with custom attributes and enhancements, just varying syntax) that listens to a "foo" attribute on its host element, and the way that it sets this up is via a decorator that does two things: observes the "foo" attribute and observes the "foo" property on the host element (taking into consideration that maby frameworks today bypass attributes and set properties directly on custom elements and therefore the behavior needs to observe properties because frameworks are setting JS properties):

<body>
  <div has="coolness" foo="bar"></div>
</body>
@behavior('coolness')
class Coolness extends ElementBehavior {
  @attribute @receiver foo = "initial"
  
  connectedCallback() {
    // Log this.foo anytime it changes (which will have been due to either the host's "foo" attribute changing, or the host's "foo" property changing).
    createEffect(() => console.log(this.foo))
  }
}

where @behavior sets up observedAttributes containing "foo" due to the @attribute decorator, @behavior installs a getter/setter on the host element for the property "foo" due to the @receiver decorator (real-world example), and createEffect is an API from Solid.js that makes a function re-run when properties used inside of it change value due to the @attribute decorator making those properties read and write from a Solid.js signal (real-world example).

We start to experience some of the same problems with type safety with the following added code:

const div = document.querySelector('div') // TypeScript infers this to be HTMLDivElement

div.foo = "bar" // Type Error, but it works! Logs "bar" to console.

It is important for a behavior to be able to observe properties, not just attributes, due to today's frameworks allowing both attributes and properties to be set via delarative templating systems, with the preference being on JS properties.

In plain HTML, the following will cause the behavior to map the host element attribute value to the behavior's JS property:

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

In Lit, the following template set an attribute, and so the behavior with "foo" in observedAttributes (due to the @attribute decorator) will catch the value and map it to its foo property:

return html`
    <div has="coolness" foo="bar"></div>
`

Lit has syntax for setting properties on an element, bypassing the attributes, so we need to write robust implementations that can handle this:

return html`
    <div has="coolness" .foo="bar"></div>
    <some-custom-element has="coolness" .foo="bar"></some-custom-element>
`

where some-custom-element might even be a 3rd-party custom element that has a particular foo JS property but no corresponding foo attribute. Such libraries exist:

https://iogui.dev

This iogui doc page states:

Note: Io-Gui templates do not set HTML attributes - only properties are set.

Solid'shtml template tag also has a similar feature, and it defaults to JS properties for custom elements because custom elements typically use JS properties for their reactivity:

const elements = html`
    <div has="coolness" foo="bar"></div> <!-- sets an attribute by default -->
    <some-custom-element has="coolness" foo="bar"></some-custom-element> <!-- sets a property by default -->
    <div has="coolness" prop:foo="bar"></div> <!-- explicitly set property -->
    <some-custom-element has="coolness" attr:foo="bar"></some-custom-element> <!-- explicitly set attribute -->
`

In today's landscape custom attributes, behaviors, enhancements, or any similar concept, need to be robust and handle both attributes and properties.

In the above examples, the foo attributes and properties were not defined on the elments, but only in the behavior.

You could, in practice, define a certain custom element that can have a certain set of behaviors on it, and you can augment the type definition so that the element class will have all possible properties of all possible behaviors as optional properties, which is very ugly (real-world example).

This is fairly bad because it means a library (f.e. Lume) has to define up front what possible behaviors are known to be placeable onto a <lume-mesh> element in order to get type safety. What about 3rd-party authors? Now they need to augment the type of the Mesh class using this similar sort of type hack in their extending libraries, which is very hacky.

In Lume's current state, when you write this code in VS Code:

const mesh = document.querySelector('lume-mesh')

mesh.

then VS Code will begin to show possible auto-completions, which will include a list of all possible properties even if they are properties from behaviors that are not currently added to the element:

Screenshot 2023-11-15 at 5 26 31 PM

What if the <lume-mesh> element has="phong-material" but does not has="physical-material"? The clearcoat property is not applicable with a phong material. Etc. This is not the best developer experience. Besides that, if new behaviors are added for use on <lume-mesh> elements, and someone forgot to augment the Mesh class with the additional properties, TypeScript will show a type error for the missing properties when attempting to use them.

But this example of pre-defined behaviors in Lume is not even a usable concept with behaviors that are generic to be applied onto any element. Imagine if, for example, a behavior/attribute/enhancement author goes and augments the HTMLElement base class with all the possible properties of all their behaviors. What a mess that would be!

An alternative pattern, but not a replacement for is=""

I am contemplating to add a new pattern to Lume, where instead of behaviors being added via the has="" attribute, they will be added as "behavior elements" that are behaviors to their composed parent in the composed DOM tree.

This HTML,

<lume-mesh has="phong-material" color="red"></lume-mesh>
<lume-mesh has="physical-material" clearcoat="0.7"></lume-mesh>

will change to this:

<lume-mesh>
  <phong-material color="red"></phong-material>
</lume-mesh>
<lume-mesh>
  <physical-material clearcoat="0.7"></physical-material>
</lume-mesh>

This is a lot cleaner because:

  • type definitions are scoped to custom element definitions. document.querySelector('physical-material')!.clearcoat = "0.8" has no type error in TS.
  • there is no more need to augment element classes with additional possible properties that really only exist when certain behaviors are added
  • people adding "behavior elements" to an augment an existing library simply define new classes and use them. They need not fiddle with type definitions of a library's existing classes.
  • this is compatible with all of today's web framework composition patterns (slots, props.children, etc) where behaviors-via-attributes is not. This next example uses JSX syntax, and composes "behavior elements" (for sake of naming them to compare then with "element behaviors") in an idiomatic way that all of today's frameworks support (with varying syntax not necessarily as follows):
    function MyMesh(props) {
      return <lume-mesh {...props}><slot></slot></lume-mesh>
    }
    
    function ThirdPartyDiamondMaterialAndGeometry() {
      return <>
        <special-glass-material index-of-refraction="0.8"></special-glass-material>
        <diamond-geometry ...></diamond-geometry>
      </>
    }
    
    function MyApp() {
      return <MyMesh position="1 2 3" rotation="4 5 6">
        {/*Compose any material and geometry combination into the MyMesh component.*/}
        <ThirdPartyDiamondMaterialAndGeometry />
      </MyMesh>
    }
    This composition is not possible with most of today's templating systems (Lit/React/Vue/Solid/Svelte/Angular/anything-with-slots/etc) because most of those systems do not compose components into attributes, only into children. Some templating systems like Handlebars (or basically anything that does string interpolation on the server side without necessarily having component boundaries) are able to compose into attributes, but this is largely not a part of client-side templating.

This "behavior element" pattern be a lot easier to work with (easy to define type defintions simply as properties on a class, and without a bad intellisense story), and a lot easier to compose in React/Vue/Svelte/Solid/Angular/etc.

TLDR

The concept of element behaviors, custom attributes, and element enhancements, still have a place.

They will be more useful in cases that do not listen to arbitrary host element JS properties (as far as type checking goes), or cases that listen only to attributes (but then setAttribute(...) is just like Record<string, string> which isn't as type safe).

The "behavior element" pattern does not solve the problems that is="" solves. For example, "behavior elements" will not work in this case:

<table>
  <tr>
    <behavior-for-the-tr></behavior-for-the-tr>
    <td></td>
  </tr>
</table>

The parser will move the <behavior-for-the-tr> and the result will be:

<behavior-for-the-tr></behavior-for-the-tr>
<table>
  <tbody>
    <tr>
      <td></td>
    </tr>
  </tbody>
</table>

which means the behavior-for-the-tr will have a different parent "host" element that it will apply operate on.

An upside of custom attributes/behaviors/enhancements is they can still be useful for solving cases like with <table>, especially the ones where custom elements are impossible to use. It might be important to at the very least introduce something simple like custom attributes/behaviors/enhancements merely to solve that problem.

But generally speaking, for other cases, I would like to migrate to the children-as-behaviors format for better type safety and composability.

Perhaps ironically, as compared to styling an element with certain behaviors/attributes/enhancements by using attribute selectors, styling elements with certain "behavior elements" would be done using the :has(behavior-element) syntax.

In Lume we have a goal to be able to move our Lume-specific features out of HTML and into CSS, taking place of behaviors, f.e.

converting either of these

<lume-mesh has="phong-material" color="red"></lume-mesh>
<lume-mesh has="phong-material" color="red"></lume-mesh>
<lume-mesh has="physical-material" clearcoat="0.7"></lume-mesh>
<lume-mesh has="physical-material" clearcoat="0.7"></lume-mesh>
<lume-mesh><phong-material color="red"></phong-material></lume-mesh>
<lume-mesh><phong-material color="red"></phong-material></lume-mesh>
<lume-mesh><physical-material clearcoat="0.7"></physical-material></lume-mesh>
<lume-mesh><physical-material clearcoat="0.7"></physical-material></lume-mesh>

into something like this:

<lume-mesh class="one"></lume-mesh>
<lume-mesh class="one"></lume-mesh>
<lume-mesh class="two"></lume-mesh>
<lume-mesh class="two"></lume-mesh>
<style>
  .one {
    --material: phong;
    --material-color: red; 
  }
  .two {
    --material: physical;
    --material-clearcoat: 0.6; 
  }
</style>

I think that we'll have some sort of system that applies "behaviors" from CSS, then overrides with the attribute values from "element behaviors" or "behavior elements". I'm not sure which pattern is better for it. Maybe if the concept of a behavior is totally abstracted so that element.behaviors provides the same JS API regardless if the behaviors are attributes or elements, then it might not matter.

@zcorpan
Copy link
Contributor
zcorpan commented Dec 21, 2023

@smaug---- We already have a window.Attr class for attributes. Should we extend from that? Should we add EventTarget to that?

Attr already inherits from EventTarget.
https://dom.spec.whatwg.org/#interface-attr

@bahrus
Copy link
bahrus commented Dec 26, 2023

@trusktr, I feel like maybe there is a question of whether Typescript/JSX is the cart or the horse. To my mind, the most relevant question is not "what does Typescript/JSX support today"? but rather "could JSX/Typescript be trained to support whatever we come up with?", and we should come up with the best solution, considering that additional question as a secondary consideration.

I was extremely excited when Typescript was announced, I spent all night playing around with it the day it came out, but, perhaps this is an old-fashioned view, one of the things that really excited me about it is that it took an extremely subservient view to the standards at the time ("we will adopt to the standards (which we may influence a bit) even if it breaks backwards compatibility when they come out, and not the other way around"). I've not seen the same humbleness from many JSX advocates, but that's another story for another day.

Anyway, do you see any reason Typescript/JSX couldn't be trained to support autocomplete / compiling support for something like:

<table>
  <tr [behaviorForTheTr 
           myFirstProp:={myListItem} 
           mySecondProp:={isCollapsed} 
           my-first-attribute?={isOdd}] >
    <td></td>
  </tr>
</table>

???

@clshortfuse
Copy link
clshortfuse commented Jan 14, 2024

I feel I've already tackled most of the complexities with CEs but am willing to open that can of worms again.

The current approach I've implemented is mixins. The idea is to replicate/extend the native feature. Meaning <x-input> does everything <input> does. There's no relearning how to do things for one CE provider and then another being different.

That said, it's a pain to scaffold, but it works. And I know it works because I run over 1000 W3C spec tests to confirm. (Even fixing out-of-spec issues in Safari for HTMLInputElement).

But no beginner creating MyFirstCheckbox should have to deal with all the quirks. And there are many.

Some attributes aren't bidirectional with what's in the DOM. For example:

  • .value is a DOMString which handles null differently.
  • Attribute names and properties aren't symmetrical either. 99% it's fine, but sometimes it's [max-length]/maxlength not following common hyphenated uppercase.
  • Some properties are so asymmetrical they related to very different properties ([checked]/defaultChecked)
  • Some attributes have hidden internal writes (.checked affects .#checkedness).
  • Some properties have hidden translation layers (eg: form reset effects, checked, default checked).
  • Some attributes split between float and integer despite both resolving to Number.

If there's no parity with .myProperty and all that has to be scripted separately, that's fine, but I think people would prefer to set both at once. My strategy is properties (even internal) that can have attributes, not attributes that can have properties. Though, I can imagine building class ReflectedAttribute extends Attr couldn't be too hard.

I prefer overloading the native attributes names and properties names, but being forced to use custom is fine. I prefer being able to swap HTMLButtonElement with XButtonElement and change nothing else in code, but it's not a tradeoff for me. (And change 0 JS if you are using .getElementById() by hotswapping the tagname in HTML.) But that's me.

Attributes emitting events sounds good. Performance concerns versus .defineProperties() can be examined later. If the Attr can inspect the element it belongs to, which is likely (tagname or ownerNode, IIRC) then it should be enough to replicate Class hierarchy by means of .stopImmediatePropagation. For example, you may want most Attributes to work the same on all elements, but carve out exceptions for some. (eg: <textarea> handles value differently than <input>.)

Typing is always a mess. I do have some automatic, no transcompile needed, support for Typescript but the parser is rather subpar when working with pure JS files. I do feel once something become native in the browser, TS team will do what's needed to be parsed right. There is very limited support for any of our adhoc Attribute/Property solutions. I've tried and resigned to not bother expecting JS structures to get first-class support. I'm not interested in writing once in JS and then again in a d.ts for each of my custom elements. I've accepted the limitations and found suitable workarounds.

I'll have to deepdive later into what other attributes can prove problematic, but this proposal just hit my radar, and wanted to add my concerns from experience dealing with CE/FACE.

@trusktr
Copy link
trusktr commented May 15, 2024

Anyway, do you see any reason Typescript/JSX couldn't be trained to support autocomplete / compiling support for something like:

<table>
  <tr [behaviorForTheTr 
           myFirstProp:={myListItem} 
           mySecondProp:={isCollapsed} 
           my-first-attribute?={isOdd}] >
    <td></td>
  </tr>
</table>

I think it is unlikely for JSX to go in that direction. But with that said, it seems from your snippet, that the attributes inside the brackets are for the behavior right? In that case, maybe this is better:

<table>
  <tr>
    <behavior-For-The-Tr myFirstProp="..." mySecondProp="..." my-first-attribute="..."></behavior-For-The-Tr>
    <td></td>
  </tr>
</table>

(that's HTML, but interpolation would work in JSX, html template tag, etc, or hopefully with Declarative Custom Elements which may include both attribute and prop definitions). That's assuming we could fix the parsing issue. Is that possible? If so, then that custom element can do whatever it wants with its .parentElement, while also having a separate bag of attributes (type checkable in today's tooling) and display: none because it is a "behavior" only.

So that's another question for some use cases: can we fix parsing? Or is that impossible?

@trusktr
Copy link
trusktr commented May 15, 2024

Here's another element behaviors lib from 12 years ago by @robb1e!

https://github.com/elementaljs/elementaljs

It's a bit simpler, no life cycle methods (presumably in that time you'd use DOM Mutation Events for that).

@robb1e
Copy link
robb1e commented May 15, 2024

Here's another element behaviors lib from 12 years ago by @robb1e!

https://github.com/elementaljs/elementaljs

It's a bit simpler, no life cycle methods (presumably in that time you'd use DOM Mutation Events for that).

Thanks for the mention. I'm happy to share background on ElementalJS if useful. It came out of work developed by @nertzy on projects we were working on many years ago.

@bahrus
Copy link
bahrus commented May 15, 2024

Hi @trusktr

I'm fine if you prefer to use separate custom element "element behaviors", essentially abandoning/undercutting the whole premise for our proposals (including yours), the moment we seemed to reach a consensus between us, annoying as that is 😄. Go for it!

The good news is the platform has everything that you need already, other than perhaps a little more flexibility with some HTML tags, like the table tag, which I'm fine if the platform feels the need to address.

What I'm not so keen on is that the premise you seem to have for why the rest of us should all abandon wanting to enhance elements themselves, is that it doesn't play nice with current JSX, and a vague "that's not a direction JSX would go in". I view JSX as a tool that is supposed to help us, not hinder us. I just don't find that argument compelling. And it seems to ignore the fact that countless frameworks/libraries, which I've linked to in my proposal, to which I may add elementjs, find it useful to want to enhance elements directly, including React, which adds "react fiber" objects to the elements themselves. My proposal is attempting to "formalize" what the king of all JSX frameworks is doing already, in an interoperable way.

I feel like I'm on some infinite loop, spanning decades, of bringing up points already raised with the same cast of characters ( 😄 ), but anyway, the other reason I don't find that argument compelling, is that some JSX based libraries like SolidJS and Astro, have in fact started using attributes in the JSX to institute behavior like functionality:

<For each={state.list} fallback={<div>Loading...</div>}>
  {(item) => <div>{item}</div>}
</For>

Why not carry over that concept to adorning built-in or custom elements? If you don't like the bracket idea for grouping related properties and attributes together, maybe you prefer a common prefix approach? In the example above, focusing on the fallback attribute, solidjS is using curly braces to group things, rather than square brackets, as I suggested. I didn't put much thought into which would be better, I couldn't care less between the two, maybe using curly braces is a direction JSX could go with?

In fact, for quite a while, I was following the path you appear to be on now, for example with this approach. I did struggle with the question, especially as I liked the way separate elements could allow the attributes to form "complete sentences" via boolean, prefix-less attributes. And also, hoping that what the platform had already provided was sufficient, no need for requesting more.

But with time, I have evolved to think it is much better to group related things closer / more tightly together. I believe there are performance benefits. Also, it is easier for copy/cut and paste, and also lends to a better api (especially when we want to combine multiple behaviors together for the same element):

myTRElement.enhancements.expander.isExpanded = true;

vs.

let myExpanderBehaviorIHope = myTRElement.firstChild;
while(!(myExpanderBehaviorIHope instanceOf MyExpanderBehavior)){
  myExpanderBehaviorIHope = myExpanderBehaviorIHope.nextElementSibling;
}
myExpanderBehaviorIHope.isExpanded = true;

Not to mention the ambiguity non experts using the library are likely to face:

Wait, does that element behavior apply to the parent, or the next element sibling, or the previous element sibling? I'm confused.

Not all such element behaviors could be child elements. For example, what if you want to enhance the input element, which would require more platform parsing adjustments, I think. Just to placate one vision of where JSX ought to go?

PS, to answer one of your questions:

<table>
  <tr [behaviorForTheTr 
           myFirstProp:={myListItem} 
           mySecondProp:={isCollapsed} 
           my-first-attribute?={isOdd}] >
    <td></td>
  </tr>
</table>

This was my passive-aggressive way of continuing to suggest that we are overemphasizing the attribute aspect of what I want, at least. The only part of the example above that would be an attribute is the one that has an attribute in the name, used only for styling purposes -- my-first-attribute (yes, modern css doesn't require that, it was just trying to illustrate a point with a simple example).

All the rest would be properties, hence my use of props in the names.

The tr element would have a property that is accessible via oTR.enhancements.behaviorForTheTr, to which JSX could pass a non-JSON serializiable listItem via (behind the scenes):

 oTR.enhancements.behaviorForTheTr.myFirstProp = myListItem;
oTR.enhancements.behaviorForTheTr.mySecondProp = isCollapsed;
if(isOdd){
   oTR.setAttribute('my-first-attribute');
}else{
  oTR.removeAttribute('my-first-attribute');
}

The latter statement would pass through the behavior/enhancement's attributeChangedCallback (but I've been investigating a better api for that over time).

@zcorpan
Copy link
Contributor
zcorpan commented May 15, 2024

@trusktr we can't remove Foster Parenting generally for web compat reasons. The template and script elements are allowed almost anywhere, though.

@sashafirsov
Copy link

The API is nice, but HTML engines do have an attribute node and Attr type

interface Attr extends Node {

Based on this concept, in custom-element it is exposed via declarative syntax:

<custom-element tag="dce-link" hidden="">
            <attribute name="p1">default_P1                </attribute>
            <attribute name="p2" select="'always_p2'"></attribute>
            <attribute name="p3" select="//p3 ?? 'def_P3' "></attribute>
            p1:{$p1} <br> p2: {$p2} <br> p3: {$p3}
</custom-element>
<dce-link></dce-link>
<dce-link p1="123" p3="qwe"></dce-link> 

More live samles

As for imperative declaration, the class Attribute seems fine to me, but only trouble it is already a part of all browser implementations. Instead of trying to propose own, perhaps worth to explore existing Attribute implementation in WebKit?
The only action in this case would be

  • make it part of HTML parser, i.e. add to HTML5
  • add the functionality of observed attributes.

@trusktr
Copy link
trusktr commented May 16, 2024

@bahrus I don't want anyone to abandon the behaviors-or-similar idea. If the parser could be changed, then the behaviors-as-children idea would be an alternative for the main issue: lack of support for the is="" attribute.

As for the JSX stuff @bahrus,

Solid.js is using existing JSX syntax, it did not change any syntax (the stuff it does with curly braces is valid JSX (inside the curlies is just JavaScript)), and remains compatible with tooling like TypeScript, etc. JSX itself does not have any runtime specification, it is only a syntax specification, and frameworks like React, Solid, etc, can create any runtime output they want when they compile JSX to JS. Maybe if we want to have the best effect, we can propose something for HTML instead of JSX, and then every markup language including JSX would likely want to make space for it if possible.

Currently, the easiest way to get support with existing tools (f.e. TypeScript+JSX) is with elements, not currently with behaviors-or-similar.

I agree that behaviors-or-similar can be totally useful, especially when type checking is not in play. Is it easier to introduce behaviors-or-similar, or to update parsing? (seems like the former is)

Behaviors via syntax?

Is it possible to propose new (or moreso, modified) syntax behavior for HTML?

<table>
 <tr
    #my-behavior(foo="asdf" bar="blah")
    class="row"
    #other-behavior-without-attributes
  ></tr>
</table>

This is currently valid HTML, so not necessarily new, but people don't generally write attributes like that, so a subset of HTML could potentially be reserved for a new feature?

For non-parser-blocked scenarios (f.e. "foster parenting"), syntax could allow it as a child-parent association in the markup:

<div>
   <p class="row">
    <!-- these behaviors apply to the <p> -->
    <#my-behavior foo="asdf" bar="blah" />
    <#other-behavior-without-attributes />
  </p>
</div>

In that last example, although the markup looks as though behaviors are children, the instances would not appear in childNodes or children, etc. They would only appear in .behaviors (or similar), in the same way that attributes don't appear in childNodes or children.

On this question on if a syntax feature like this is possible, if it is possible, then maybe we can support primitive types too (maybe similar to JSON)? For example:

<p id="myPara">
  <#my-behavior foo="0" bar="'blah'" baz="true" lorem="[0, true, 'foo']" />
</p>
<script>
  const myBehavior = myPara.behaviors.myBehavior
  console.log(typeof myBehavior.foo) // "number"
  console.log(typeof myBehavior.bar) // "string"
  console.log(typeof myBehavior.baz) // "boolean"
  console.log(Array.isArray(myBehavior.lorem)) // true
</script>

Improved HTML?

And if we could do this, we'd probably want this ability on regular elements somehow (namely custom elements):

<p id="myPara" #foo="0" #bar="'blah'" #baz="true" #lorem="[0, true, 'foo']"></p>
<script>
  console.log(typeof myPara.foo) // "number"
  console.log(typeof myPara.bar) // "string"
  console.log(typeof myPara.baz) // "boolean"
  console.log(Array.isArray(myPara.lorem)) // true
</script>

(There'd be no lexical scope, like JSON).

The only thing missing so far would be boolean attributes (foo="" already covers string attributes, but not existing-or-not-existing attributes based on a boolean), and maybe another sigil would be needed:

<p id="myPara" ?foo="true" ?bar="false"></p>
console.log(myPara.hasAttribute('foo')) // true
console.log(myPara.hasAttribute('bar')) // false

(that's based on Lit's html syntax).

If we could adopt . instead of # (I used # because I'm thinking . may be too risky) it would just be a lot nicer:

<p id="myPara" .lorem="[0, true, 'foo']"></p>
<script>
  console.log(Array.isArray(myPara.lorem)) // true
</script>

If we had the above syntax features, then it would encourage the following problem to be solved in all frameworks:

Right now frameworks are just confused, no one knows if foo="bar" should set an attribute, or a JS property. Only a few frameworks get it right, like Lit html and Pota's JSX and html. If the HTML standard set an example here, it would likely get all framework devs thinking on a similar page.

I think a parser update could really help us a lot. Engines already have JSON for primitives. There might be space for behaviors in new syntax, and this would give a reason to languages like JSX to expand, and as a result type checking in TS would expand to support it.

Sorry, that was on a slight tangent, but maybe its a space that is related (better syntax for elements with room for behaviors-or-similar).

@blizzardengle
Copy link
blizzardengle commented Jun 3, 2024

I don't know how well this will fit in with this proposal, but for what its worth I am throwing my hat into the ring:

I have just publicly released my own version of Lume's Element Behaviors (@trusktr) that I have been using privately for my business/projects. I think it may be worth considering some of the differences as part of this proposal.

The interactive manual (documentation) includes a Limitations and Modified Behavior section that I imagine specification authors would like to read, as well as live examples.

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

No branches or pull requests

14 participants