Skip to content

Plugins

Plugins add your own b- directives, $ helpers, and components to an app. Built-in directives keep working exactly as documented; a plugin can only add names, never change existing ones. Brio’s own add-ons (inputs, observers, and the router outlet) are plugins built on this same API.

A directive is an object with a name and one or more hooks:

import { createApp, defineDirective } from '@jnreis/briojs';
const tooltip = defineDirective({
name: 'b-tooltip',
arg: true, // also matches b-tooltip-top, b-tooltip-left, ...
modifiers: ['hover'], // anything else is an error that names the attribute
mount(element, binding) {
const tip = document.createElement('span');
tip.className = `tooltip ${binding.arg ?? 'top'}`;
element.append(tip);
binding.effect(() => { tip.textContent = binding.value(); });
if (binding.modifiers.hover) {
binding.on(element, 'pointerenter', () => tip.hidden = false);
binding.on(element, 'pointerleave', () => tip.hidden = true);
}
return () => tip.remove(); // runs when the element leaves the page
},
});
createApp({ state: () => ({ name: 'Ada' }) })
.directive(tooltip)
.mount('#app');
<button b-tooltip-left.hover="`Say hi to ${$data.name}`">Wave</button>

defineDirective only adds editor types; a plain object works the same way.

FieldMeaning
nameb- followed by lowercase kebab-case. Built-in names and prefixes (b-on-*, b-bind-*, b-prop-*, b-ref-*, b-router-*) are reserved.
argtrue to also match name-<anything>, available as binding.arg.
modifiersAllowed .modifier names. Omit to accept any, [] for none. options is reserved.
optionsAllowed keys for a .options attribute, read as binding.options. Omit to reject .options.
value'expression' (default), 'statement' for event-style code, 'text' for the raw attribute string, or 'none' when the attribute must be empty.
childrenfalse when the directive owns the element’s content, like b-text; Brio then skips the element’s children.
scope, mount, readyThe hooks below. At least one is required.
description, exampleOptional text for documentation and editor tooling.

Each element is processed in a fixed order, so you choose a phase rather than a priority:

PhaseRunsUse it to
scope(element, binding)Before any other directive on the element, after b-data and b-provide. Return an object to add $names for the element, its other directives, and its descendants.Give a subtree a controller, such as $form.
mount(element, binding)With the element’s other directives, in attribute order, before its children.Most behavior: listeners, effects, observers.
ready(element, binding)After the element’s children are hydrated, alongside b-model on <select>.Read or enhance content that Brio rendered inside.

mount and ready may return a cleanup function. Structural directives (b-if, b-for, b-template) finish first and hydrate each rendered copy again, so hooks run once per rendered element. Plugin directives cannot be used on component hosts or on <template> fragments; put them on an element inside.

When two directives react to the same event, the DOM’s own order decides: a capturing listener runs before b-model’s, a bubbling one after. That is how b-mask formats input before b-model reads it.

MemberPurpose
directive, name, arg, modifiers, expressionThe definition name, the full attribute name, the argument, the modifier flags, and the raw value.
optionsThe checked object from the element’s .options attribute, or {}. Merge it over modifiers so that values win: { ...binding.modifiers, ...binding.options }.
contextThe expression context at this element ($data, $ref, and the rest).
appThe app that is hydrating the element.
value()Evaluate the attribute. Inside binding.effect, it re-runs when what it reads changes.
evaluate(source, locals?)Evaluate another expression in the element’s context, with optional extra $names.
run(locals?)Run a 'statement' value, for example binding.run({ $event }).
assign(value)Write to the attribute’s expression, like b-model does.
effect(fn)A reactive effect stopped with the element.
on(target, type, listener, options?)addEventListener, removed with the element.
onCleanup(fn)Run fn when the element leaves the page (b-if, b-for, unmount).
hydrate(node)Hydrate content the directive inserted itself (with children: false) in the element’s context. It is cleaned up with the element.

Locals passed to evaluate and run must be $-prefixed, like $event.

import { defineDirective, state } from '@jnreis/briojs';
const disclosure = defineDirective({
name: 'b-disclosure',
value: 'none',
scope: () => ({ $disclosure: state({ open: false }) }),
});
<section b-disclosure>
<button b-on-click="$disclosure.open = !$disclosure.open">Details</button>
<p b-if="$disclosure.open">…</p>
</section>

Names from scope work like b-data: sibling directives on the element and everything inside can read them, and nested elements can shadow them.

A plugin object groups directives, helpers, and components under one name:

import { definePlugin } from '@jnreis/briojs';
export default definePlugin({
name: 'brio-plugin-ui',
directives: [tooltip, disclosure],
helpers: { $currency: value => new Intl.NumberFormat('en', { style: 'currency', currency: 'USD' }).format(value) },
components: { 'ui-badge': { template: '<span class="badge"><slot></slot></span>' } },
setup(app) {}, // once, at install
onMount(root, app) { return () => {}; }, // after each root hydrates; cleanup on unmount
});
createApp({ plugins: [ui] }).mount('#app');
// or: createApp().use(ui).mount('#app');

Shipping a browser script? Call brio.use(plugin) in it, and every app created afterwards on the page installs the plugin, just like Brio’s own add-on scripts. brio.use exists only on the browser global; with npm, pass the plugin to each app.

  • All or nothing. If any part of a plugin fails to register (a name conflict, a failing setup), nothing from it stays installed.
  • Conflicts are errors, never silent overrides. The message names both plugins.
  • Register before mounting. app.directive, app.helper, and plugins that add them must run before the first app.mount(), so every root hydrates with the same vocabulary.
  • Helpers need a $ prefix and cannot reuse built-in names such as $data. A misspelled $name still throws a ReferenceError.
  • Installed once. app.use() ignores a plugin object it already installed; a different plugin with the same name is an error.
  • Plugin functions still work. app.use(app => { … }) runs once with the app, and can call app.directive() and app.helper() itself.
  • Unknown directives warn in development. With brio.dev.min.js or enableDevtools(), an unrecognized b- attribute logs a warning that names it, once per name. Production bundles stay silent.

Brio uses this same API itself: inputsPlugin (b-mask, b-form) and observersPlugin (b-on-enter, b-on-resize, …) are plugin objects, and a router brings b-router-outlet as router.plugin, which createApp({ router }) installs for you. Their names are reserved, so another plugin cannot claim them.

@jnreis/briojs/testing mounts markup in a fresh app. It needs a DOM on globalThis, such as global-jsdom or happy-dom’s global registrator:

import 'global-jsdom/register';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { renderBrio } from '@jnreis/briojs/testing';
import ui from '../src/index.js';
test('b-tooltip shows its text', t => {
const view = renderBrio('<button b-tooltip="\'Hi\'">Wave</button>', { plugins: [ui] });
t.after(view.cleanup);
assert.equal(view.get('.tooltip').textContent, 'Hi');
});

renderBrio(html, options) accepts every createApp option and returns { root, app, handle, flush, get, type, click, cleanup }. type and click dispatch the events and flush, so assertions see the updated DOM.

Start from the plugin template: it includes a directive, a helper, tests, a demo page, and editor autocomplete. Name the package brio-plugin-<name> (or @scope/brio-plugin-<name>), add the brio-plugin keyword, list @jnreis/briojs as a peer dependency, and ship an html-custom-data.json for your directives so users can add it to VS Code’s html.customData.