Directives are one of the core features of Modulo. It allows for callbacks to be triggered when a particular DOM element is "mounted" or first appears in the DOM. It allows for your custom code to access references to DOM nodes created after rendering.

Directives

Every directive has a name. Every directive is specified as an attribute on the DOM element that you wish to gain access to, by enclosing the name in square brackets. For example, the State CPart has the directive named bind, making the full attribute be [state.bind]. Some directives have shortcut names. For example, [component.event] can be shortened to only a single at-sign, @.

Directives also have callback functions. Just like lifecycle callbacks, a directive callback name is created by suffixing a string to the end of the name of the directive. Unlike lifecycle callback functions, directive callback functions end with Mount and Unmount.

Built-in directives vs custom directives

Modulo ships with a total of 3 built-in directives, defined by the built-in CParts (1 from State and 2 from Component). Typically, the built-in directives are all you need. However, just like with lifecycle functions, the Script CPart also exposes the directive interface to component developers. This is so that you can create custom directives in a component to access the DOM after rendering.

Built-in directives

  • [component.dataProp] (shortcut: suffix :) - Attach data to a DOM element's .dataProp object, which can be used to directly pass renderObj values as Props or events
    • Symbols: ev:=script.gotClicked, data:=state.dataArray, blogs:=props.blogArray etc - Data-props can attach any values that are exposed in the renderObj by any CPart in your component. Use JavaScript reference notation, e.g. state.objdata.prop1 would access something like <State objdata:='{"prop1": "my data"}'>
    • Primitives: arr:=[], obj:={} boo:=true, num:=3, etc - The data-prop can also be used to attach any data in JSON format (whatever "JSON.parse" can parse).
  • [component.event] (shortcut: prefix @) - Attach event listeners to DOM elements, and remove them when the DOM elements are removed. (For jQuery users, this is used for similar purposes as "live" (delegated) events, but is faster.)
    • payload=... - By assigning to the "payload" property, either with a regular HTML attribute or a := data-prop, you can provide extra arguments to events.
    • click.payload=..., mouseover.payload=..., etc - If you have multiple events attached, you can attach separate payloads by prefixing the "payload" attribute with the name of the event followed by a dot.
  • [state.bind] - Two-way binding with State data, with the key determined by the name= property of whatever it is attached to.

Important directive facts: Directives are discovered during the reconcile lifecycle phase when DOM reconciliation is occurring, and invoked during the update phase. Note that they are independent of the Template CPart: You can have a component that has no Template but still may employ directives, e.g. if it generates HTML contents some other way.

Custom directives

Custom directives are like "refs" - Directives have the same uses as Refs in React: “Managing focus, text selection, or media playback, triggering imperative animations, or integrating with third-party DOM libraries.”

Custom directives are used for direct access to the DOM. They are invoked when a particular element is first rendered on the screen (Mount), invoked when that attribute has any changes (e.g. the value gets changed, also Mount), and also invoked when about to be removed from the document (Unmount). This allows you to do custom set-up or tear-down code for particular elements, such as to attach third-party JavaScript frameworks in a convenient manner.

Registering Mount callback

Custom directives can be easily registered in Script CParts simply by defining a function with a certain name. For example, for a directive called myinput directive, you can create a function like such:

