JSPM

  • Created
  • Published
  • Downloads 55
  • Score
    100M100P100Q82496F
  • License MIT

Glue DOM and custom elements together via JSON declarations.

Package Exports

  • be-observant
  • be-observant/be-observant.js
  • be-observant/getElementToObserve.js
  • be-observant/getElementWithProp.js
  • be-observant/getProxy.js
  • be-observant/hookUp.js
  • be-observant/index.js
  • be-observant/setProp.js
  • be-observant/trPlugin.js

Readme

be-observant

Playwright Tests

How big is this package in your project?

be-observant is a key member of the may-it-be family of web components. It allows one DOM element to observe another element, where that element typically comes "before it". It is much like how Javascript closures can access variables defined outside the closure, as long as it came before.

be-observant is also a trend-setting member of the family -- many of the other may-it-be components piggy-back both on the code as well as the syntax for adding "environment-aware" bindings to their configuration properties.

be-observant also provides an experimental declarative trans-render plugin, so the binding can be done while instantiating a template, rather than after the DOM has been added to the live DOM tree.

Sample syntax

<ways-of-science>
    <largest-scale>
        <woman-with-carrot-attached-to-nose></woman-with-carrot-attached-to-nose>
    </largest-scale>
    <largest-scale>
        <a-duck></a-duck>
    </largest-scale>
    <if-diff iff not-equals set-attr=hidden be-observant='{
        "lhs": {"observe": "largest-scale:has(> woman-with-carrot-attached-to-nose", "on":"value-changed", "valueFromTarget": "value"}, 
        "rhs": {"observe": "largest-scale:has(> a-duck)", "on":"value-changed", "valueFromTarget": "value"} 
    }'>
        <template>
            <div hidden>A witch!</div>
        </template>
    </if-diff>
</ways-of-science>

Now hold on just a minute...

A personal statement

Have I learned nothing, you may be asking? "Don't you know props are passed down, events passed up?" Yes, the approach be-observant follows has been declared an "anti-pattern" by many. However, this anti-pattern is somewhat forced on us, when we use custom attributes, and when in addition we want to adhere to the principle of not attaching unrecognized properties on the element adorned by the attribute. Yes, there is an approach, which theoretically the host could use to pass props down. But the intention of these custom attribute / decorators / behaviors is that they be usable within any framework, but especially within any web component library (without having to modify / extend the code), avoiding tight-coupling as much as possible. Requiring this approach for "passing props down," and insisting on allowing no alternative, would get in the way of achieving that goal.

Anyway, be-observant encourages uni-directional data flow, which to me is the more important goal, if these goals are designed to make reasoning about the code easier. (Of course what makes things easier to reason about is quite subjective).

Another benefit of this "anti-pattern" is that it works quite nicely when lazy loading content. The hosting element doesn't need to be micro-managing internal elements coming and going. It is a less "planned" component economic model :-). Underlying this idea is the concept that web components and custom decorators / attributes / behaviors, have a sense of "identity", capable of reacting spontaneously to user events, and able to "think independently" when interacting with peer components, even able to spawn children when the conditions are right, without bogging the host element down in minutia. Reasoning about them may be easier if we can relate to the way they work together to how human organizations function -- or at least non-North Korean Military organizations :-). This approach also seems to be more natural when striving for more declarative, markup-centric, less code-centric environments. be-observant is closely "observing" whether there are any signs of life as far as HTML Modules. Oh, and JQuery, still the most popular framework out there, doesn't follow such a strict hierarchy either, am I right?

Just as custom elements becoming activated relies on css features of the markup (registered tag names), here we also rely on CSS recognizing the attribute, without permission from any host component (though the host has to "opt-in" in a rather light-touch way if using Shadow DOM - by plopping a be-hive element somewhere inside the Shadow DOM realm).

Use Cases

Web Components as a Democratic Organism

