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
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 peripheral elements (both built-in and custom) in reading the "thoughts" from this "brain".
Progressively Enhancing Server Rendered/Generated content
Because this.
Assumptions, shortcuts
be-observant is an attribute based alternative to the pass-down web component. A lengthy discussion comparing them is found here.
As mentioned there, one 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 (SSR/SSG rendered) web component.
In particular, with be-observant, the shortcuts we provide are based on the assumption that more often than not, there is 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.
Note: As we will see, be-observant provides quite a bit of functionality. As much as possible, be-observant attempts to keep it scalable by only loading code on demand -- so if features aren't used, it won't add to the payload.
Syntax in depth
What
First we need to choose what to observe.
be-observant supports no less than two alternative schemes for deciding this: One that reuses common functionality used by many be-decorated elements, with a reduced set of options.
The other is more powerful, with more options and has lots of intricate cross-logic fallbacks from one option to another (should one fail to produce the desired element), which is both a blessing and a curse.
The beautiful thing about dynamic imports is if you choose to use one scheme across the board, there's no penalty from the fact that another supporting another scheme which you never use is present (i.e. it shouldn't show up as unused code by dev tools).
First option: of
First, the simpler common approach: [TODO]
Simple example of the syntax:
<my-custom-element be-observant='{
"myProp": {"on": "click", "of": "parent"}
}>The full list of options for the simple schema is shown below:
/**
* Target selector in upward direction.
*/
export type Target =
/**
* Use the parent element as the target
*/
'parent' |
/**
* abbrev for parent
*/
'p' |
/**
* Use the element itself as the target
*/
'self' |
/**
* abbrev for self
*/
's' |
/**
* Use the native .closest() function to get the target
*/
['closest', string] |
/**
* abbrev for closet
*/
['c', string] |
/**
* Find nearest previous sibling, parent, previous sibling of parent, etc that matches this string.
*/
['upSearch', string] |
/**
* abbrev for upSearch
*/
['us', string] |
/**
* If second element is true, then tries .closest('itemscope'). If string, tries .closest([string value])
* If that comes out to null, do .getRootNode
*/
['closestOrHost', boolean | string] |
/**
* abbrev for closestOrHost
*/
['coh', true | string] |
/**
* get host
*/
'host' |
/**
* abbrev for host
*/
'h'
;Option 2: multi-key
This is done via a number of alternative keys:
| 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) | Uses the native function call "querySelector" to find the first matching element to observe within the adorned element. If a matching element is not found, it expects a be-a-beacon element to announce that parsing has completed. |
| 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. Awaiting a strong use case before implementing. |
| observeWinObj (abbrev owo) | Observe window object. Example: observeWinObj:'navigation' |
Homing in
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.
| 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). | |
| eval | portmanteau of "event" and "val". There is a growing number of use cases where the name of the event matches the path to extract from the target, after translating camel to lisp case |
What to pass
Next we specify what to pass to the adorned element from the element we are observing and possibly from the event.
| Key | Meaning | Notes |
|---|---|---|
| valFromTarget (abbrev vft) |
|
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:
| 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' | 'regExp'. 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":
| 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. |
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:
| Element | Basic Purpose | Current Limitations |
|---|---|---|
| be-observant | Pulls down values from previously defined elements (typically) as they change, to the element be-observant adorns. |
|
| pass-down | Acts as a mediator between an observed element and one or more downstream elements (usually). |
|
| be-noticed | Push up values to previously defined elements as the element be-noticed adorns changes. |
|
| pass-up | Push-up values up the DOM hierarchy |
|
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 / API
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.
Viewing Locally
- Install git.
- Fork/clone this repo.
- Install node.
- Open command window to folder where you cloned this repo.
npm install
npm run serve
- Open http://localhost:3030/demo/dev in a modern browser.
Importing in ES Modules:
import 'be-observant/be-observant.js';Using from CDN:
<script type=module crossorigin=anonymous>
import 'https://esm.run/be-observant';
</script>