be-decorated

be-decorated provides a base class that enables attaching ES6 proxies onto other 'Shadow DOM peer citizens] -- native DOM or custom elements in the same Shadow DOM realm.

Usage no npm install needed!

<script type="module">
  import beDecorated from 'https://cdn.skypack.dev/be-decorated';
</script>

README

be-decorated

Actions Status

be-decorated provides a base class that enables attaching ES6 proxies onto other "Shadow DOM peer citizens" -- native DOM or custom elements in the same Shadow DOM realm.

be-decorated provides a much more "conservative" alternative approach to enhancing existing DOM elements, in place of the controversial "is"-based customized built-in element standard-ish.

In contrast to the "is" approach, we can apply multiple behaviors / decorators to the same element:

#shadow-root (open)

    <black-eyed-peas 
        be-on-the-next-level=11
        be-rocking-over-that-bass-tremble
        be-chilling-with-my-motherfuckin-crew
    ></black-eyed-peas>

    <!-- Becomes, after upgrading -->
    <black-eyed-peas 
        is-on-the-next-level=11
        is-rocking-over-that-bass-tremble
        is-chilling-with-my-motherfuckin-crew
    ></black-eyed-peas>

Priors

be-decorated's goals are quite similar to what is achieved via things commonly referred to as "custom directives."

Differences to these solutions:

  1. This can be used independently of any framework (web component based).
  2. Each decorator can be imported independently of others via ES6 proxies.
  3. Definition is class-based.
  4. Applies exclusively within Shadow DOM realms.
  5. Reactive properties are managed declaratively via JSON syntax.
  6. Namespace collisions easily avoidable within each shadow DOM realm.

Prior to that, there was the heretical htc behaviors.

Basic Syntax

To define a decorator, define a "controller" class. The structure of the class is fairly wide open. The lifecycle event methods can have any name you want. For example:

export class ButterbeerController{
    #self: ButterbeerCounterProps | undefined;
    init(self: ButterbeerCounterProps, btn: HTMLButtonElement){
        this.#self = self;
        btn.addEventListener('click', this.handleClick)
        self.count = 0;
        
    }
    onCountChange(){
        console.log(this.#self!.count);
    }
    handleClick = (e: MouseEvent) => {
        this.#self!.count++;
    }
}

Then use (mostly) JSON configuration to instruct be-decorated how to apply the decorator onto elements:

import {ButterbeerController} from '[wherever]';
import {define} from 'be-decorated/be-decorate.js';

define({
    config:{
        tagName: 'be-a-butterbeer-counter',
        propDefaults:{
            virtualProps: ['count'],
            upgrade: 'button',
            ifWantsToBe: 'a-butterbeer-counter',
            intro: 'init'
        },
        actions:{
            'onCountChange': {
                ifKeyIn: ['count']
            }
        }
    },
    complexPropDefaults:{
        controller: ButterbeerController,
    }
});

Note the specification of "virtualProps". Use of virtualProps is critical if we want to be guaranteed that our component doesn't break, should the native DOM element or custom element be enhanced with a new property with the same name.

Within each shadow DOM realm, our decorator web component will only have an effect if an instance of the web component is plopped somewhere inside that shadow DOM.

Although it is a bit of a nuisance to remember to plop an instance in each shadow DOM realm, it does gives us the ability to avoid name conflicts with other libraries that use custom attributes. In the example above, if we plop an instance inside the shadow DOM with no overrides:

<button be-a-butterbeer-counter='{"count": 30}'>Count</button>
...

<be-a-butterbeer-counter-bahrus-github></be-a-butterbeer-counter-bahrus-github>

then it will affect all buttons with attribute be-a-butterbeer-counter within that shadow DOM.

To specify a different attribute, override the default "ifWantsToBe" property thusly:

<button be-a-b-c='{"count": 30}'>Count</button>
...

<be-a-butterbeer-counter-bahrus-github if-wants-to-be=a-b-c></be-a-butterbeer-counter-bahrus-github>

Another silver lining to this nuisance: It provides more transparency where the behavior modification is coming from.

The be-hive component makes managing this nuisance almost seamless. If developing a component that uses more than a few decorators, it is probably worth the extra dependency.

Note the use of long names of the web component. Since the key name used in the markup is configurable via if-wants-to-be, using long names for the web component, like guid's even, will really guarantee no namespace collisions, even without the help of pending standards. If be-hive is used to help manage the integration, developers don't really need to care too much what the actual name of the web component is, only the value of if-wants-to-be, which is configurable within each shadow DOM realm.

Setting properties of the proxy externally

Just as we need to be able to pass property values to custom elements, we need a way to do this with be-decorated elements. But how?

The tricky thing about proxies is they're great if you have access to them, useless if you don't.

Approach I. Programmatically (Ugly, not guaranteed)

The instance of the decorator component sitting inside the Shadow DOM has a key to getting the controller class. Assuming we've waited long enough:

function getProxy(btn){
const proxy = shadowRoot.querySelector('be-a-butterbeer-counter').targetToController.get(btn).proxy;
}

Approach II. Setting properties via the controlling attribute:

A more elegant solution, perhaps, which xtal-decor supports, is to pass in properties via its custom attribute:

<list-sorter upgrade=* if-wants-to-be=sorted></list-sorter>

...

<ul be-sorted='{"direction":"asc","nodeSelectorToSortOn":"span"}'>
    <li>
        <span>Zorse</span>
    </li>
    <li>
        <span>Aardvark</span>
    </li>
</ul>

After list-sorter does its thing, the attribute "be-sorted" switches to "is-sorted":


<ul is-sorted='{"direction":"asc","nodeSelectorToSortOn":"span"}'>
    <li>
        <span>Aardvark</span>
    </li>
    <li>
        <span>Zorse</span>
    </li>
</ul>

You cannot pass in new values by using the is-sorted attribute. Instead, you need to continue to use the be-sorted attribute:


<ul id=list is-sorted='{"direction":"asc","nodeSelectorToSortOn":"span"}'>
    <li>
        <span>Aardvark</span>
    </li>
    <li>
        <span>Zorse</span>
    </li>
</ul>

<script>
    list.setAttribute('be-sorted', JSON.stringify({direction: 'desc'}))
</script>

A vscode plug-in is available that makes editing JSON attributes like these much less susceptible to human fallibility.

Approach III. Integrate with other decorators -- binding decorators -- that hide the complexity

be-observant provides a pattern, and exposes some reusable functions, for "pulling-in" bindings from the host or neighboring siblings. This can often be a sufficient and elegant way to deal with this concern.

API

This web component base class builds on the provided api:

import { upgrade } from 'xtal-decor/upgrade.js';
upgrade({
    shadowDOMPeer: ... //Apply trait to all elements within the same ShadowDOM realm as this node.
    upgrade: ... //CSS query to monitor for matching elements within ShadowDOM Realm.
    ifWantsToBe: // monitor for attributes that start with be-[ifWantsToBe], 
}, callback);

API example:

import {upgrade} from 'xtal-decor/upgrade.js';
upgrade({
    shadowDOMPeer: document.body,
    upgrade: 'black-eyed-peas',
    ifWantsToBe: 'on-the-next-level',
}, target => {
    ...
});

The API by itself is much more open ended, as you will need to entirely define what to do in your callback. In other words, the api provides no built-in support for creating a proxy and passing it to a controller.

For the sticklers

If you are concerned about using attributes that are prefixed with the non standard be-, use data-be instead:

<list-sorter upgrade=* if-wants-to-be=sorted></list-sorter>

...

<ul data-be-sorted='{"direction":"asc","nodeSelectorToSortOn":"span"}'>
    <li>
        <span>Zorse</span>
    </li>
    <li>
        <span>Aardvark</span>
    </li>
</ul>

Monitoring

Any be-decorated based decorator/behavior can be configured to emit namespaced events via the emitEvents property.

If set to true, then all property changes will emit an event whenever a property change is made via the proxy.

For example, if a property "foo" is modified via the proxy, and emitEvents is set to either true, or an array containing "foo", then an event will be dispatched from the adorned element with name "[if-wants-to-be]::foo-changed".

Reserved, Universal Events

If emitEvents is defined, then when the proxy has been established, the target element will emit event:

"[if-wants-to-be]::is-[if-wants-to-be]".

For example, this behavior:

<form be-reformable='{}'>
</form>

will emit event "reformable::is-reformable" when the proxy has been created.

The detail of the event contains the proxy, and the controllerInstance.

Debugging

Compared to working with custom elements, working with attribute-based decorators is more difficult, due to the issues mentioned above -- namely, the difficulty in getting a reference to the proxy.

But if the JSON attribute associated with a decorator has value "debug": true, then an adjacent debugging template element is inserted, that makes viewing the proxy and controller much easier.

In dev tools, after inspecting the element, just look for that adjacent template element, select it in the elements exporer, and in the console, type $0.controller to show the class behind the behavior.

You should then be able to use the context menu to jump to the definition. You can view virtual properties by typing $0.controller.[name of virtual property]. You can edit the value by typing $0.proxy.[name of virtual property] = "whatever you want."

Primary prop

Sometimes a decorator will only have a single, primitive-type property value to configure, at least for the time being. Or maybe there are multiple props, but one property in particular is clearly the most important, and the other properties will rarely deviate from the default value. In that case, the extra overhead from typing and parsing JSON just to read that value seems like overkill. So we should have a way of defining a "primary" property, and just set it based on the string value, if the string value doesn't start with a { or a [.

Name of the property: "primaryProp"

Viewing example from git clone or git fork:

Install node.js. Then, from a command prompt from the folder of your git clone or github fork:

$ npm install
$ npm run serve

Open http://localhost:3030/demo/dev.html