function myinputMount(opts) { // ... }

Similarly, If you want to register custom code when an element leaves the screen, such as to clean up references, you can register an Unmount callback:

function myinputUnmount(opts) { // ... }
Example 1: Mount variables

For a simple example that shows the variables avaialble:

<Template> <div [script.myinput]myattr="my value"></div> </Template> <Script> function myinputMount(opts) { const { el, attrName, value } = opts; // Uncomment below to see console logs: /* console.log("element mounted:", el); console.log("with attribute:", attrName); console.log("and value:", value); */ } </Script>
Example 2: Storing Ref

A full, working example is below, which uses a custom directive to focus on an input when a button is clicked:

Example custom directives:

<Template> {# [script.myinput] is a custom directive, defined below #} <input [script.myinput] /> {# [component.event] is a built-in directive (shortcut is @) #} <button [component.event]click:=script.showInfo> Click me </button> </Template> <Script> function myinputMount(mountOptions) { element.inputRef = mountOptions.el; // Try uncommenting the following to see data: //console.log("myinputMount:", mountOptions); } function showInfo(el) { alert("Focusing on:" + String(element.inputRef)); element.inputRef.focus(); } </Script>

Notes on custom directives

  • Note that the functions must be named exactly like this, including capitalization. The attributes, however, are technically not case-sensitive, but the JavaScript portion is.
  • DOM manipulation is what Templates, and many other features of Modulo, are supposed to "save you from". In other words, it gets messy. This is why the it is recommended to try to use the "Modulo" way of State, Templating, and re-rendering, to keep things from getting messy.
  • Only do direct DOM manipulation as a last resort, such as when you are following a tutorial that gives instructions this way, or are combining Modulo with another framework that operates with direct DOM references

Multiple directives

Multiple directives can be attached to the same attribute. For example, @click: has both a component.event directive (@) and a component.dataProp (:) directive. Similarly, multiple custom directives can be applied to the same attribute. For example: [script.hook][script.setup]text="Hello" would be a valid way to register two directives(the imaginary script.hook and script.setup directives, in that order).

Mount and Unmount parameters

Frequently used

  • el will contain a reference to the HTML element
  • value will contain the value you are assigning to the attribute, with any previous directives resolved (e.g. if you combine your directive with a := dataProp directive, you will already have the "true value" be passed in.)
  • attrName will contain the bare attribute name, e.g. the portion of the attribute name (on the left side of the assignment) without the directive syntax

Infrequently used

  • name contains contain the entirety of the attribute name, directive syntax included
  • rawName will typically be the same as name. However, if shortcuts were applied, rawName will show the previous, "unexpanded" verison of the name (e.g. without the regular expression substitution applied).
  • directiveName will contain the name of the current directive being applied — typically not useful since we already know that, it's the function's name!

Mount and Unmount and multiple directive demonstration

The following demonstration shows both Mount and Unmount callbacks in a script tag. Note that in this demonstration, the "Mount count" and "Unmount count" values visually displayed in the <p> tags appear to "lag" behind the actual value — the number of times a Mount or Unmount callback was actually called. This is because the Mount and Unmount happen after the rendering is complete, and thus the rendering can only "report" on the previous value.

Uncommenting the "console.log" statements will reveal what arguments and information are sent along with the callback.

<Template> {% if state.visible %} <h1 [script.thing][script.example]attr-name="value example"> Example H1 </h1> <button @click:=script.hide>(Hide)</button> {% else %} <button @click:=script.show>Show</button> {% endif %} <p>Mount count: {{ state.mcount }}</p> <p>Unmount count: {{ state.ucount }}</p> </Template> <State visible:=false mcount:=0 ucount:=0 ></State> <Script> function show() { state.visible = true; } function hide() { state.visible = false; } function thingMount({ el, value, name, attrName, directiveName, rawName }) { //console.log("Element is mounting (thing)"); //console.log({ el, value, name, attrName, directiveName, rawName }); state.mcount++; } function thingUnmount({ el, value, name, attrName, directiveName, rawName }) { console.log("Element is unmounting (thing)"); console.log({ el, value, name, attrName, directiveName, rawName }); state.ucount++; } function exampleMount({ el, value, name, attrName, directiveName, rawName }) { console.log("Element is mounting (example)"); console.log({ el, value, name, attrName, directiveName, rawName }); state.mcount++; } function exampleUnmount({ el, value, name, attrName, directiveName, rawName }) { console.log("Element is unmounting (example)"); console.log({ el, value, name, attrName, directiveName, rawName }); state.ucount++; } </Script>

Directive pitfalls

A quick word of caution on custom directives: If you find yourself using custom directives in Script CParts to do vanilla JS DOM manipulation often, you are probably doing something wrong! They are meant as an "emergency escape hatch" to gain access to the DOM underneath, and typically you only use them while writing a CPart library, or when you are doing something unusual such as a situational or unique performacne enhancement in mind that requires direct DOM manipulation.

Directives vs. Templates for manipulating DOM

Not sure which to use to manipulate the DOM and generate HTML? Short answer: Templates! Long answer: Directives deal with direct DOM references, and thus are almost always messier to use. Modulo's templating system is designed to generate a string of HTML code as a purposeful limitation during the render phase, and thus prevents this messiness with a stricter structure.

So, when you can, try to make the DOM the "source of truth", and attach data to DOM elements via regular HTML properties or :=-style dataProp properties. In other words, avoid using direct DOM manipulation as your first approach, instead only using this "escape hatch" to vanilla JS as a last resort.

Directives and template variables

Don't get confused when attempting to mix Template CPart variables with directives. Directives cannot access template variables, since directives are only are applied after the template is fully rendered and all template variables are already forgotten. As an example, consider the following code:

<Template> {% for athlete in state.team %} {# (HTML Attributes) #} {# Broken, will not work: #} <a href:=athlete.url>{{ athlete.name }}</a> {# Correct, will work: #} <a href="{{ athlete.url }}">{{ athlete.name }}</a> {# (Events) #} {# Also broken, will not work: #} <button @click:=athlete.myClickCallback> {{ athlete.name }}</button> {# Correct, will work: #} <button @click:=script.selectOne payload="{{ athlete.id }}"> {{ athlete.name }}</button> {% endfor %} </Template>

Why "payload"? Why does Modulo take this approach, vs something like React, that allows direct attachment of anonymous JavaScript functions? As any React developer knows by now, there are a lot of "footguns" (common mistakes) with attaching events like this, specifically because complexities with "this" context, anonymous functions, and bound functions with arguments can make introspection (e.g. interactive debugging) hard.

The Modulo approach is always "DOM determines behavior". Just by using "Inspect" in your browser's Developer Tools, you can examine or even modify the "payload" attribute while debugging event behavior. In other words, Modulo tends to treat the DOM as the "source of truth", and thus derives it's behavior from properties on DOM elements.

The first attempt (HTML Attributes) uses a directive ([component.dataProp], in this case using the colon : shortcut), in an attempt to "directly" attach the URL to href. The second attempt correctly uses the template variable with double curly braces to embed it as an actual HTML property. The first attempt will fail because it fails to take into account that the directives will happen after rendering the template. That is, the resulting HTML will literally resemble something like this: “<a href:=athlete.url>Steph Curry </a> <a href:=athlete.url>Megan Rapinoe</a> <a href:=athlete.url>Devante Adams</a>”.

In other words, the "athlete" variable is a temporary Template variable in the for loop (that is to say, not a variable in the renderObj), and will only be used at the templating step, and forgotten immediately after. Since directives are only invoked once templating is fully completed, there is no way to resolve the variable from the for loop.

What is the proper use of the [component.dataProp] directive, you might ask? The proper use is for direct assignment or attachment of values that are not strings or numbers at the top level of the renderObj. Basically, anything that can't be conveniently serialized into a string attribute. The common usage is passing down complex nested data types as Props (i.e. without having to clutter the DOM and waste memory with a massive JSON object serialized as an attribute), or for attaching callbacks from the renderObj.

We see such correct usage in the second (Events) "correct" example: It references script.selectAthlete which is at the global level. We can tell that it's at the global renderObj level since it starts with script., referencing the contribution to the renderObj that was provided by the Script CPart. The issue with this, however, is that we won't know which button was clicked, since it references one universal selectAthlete function. This is solved by attaching some sort of ID reference to the DOM element as a "payload" so that the callback function knows which athlete was selected. This is the correct approach: It uses the DOM as a "source of truth" and is predictable in behavior, with no "hidden" functions getting attached.

Rewriting direct DOM manipulation

In this section, the goal is to show how direct DOM manipulation might be more cumbersome than using Template and other built-in Modulo features. If you are familiar with vanilla JavaScript, observer how these two approaches differ in the next section.

Using direct DOM manipulation

Examine this odd little example of directly attaching click events using the onclick property, without using the Modulo helper "@click", and then directly using the "textContent" property to modify content.

<Template> {% for num in state.numbers %} <tt [script.explode]>{{ num }}</tt> {% endfor %} </Template> <State numbers:='[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ]' ></State> <Script> function explodeMount({ el }) { // console.log('Explode component Mounting:', value); let numberToLookFor = Number(el.textContent); el.onclick = () => { el.textContent = '-( ( Popped! ) )-'; setTimeout(() => { state.numbers = state.numbers .filter(num => num !== numberToLookFor); element.rerender(); }, 1000); // 1 second! } } function explodeUnmount({ el }) { // console.log('Explode component Unmounting:', value); } </Script> <Style> tt { background: #aaa; display: block; border: 1px solid #777; padding: 3px; } :host { overflow: auto; height: 150px; display: block; } </Style>

Rewritten

Here is the previous code rewritten to be a normal click event. Using Modulo's built-in functionality the code becomes cleaner.

<Template> {% for num in state.numbers %} <tt @click:=script.explode payload="{{ num }}">{{ num }}</tt> {% endfor %} </Template> <State numbers:='[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ]' ></State> <Script> function explode(value) { let numberToLookFor = Number(value); let index = state.numbers.indexOf(numberToLookFor); state.numbers[index] = '-( ( Popped! ) )-'; setTimeout(() => { state.numbers.splice(index, 1); // cut away 1 item at index element.rerender(); }, 1000); // 1 second! } </Script> <Style> tt { background: #aaa; display: block; border: 1px solid #777; padding: 3px; } :host { overflow: auto; height: 150px; display: block; } </Style>