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.
Write a directive
Section titled “Write a directive”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.
| Field | Meaning |
|---|---|
name | b- followed by lowercase kebab-case. Built-in names and prefixes (b-on-*, b-bind-*, b-prop-*, b-ref-*, b-router-*) are reserved. |
arg | true to also match name-<anything>, available as binding.arg. |
modifiers | Allowed .modifier names. Omit to accept any, [] for none. options is reserved. |
options | Allowed 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. |
children | false when the directive owns the element’s content, like b-text; Brio then skips the element’s children. |
scope, mount, ready | The hooks below. At least one is required. |
description, example | Optional text for documentation and editor tooling. |
When each hook runs
Section titled “When each hook runs”Each element is processed in a fixed order, so you choose a phase rather than a priority:
| Phase | Runs | Use 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.
The binding
Section titled “The binding”| Member | Purpose |
|---|---|
directive, name, arg, modifiers, expression | The definition name, the full attribute name, the argument, the modifier flags, and the raw value. |
options | The checked object from the element’s .options attribute, or {}. Merge it over modifiers so that values win: { ...binding.modifiers, ...binding.options }. |
context | The expression context at this element ($data, $ref, and the rest). |
app | The 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.
Give a subtree a name with scope
Section titled “Give a subtree a name with scope”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.
Package a plugin
Section titled “Package a plugin”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 firstapp.mount(), so every root hydrates with the same vocabulary. - Helpers need a
$prefix and cannot reuse built-in names such as$data. A misspelled$namestill throws aReferenceError. - 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 callapp.directive()andapp.helper()itself. - Unknown directives warn in development. With
brio.dev.min.jsorenableDevtools(), an unrecognizedb-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.
Test a plugin
Section titled “Test a plugin”@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.
Publish a plugin
Section titled “Publish a plugin”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.