be-observant works well with web components that are designed like an organism - with an internal non visual "component as a service" acting as the "brain", and *be-observant" aids in transmitting the "thoughts" from this "brain" to peripheral elements (both built-in and custom).

Progressively Enhancing Server Rendered/Generated content

Because this.

Alternatives

be-observant shares similar syntax / concepts to be-noticed and to pass-down and pass-up.

However, there are some subtle differences in spirit between what these three components are trying to achieve. In many cases, more than one of these components can solve the same problem, so it becomes a matter of "taste" which one solves it better.

What all these components share in common is they do not assume that there is a host component managing state.

For example, the preceding example does not yet assume King Arthur has fully established his kingdom -- it works regardless of a containing component managing state.

The overlap between these four components, functionally, is considerable.

The general guidelines for choosing between these four elements:

General guidelines
Element Basic Purpose Current Limitations
be-observant Pulls down values from previously defined elements as they change, to the element be-observant adorns.
  • Can't attach to non-viewable elements.
  • Can only pass values to a single (adorned) element.
pass-down Acts as a mediator between an observed element and one or more downstream elements (usually).
  • Because it becomes active regardless of visibility, doesn't provide built-in "lazy loading" support.
  • Some HTML markup syntax isn't amenable to custom or unknown elements being placed in the mix (for example, tables are quite finicky about allowed child elements)
  • Perfect match scenario: A markup centric, declarative(ish) web component, where the "brains" of the web component is not in the main web component itself, but in a reusable "ViewModel" component. The ViewModel component isn't visible, and as the view model changes, it needs to be passed down to multiple downstream components.
be-noticed Push up values to previously defined elements as the element be-noticed adorns changes.
  • Can't attach to non-viewable elements
  • Can only pass values to one element per event subscription.
pass-up Push-up values up the DOM hierarchy
  • Because it becomes active regardless of visibility, doesn't provide built-in "lazy loading" support.
  • Some HTML markup syntax isn't amenable to custom or unknown elements being placed in the mix (for example, tables are quite finicky about allowed child elements)
  • Can only pass values to a single element.

Assumptions, shortcuts

Another subtle difference in emphasis between pass-down and be-observant:

Whereas the pass-down component may be more fitting for a 30,000 ft above the ground environment outside any web component (rather, as part of a "web composition" of Native DOM and custom DOM elements), be-observant is more tailored for markup within a web component.

In particular, with be-observant, the shortcuts we provide are based on the assumption that there is such a component container managing (some amount of) state.

So, for example:

 <xtal-editor be-observant='{
    "open": "expandAll",
    "expandAll": "expandAll",
    "readOnly": "readOnly"
}'></xtal-editor>

is interpreted to mean: "any time the host's expandAll property changes (communicated via expand-all-changed event), set this instance's "open" property to the same value. Likewise with the other two lhs/rhs pairs.

The host is obtained by calling the native function el.getRootNode(). If that is a miss, it searches for the closest parent containing a dash.

To specify a different source to observe other than the host, there are numerous other options, which are catalogued below.

Notes

Note: Editing large JSON attributes like this is quite error-prone, if you are like me. The json-in-html VSCode extension can help with this issue. That extension is compatible with pressing "." on the github page and with the web version of vs-code. An even better editing experience can be had by using .mts/.mjs files to define the html, with the help of a transpiler such as the may-it-be transpiler.

Note: The attribute name "be-observant" is configurable. "data-be-observant" also works, with the default configuration. The only limitation as far as naming is the attribute must start with be-* (which also guarantees data-be-* as well).

Note: The syntax, and the core code behind be-observant, is also used by a fair number of other web components in the may-it-be family of web components, so it is worthwhile expounding on / understanding exactly what that syntax means, if we wish to be fluent in be-speak.

Note: The be-observant attribute can also be an array, allowing for grouping of observers, and observing duplicate events or properties.

