III. O components - bahrus/trans-render GitHub Wiki

This package provides first-class support for non-visual web components.

There are numerous scenarios where we want to build a component and not impose any rendering library performance penalty. They generally fall into one of these four scenarios and counting:

  1. "Web Components as a Service": Providing a timer component or some other non visual functionality.
  2. Providing a wrapper around a third-party client-side library that does its own rendering. Like a charting library.
  3. Providing a wrapper around server-rendered content.
  4. "Web Components as an Organism". Providing a viewModel from raw data that serves as a non-visual "brain" component that handles all the difficult JavaScript logic, and that is all. Other non-visual components transmit updates from the 'brain" component to peripheral visual components, and dispatch updates back up to the "brain" component. All contained within a large single web component (the body). Kind of the same as 1, but with a little more context.

Use case I: The time-ticker web component

Let's take a look at one such published web component, built with O:

import {O, OConfig} from 'trans-render/froop/O.js';
import {Actions, AllProps, EventTargetProps, PAP} from './types';
import {getNextValOfLoop} from 'trans-render/positractions/getNextValOfLoop.js';
import {dispatchEvent} from 'trans-render/positractions/dispatchEvent.js';

export class TimeTicker extends O implements Actions{
    static override config: OConfig<AllProps, Actions, EventTargetProps> = {
        propDefaults:{
            ticks: 0,
            idx: -1,
            duration: 1_000,
            enabled: true,
            loop: false,
            wait: true,
        },
        propInfo:{
            enabled:{
                dry: false,
                parse: true,
                type: 'Boolean',
            },
            disabled: {
                type: 'Boolean',

            },
            item:{
                type: 'Object',
                ro: true,
            },
            items: {
                type: 'Object',
                parse: true,
                attrName: 'items'
            },
            loop:{
                type: 'Boolean',
                parse: true,
                attrName: 'loop'
            },
            repeat:{
                type: 'Number',
                parse: true,
                attrName: 'repeat',
            },
            ticks: {
                ro: true,
            },
            timeEmitterAC: {
                ro: true,
            },
            timeEmitter:  {
                ro: true,
            },
            wait: {
                type: 'Boolean',
                parse: true,
                attrName: 'wait'
            }
        },
        actions:{
            start:{
                ifAllOf: ['duration'],
                ifNoneOf: ['disabled']
            },
            rotateItem: {
                ifKeyIn: ['repeat', 'loop', 'idx'],
                ifAllOf: ['items'],
                ifNoneOf: ['disabled']
            },
            stop: {
                ifAllOf: ['disabled', 'timeEmitterAC']
            }
        },
        compacts: {
            negate_enabled_to_disabled: 0,
            pass_length_of_items_to_repeat: 999_999_999
            
        },
        handlers: {
            timeEmitter_to_incTicks_on: 'value-changed'
        },
        positractions: [
            {
                do: 'getNextValOfLoop',
                ifAllOf: ['ticks'],
                pass: ['idx', 0, 'repeat', 1, true],
                assignTo: ['idx'],
            },
            {
                do: 'de',
                ifKeyIn: ['idx'],
                pass: ['$0', '`value-changed`']
            }
        ]
        
    }
    de = dispatchEvent;
    getNextValOfLoop = getNextValOfLoop;

    async start(self: this){
        const {timeEmitterAC: oldController, ticks: oldTicks, duration} = self;
        let ticks = oldTicks;
        if(oldController !== undefined){
            ticks = 0;
            oldController.abort();
        }
        const timeEmitterAC = new AbortController();
        const {TimeEmitter} = await import('./TimeEmitter.js');
        const timeEmitter = new TimeEmitter(duration, timeEmitterAC.signal);
        return {
            timeEmitterAC,
            ticks,
            timeEmitter
        } as PAP;
    }

    incTicks(self: this){
        const {ticks: oldTicks} = self
        return {
            ticks: oldTicks + 1
        } as PAP;
    }

