Re: [WICG/webcomponents] Proposal: Custom attributes for all elements, enhancements for more complex use cases (Issue #1029)

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.

@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.:

```js
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:

```js
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.

```html
<div id="div" has="foo"></div>
```
```js
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:

```js
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`:

```js
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:

```js
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,

```js
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:

```js
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:

```js
<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:

```js
<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:

```html
<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:

```html
<button has="cool-button" cool-foo="foo" cool-bar="bar">
```
or
```html
<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):

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

customAttributes.define('with-moods', WithMoods)
```
```html
<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:

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

foo() {...}

connectedCallback() {
  this.shadowRoot.innerHTML = `
    <div onclick="foo"></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. Why 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:

```js
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`.

-- 
Reply to this email directly or view it on GitHub:
https://github.com/WICG/webcomponents/issues/1029#issuecomment-1726399654
You are receiving this because you are subscribed to this thread.

Message ID: <WICG/webcomponents/issues/1029/1726399654@github.com>

Received on Tuesday, 19 September 2023 20:08:48 UTC