Extending and debugging

Your own directives and plugins

Everything Voodoo.js does internally is available to you: registering directives, creating magic variables, adding components, extending the V object and packing all of that into a plugin.

The short form

A single function, which stands for mounted and updated at once. It is enough for most cases.

<div>
  <script>
    V.directive('size', (el, binding) => {
      el.style.fontSize = binding.value + 'px';
    });
  </script>

  <div v-data="{ n: 18 }">
    <input type="range" min="12" max="48" v-model.number="n">
    <p v-size="n">This text obeys a directive you wrote yourself.</p>
  </div>
</div>

The form with a lifecycle

V.directive('highlight', {
  created(el, binding) {},
  beforeMount(el, binding) {},
  mounted(el, binding) { el.style.background = binding.value; },
  updated(el, binding) { el.style.background = binding.value; },
  beforeUnmount(el, binding) {},
  unmounted(el, binding) {},
  priority: 0,
  raw: false
});
Field of bindingWhat it is
elThe element
valueThe value, already evaluated
oldValueThe previous value, in updated
argThe argument after the colon
modifiersThe modifiers after the dots
expressionThe original text
scopeThe active scope
instanceThe nearest component instance, or null

priority sets the order: higher runs first. raw: true hands you the expression as text, without evaluating it, which suits directives that receive selectors or names.

Integrating an outside library

This is where a directive of your own pays off most: wrap an external component once and use it as an attribute anywhere.

V.directive('datepicker', {
  mounted(el, binding) {
    const picker = new SomeDateLibrary(el, {
      format: binding.arg || 'dd/mm/yyyy',
      initial: binding.value,
      onChange: (value) => {
        el.value = value;
        el.dispatchEvent(new Event('input', { bubbles: true }));
      }
    });
    el.__picker = picker;
  },
  updated(el, binding) { el.__picker.setValue(binding.value); },
  unmounted(el) { el.__picker.destroy(); }
});
<input v-datepicker:dd-mm-yyyy="form.birthday" v-model="form.birthday">
The detail that makes it work

The dispatchEvent of an input is how an outside integration tells v-model. Without it, the library writes into the field and the state never finds out.

Fine control, with defineDirective

For cases that need effects of their own, explicit cleanup or control over the subtree:

import { defineDirective, PRIORITY } from 'voodoojs';

defineDirective(
  'countdown',
  ({ el, evaluate, effect, cleanup }) => {
    let timer = null;

    effect(() => {
      const target = new Date(evaluate());
      clearInterval(timer);
      timer = setInterval(() => {
        const left = Math.max(0, target - Date.now());
        el.textContent = Math.floor(left / 1000) + 's';
        if (left === 0) clearInterval(timer);
      }, 1000);
    });

    cleanup(() => clearInterval(timer));
  },
  { priority: PRIORITY.DEFAULT }
);
ContextWhat it is
elThe element that declared the attribute
scopeThe active scope
expressionThe text of the attribute
arg, modifiersArgument and modifiers
rawThe full attribute name, handy in error messages
evaluate(expr?)Evaluates the expression. Never throws
effect(fn)Creates a reactive effect already tied to the element's cleanup
cleanup(fn)Runs when the element leaves the DOM
walk(node, scope)Initializes HTML created by the directive

The registration options are priority and terminal, which stops the walker from descending into the children — it is what v-for and v-if use. The priorities live in V.PRIORITY: IGNORE (100), FOR (90), IF (80), DATA (70), COMPONENT (65), REF (60), MODEL (40), BIND (30), DEFAULT (0), INIT (-10) and TRANSITION (-20).

Names that are already taken

The registered name does not include the prefix: V.directive('toggle', ...) answers to v-toggle — and overwrites the internal directive of the same name. Before picking a name, check V.directives, the Map holding everything already registered. A prefix of your own, such as v-acme-toggle, avoids the collision for good.

Magic variables of your own

<div>
  <script>
    V.magic('$user', () => ({ name: 'Ann', plan: 'Pro' }));
    V.magic('$height', (scope) => scope.el.offsetHeight);
  </script>

  <div v-data="{}">
    <p>Hello, { $user.name } — { $user.plan } plan.</p>
    <p>The height of this scope is { $height } pixels.</p>
  </div>
</div>

The getter receives the active scope, so a magic variable can depend on where it was used. The dollar sign is added for you when you leave it out. Magic variables are read-only, except for the ones that expose a set method of their own.

Packing it into a plugin

const myPlugin = {
  name: 'analytics',
  install(V, options) {
    V.track = (event, data) => send(options.key, event, data);

    V.directive('track', (el, binding) => {
      el.addEventListener('click', () => V.track(binding.value));
    });

    V.magic('$track', () => V.track);
  }
};

V.use(myPlugin, { key: 'abc123' });

The short function form works too:

V.use((V, options) => {
  V.config.globals.APP = options;
}, { version: '2.0' });

The same plugin installed twice is ignored the second time. Anything fits inside install: components, validation rules with V.validator, masks with V.registerMask, or new methods on V itself.

const brazilPlus = {
  install(V) {
    V.validator('voter-id', (value) => V.unmask(value).length === 12, 'Invalid voter ID.');
    V.registerMask('voterId', '9999 9999 9999');
  }
};

V.use(brazilPlus);
To use it in the HTML, expose it as a global

What you add to V is not visible inside expressions, which only see the scopes, the magic variables and a closed list of globals. Publish what you need on V.config.globals, or create a magic variable with V.magic.

A directive, a component or a magic variable?

What you want to addUse
A behaviour applied to elements that already existA directive
A reused structure of HTMLA component
A value or function available in any expressionA magic variable, or V.config.globals
A set of all three, shipped to other projectsA plugin