    stop(self: this){
        const {timeEmitterAC: oldController} = self;
        oldController!.abort();
        return {
            timeEmitterAC: undefined
        } as PAP;
    }

    rotateItem(self: this){
        const {idx, items} = self;
        return {
            item: (items && items.length > idx && idx > -1) ? items[idx] : undefined,
        }
    }

}

export interface TimeTicker extends AllProps{}

Why O?

  1. It is the origin of this hierarchy of web component extensions.
  2. It is focused on observing and orchestrating changing props and action methods in a roundabout way.
  3. It forms a good basis for building web components as a democratic organism.
  4. It is short.

Talking Points

  1. The methods within the class are 100% all side-effect free. It is the "FROOP reactive orchestrator", based on the roundabout library, that takes the blame for all such side effects. These configurations are declaratively configured within the "compacts", "actions", "infractions" and "positractions" sections of the configuration, that routes method calls from prop changes, and causes side effects.
  2. "this" is used sparingly in the class (aside from the convenient, optional Typescript "type" in all the method destructuring). In particular, if the "actions" that are orchestrated by the roundabout engine returns property value change suggestions, they are assigned gingerly into the class instance (DOM element) by the roundabout engine. However, if you like "this", use it, if you must. Things should still work, but might not be as optimal, and you may need to incur more boilerplate in doing so.
  3. Approximately 70% of the lines of "code" in this class are JSON serializable (not counting a generic helper library which the web component is essentially wrapping). In particular, everything inside the "config" section. As browsers add support for JSON modules, we can cut the JS size by 2/3rds by moving all that JSON configuration to a JSON import, which is kinder to the browser's cpu.
  4. The ability to filter when methods are called using the "ifAllOf", "ifKeyIn", "ifNoneOf" means our actual code can avoid much of the clutter of checking if properties are undefined.
  5. The action methods are library neutral.
  6. Since all the non-library-neutral definition is ultimately represented as JSON, it is as easy as pie to convert the "proprietary" stuff to some other proprietary stuff down the road, especially if that other property stuff is as equally committed to avoiding vender lock-in.

Counterpoints

One argument against this approach might go as follows:

Isn't this use of public action methods limiting the developer by not allowing for private methods?

What is the harm done in allowing for these public action methods? They are side effect free. They are almost like static methods, and can be reused across web component classes that don't inherit from one another.

The only argument in favor of private methods is they minify more effectively.

I suppose another argument might be if the developer doesn't want third party classes from leveraging them, because that could limit the developer's ability to refactor, without worrying about breaking anyone else's code.

[TODO - provide solution to mitigate this minor concern - add "hooks" that map what roundabout sees to the private methods? Maybe with proxies?]

There is a weak argument that "cluttering" the class with all these methods makes intellisense harder -- picking and choosing the right method to invoke becomes more challenging with so many choices. But this can be mitigated by breaking groups of methods into various interfaces.

Attributes on demand

If an element emits an attribute, and nothing is there to listen for it, did the attribute actually get emitted? More importantly, was it worth the extra processing power needed to add it to the DOM, if no one cares?

Attribute reflection can certainly be useful, even in our new world where elements can emit custom state.

Some things that attribute reflection "can do" that custom state can't:

  1. The ability to conduct wildcard matches. Lack of this ability makes it problematic to use custom state for non binary state especially.
  2. Make the HTML state persistable via a simple myElement.outerHTML.
  3. Ability to style from externally may also be impeded.
  4. Changes to state via attribute reflection can be done with mutation observers. But this doesn't work for custom state.

The Hidden Power of Custom States For Web Components provides a strong argument for where custom state does shine, that makes sense to me.

So based on all this, the O-based web components take the following approach to reflection:

Attribute reflection is supported when both the developer and the consumer opt-in.

  1. The developer opts in via the config section. For example:
static config = {
    ...
    propInfo: {
        count:{
            type: 'Number',
            parse: true,
            attrName: 'count',
        },
        stepSize:{
            type: 'Number',
            parse: true,
            attrName: 'step-size'
        }
    }
}
What about propDefaults generated properties?