Note: If a property key (lhs) starts with ^, then the previous key that didn't start with a ^ is substituted. This provides for a more compact way to avoid use of arrays.

Syntax in depth

What

First we need to choose what to observe. This is done via a number of alternative keys:

What to observe
Key Meaning
unspecified Observe the host element via the native function el.getRootNode(). If that is a miss, it searches for the closest parent containing a dash.
observe Do an "up-search" -- previous siblings, parent, previous siblings of parent, etc, until an element "css matching" the value of "observe" is found. Stop at any ShadowDOM boundary.
observeClosest Use the native function call "closest" to find the element to observe.
observeClosestOrHost (abbrev ocoho) Use the native function call "closest". If that's null, do el.getRootNode()
observeName (abbrev ona) Finds the closest containing form using .closest('form') and searches for an element within that form with matching name. If not found, does an "upSearch" for it, as described above, stopping at any ShadowDOM boundary.
observeSelf (abbrev os) Observe self
observeInward (abbrev oi) Use the native function call "querySelector" to find the first matching element to observe within the adorned element. If element not found, expect a be-a-beacon, verify instance of be-a-beacon
observeAtLarge [TODO] (abbrev oal) Do a querySelector from the root host. If element not found, expect a be-a-beacon, verify instance of be-a-beacon
observeWinObj (abbrev owo) Observe window object. Example: observeWinObj:'navigation'

Homing in [Untested]

Having selected a DOM element to observe, we may optionally want to observe a sub object as our "container" to observe from. These sub objects might extend EventTarget, meaning they have events we can subscribe to. The assumption here is that unlike other typical properties of the DOM element, which might be transient, properties we would want to home in on are "stable, permanent" properties that may, for example, point to a store (MobX, for example).

This is done via parameter homeInOn, which specifies a "dot" delimited path from the element to observe found above.

When to act

Once we find the element (or Event Target) to observe, next we need to specify what property or event to listen to on that element / Event Target.

When to react
Key Meaning Notes
on Name (or "type") of event to listen for. Uses the standard el.addEventListener([on]) If not specified, will be set to the value of valFromTarget, after turning it into lisp case and appending with -changed.
onSet Watches for property changes made via invoking the setter of the property.
skipInit Do not pass in the initial value prior to any events being fired. Needed when it doesn't make sense to do anything until the user has directly interacted with the observed element.
eventListenerOptions Specify whether to only listen once. Or to capture events that don't bubble.
eventFilter Filter out events if they don't match this object (things like keyCode can be specified here).

What to pass

Next we specify what to pass to the adorned element from the element we are observing and possibly from the event.

Getting the value
Key Meaning Notes
valFromTarget (abbrev vft)
  1. If a string is specified: Specify a path from the target to "pull" when the event is raised or the property changes, using "." notation. Use | for limited support for method invocation. E.g. "current.getState|" will invoke the getState method on the current object. Common use case: querySelector|selector.
Can also be used to auto-set the "on" value as described above.
valFromEvent (abbrev vfe) Specify a path from the event to "pull" when the event fires

Adjusting the value

There are some frequent requirements when it comes to adjusting the value obtained from the target / event, prior to passing the value. The options to do this are listed below:

Value Adjustments
Key Meaning Notes
clone Do a structuredClone of the value before passing it Makes it almost impossible to experience unexpected side effects from passing an object from one component to another
parseValAs If the value extracted from the target / event is of type string, often we want to parse the string before passing the value. Options: 'int' | 'float' | 'bool' | 'date' | 'truthy' | 'falsy' | '' | 'string' | 'object'. The option 'string' is actually the opposite, taking an object and JSON.stringify'ing it.
trueVal If val is true, set property to the trueVal specified
falseVal If val is false, set property to the falseVal specified
translate If val is a number add the translate value to it before setting the property
asWeakRef Some values of valueFromTarget (vft) can resolve to a DOM element. For example, setting vft = '.' resolves to the target element itself. This option allows the property to be set to a weak reference to the DOM element, so that garbage collection can release it more effectively when the element goes out of scope.