Note that "propDefaults" config setting, which allows for a quick way of adding a property with a default value, that in some cases an automatic corresponding attribute is created. Those default props that are set initially to strings or to the boolean "false" value are also automatically "opted-in" for attribute digestion and reflection. They are paired up with the kebab-based attribute corresponding to the propDefault, unless an additional propInfo setting is provided.

  1. What makes this library different, is that the consumer must also opt-in, in order to reduce unnecessary churn in the DOM:
<style>
    up-down-counter {
        --attrs-to-reflect: count step-size;
    }
</style>
...
<up-down-counter></up-down-counter>

Web component authors who utilize ShadowDOM will mostly likely want to include the following style rule in their Shadow DOM:

<style>
* {
    --attrs-to-reflect: initial;
}
</style>

so as not to cause child custom elements to inadvertently trigger attribute updates without intending to do so.

If a web component doesn't use shadowDOM, then a more difficult to produce style rule is needed:

<style>
    up-down-counter {
        --attrs-to-reflect: count, step-size;
    }
    up-down-counter * {
        --attrs-to-reflect: initial;
    }
</style>

To reflect all observed attributes, use the * symbol:

<style>
    up-down-counter {
        --attrs-to-reflect: *;
    }
    up-down-counter * {
        --attrs-to-reflect: initial;
    }
</style>

Custom State Reflection

Now for custom state we follow an even more customizable approach.

Custom state reflection is only available for properties of type boolean, number and string.

Booleans

For booleans, just specify each one individually:

<style>
    time-ticker {
        --custom-state-exports: enabled, disabled;
    }
    
</style>

Bonus benefit: This makes it really easy for another developer to "discover" what custom states are applicable, something that appears to be lacking with the current browser developer tools.

Strings [WIP]

For strings, we can specify a mapping:

<style>
    alert-component {
        --custom-state-exports: 
            alertTypeIndicatesSuccess if alertType==success, 
            alertTypeIndicatesFailure if alertType==failure
        ;
    }
    
</style>

We can also specify wildcard matching [TODO]:

<style>
    alert-component {
        --custom-state-exports: 
            alertTypeIndicatesSuccess if alertType*=success, 
            alertTypeIndicatesFailure if alertType$=failure
        ;
    }
    
</style>

We adopt the same symbol for the wildcard matching is is used for attribute selectors

Numbers

Finally for numbers, we can specify modulo checks, and greater than or less than checks

<style>
    alert-component {
        --custom-state-exports: 
            ticksInSecondQuarter if ticks % 4 == 1, 
            ticksInFourthQuarter if ticks % 4 == 3,
            ticksLessThan20 if ticks < 20,
            ticksGreaterThanOrEqualTo30 if ticks >= 30
        ;
    }
    
</style>

Form Associated Custom Element (FACE) and Accessbility value-add support

To specify that a custom element is form associated, just use the platform and specify the static property formAssociated = true.

O components provide the following related support when configuring properties, so as to move more "code" to JSON configuration, away from mindless repetitive JavaScript code executing in the main thread:

export interface PropInfo{
...
    /**
     * form associated read only property
     * examples: form, validity, validityMessage, willValidate
     */
    farop?: boolean;
    /**
     * form associated read only method
     * examples: checkValidity, reportValidity
     */
    farom?: 'checkValidity' | 'reportValidity';
    
    /**
     * form associated write method
     */
    fawm?: 'setValidity' | 'setFormValue'

    /**
     * internals pass through property
     * examples: role, ariaRowIndex
     */
    ip?: boolean;
}

[TODO] Document more with examples

Element Internals Accessibility value-add [TODO]

Other O-based web components in the wild:

  1. fetch-for
  2. ob-session code and config
  3. purr-sist [TODO]
  4. (Various charting web components)[TODO]

<= Signals vs Roundabouts => Debugging

⚠️ **GitHub.com Fallback** ⚠️