Alternative End Point

By default, the lhs of each be-observant setting specifies the name of the property to set.

But we can instead opt to set an attribute.

Possible values are shown below

export interface AlternateEndPoint {
    /** Set attribute rather than property. */
    as?: 'str-attr' | 'bool-attr' | 'obj-attr' | 'class' | 'part',
}

Side Effects

We can apply some "side effects":

Side Effects
Key Meaning Notes
debug Pause JS execution when be-observant is invoked
fire Emit a custom event after setting the property on the element. Most common use case: Setting the value of an input element, and want downstream elements to see it as if a user entered the value
nudge Slowly "awaken" a disabled element after be-observant has latched on. If the disabled attribute is not set to a number, or is set to "1", removes the disabled attribute. If it is a larger number, decrements the number by 1. Useful for avoiding elements to appear interactive, but don't function properly until the component is properly hydrated.
stopPropagation Prevent event from continuing to bubble.

NB

Come clean with me, you've created some declarative loopholes you could drive a truck through.

Good catch...

Doesn't supporting "." notation in the object path allow for unexpected side effects when accessing getters from a custom element?

First, this isn't a risk if the data source is JSON, but it is a bit of a risk here. However, I think it's considered a good practice not to allow getters to have side effects, and this component is assuming it is running in an environment where all the neighboring elements have been vetted in some way for following good practices via opt-in.

What about allowing the invocation of methods via the | separator?

Now you got me. So to support this concern, we provide an optional setting: allowedMethods -- an array of strings that will only permit methods from that list to be invoked. This could be set in bulk during a build process, and/or highlighted in red with the help of an IDE tool like a (de)linter. [TODO]

Big time short cuts.

When working with be-observant, we will likely encounter the following patterns rather frequently:

<my-element be-observant='{
    "typicalProp1": {"vft": "myHostProp1"},
    "typicalProp2": {"onSet": "myHostProp2", "vft": "myHostProp2"}
}'>

These are already shortcuts for a number of configurable atomic operations, but even these shortcuts get to feeling rather repetitive in many circumstances.

The shortcut for these two scenarios is shown below:

<my-element be-observant='{
    "typicalProp1": "myHostProp1",
    "typicalProp2": ".myHostProp2"
}'>

Being host-ish

Sometimes there are scenarios where we would like to benefit from the shortcuts above, but don't want to use Shadow DOM on a containing component just for the benefit of the shortcut.

An element can declare itself to be a host for these purposes by adding global attribute "itemscope". Be-observant searches for such an element before doing the getRootNode() call.

Under the hood, this scenario will use another option: observeClosestOrHost (ocoho for short), which tries using the native "closest" query first, and if that fails, does getRootNode()

Configuration Parameters

Performed during template instantiation

be-observant also provides a declarative trans-rendering plugin, trPlugin.js.

During instantiation of the template, if the trPlugin library is loaded in memory, it can bind the initial values prior to the HTML landing in the live DOM tree. If not, no problemo.

Just have the be-observant library described above, and it will, using almost completely shared code, achieve the same result.

Sample Markup.

Add filter [TODO]

If the property we are observing resolves to an array, allow filtering the array based on some conditions (like mongodb query).

where:{ ifAllOf:[], ifNoneOf:[], ifEquals:[], }

Viewing Locally

  1. Install git.
  2. Fork/clone this repo.
  3. Install node.
  4. Open command window to folder where you cloned this repo.
  5. npm install

  6. npm run serve

  7. Open http://localhost:3030/demo/dev in a modern browser.

Importing in ES Modules:

import 'be-observant/be-observant.js';

const {importFromScriptRef} = await import('be-observant/importFromScriptRef.js');

Using from CDN:

<script type=module crossorigin=anonymous>
    import 'https://esm.run/be-observant';
</script>