Install
To get started, you can either scaffold the project with the Riba CLI.
To scaffold the project with the Riba CLI, run the following commands. This will create a new project directory, and populate the directory with the initial core Riba files and supporting modules, creating a conventional base structure for your project. Creating a new project with the Riba CLI is recommended for first-time users.
# npm
npm install -g @ribajs/cli
# yarn
yarn global add @ribajs/cli
Then scaffold your project:
riba new project-name
This will create the project-name directory, node modules and a few other boilerplate files will be installed, and a src/ directory will be created and populated with several core files.
File Structure
Usage
Templates
Templates describe your UI in plain HTML. You can define them directly in the document, use template elements, custom elements or store and load them however you like. Just make sure you have a convenient way to reference your templates when you want to bind some data to them.
<section id="auction">
<h3>{ auction.product.name }</h3>
<p>Current bid: { auction.currentBid | money }</p>
<rv-aside rv-if="auction.timeLeft | lt 120">
Hurry up! There is { auction.timeLeft | time } left.
</rv-aside>
</section>
The important parts to note here are the attributes prefixed with rv- and portions of text wrapped in { ... }. These are binding declarations and they are the sole way that riba ties data to your templates. The values of these declarations all follow the same minimal and expressive syntax.
(keypath | primitive) [formatters...]
Keypaths get observed and will recompute the binding when any intermediary key changes. A primitive can be a string, number, boolean, null or undefined.
Formatters can be piped to values using | and they follow a similarly minimal yet expressive syntax. Formatter arguments can be keypaths or primitives. Keypath arguments get observed and will recompute the binding when any intermediary key changes.
(formatter) [keypath | primitive...]
Binding
Simply call riba.bind on a template element with some data that you would like to bind.
import { Riba, coreModule } from '@ribajs/core';
const riba = new Riba();
const model = {};
// Register modules
riba.module.register(coreModule.init());
const view = riba.bind(document.getElementById("rv-app"), model);
Every call to riba.bind returns a fully data-bound view that you should hold on to for later. You'll need it in order to unbind it's listeners using view.unbind().
Configuring
Use riba.configure to set the following configuration options for your app. Note that all configuration options can be overridden locally to a particular view if needed.
riba.configure({
// Attribute prefix in templates
prefix: 'rv',
// Preload templates with initial data on bind
preloadData: true,
// Root sightglass interface for keypaths
rootInterface: '.',
// Template delimiters for text bindings
templateDelimiters: ['{', '}'],
// Removes binder attribute after the binder was bound
removeBinderAttributes: true,
// Stop binding on this node names
blockNodeNames: ['SCRIPT', 'STYLE', 'TEMPLATE', 'CODE'],
// Augment the event handler of the on-* binder
handler: function(target, event, binding) {
this.call(target, event, binding.view.models)
}
})
Binders
Binders are the sets of instructions that tell riba how to update the DOM when an observed property changes. riba.js comes bundled with a handful commonly-used binders for your conveneience. See the Binder Reference to learn more about the built-in binders that are available out of the box.
While you can accomplish most UI tasks with the built-in binders, it is highly encouraged to extend riba with your own binders that are specific to the needs of your application.
One-way Binders
One-way binders simply update the DOM when a model property changes (model-to-view only). Let's say we want a simple binder that updates an element's color when the model property changes. Here we can define a one-way color binder as a object with only a routine function. This function takes the element and the current value of the model property, which we will use to updates the element's color.
You can generate a new binder with the Riba CLI.
riba generate binder color
This will generate the binder (and a .spec.ts file) in your ./src/binders directory and updates your ./src/binders/index.ts file.
Than you can change the implementation like this:
import { Binder } from '@ribajs/core';
export class ColorBinder extends Binder<string, HTMLElement> {
static key = 'color';
routine(el: HTMLElement, value: string) {
el.style.color = value;
}
}
If you use Riba CLI to generate the binder, you usually do not need to register the binder yourself because the CLI updates the ./src/binders/index.ts for you.
Alternatively, you can register the binder by calling riba.module.binder.register with your new binder.
import { Riba } from '@ribajs/core';
import { ColorBinder } from './binders/color.binder';
const riba = new Riba();
riba.module.binder.register(ColorBinder);
With the above binder defined, you can now utilize the rv-color declaration in your views.
<button rv-color="label.color">Apply</button>
Two-way Binders
Two-way binders, like one-way binders, can update the DOM when a model property changes (model-to-view) but can also update the model when the user interacts with the DOM (view-to-model), such as updating a control input, clicking an element or interacting with a third-party widget.
In order to update the model when the user interacts with the DOM, you need to tell Riba.js how to bind and unbind to that DOM element to set the value on the model. Instead of defining the binder with a single routine function, two-way binders are defined as an object containing a few extra functions.
import { Binder } from '@ribajs/core';
export class ToggleBinder extends Binder<boolean, HTMLElement> {
static key = 'toggle';
private clickHandler?: () => void;
bind(el: HTMLElement) {
const adapter = this.config.adapters[this.key.interface];
this.clickHandler = () => {
const value = adapter.read(this.model, this.keypath);
adapter.publish(this.model, this.keypath, !value);
};
el.addEventListener('click', this.clickHandler);
}
unbind(el: HTMLElement) {
if (this.clickHandler) {
el.removeEventListener('click', this.clickHandler);
}
}
routine(el: HTMLElement, value: boolean) {
el.classList.toggle('enabled', value);
}
}
API
binder.bind
This function will get called for this binding on the initial view.bind(). Use it to store some initial state on the binding, or to set up any event listeners on the element.
binder.unbind
This function will get called for this binding on view.unbind(). Use it to reset any state on the element that would have been changed from the routine getting called, or to unbind any event listeners on the element that you've set up in the binder.bind function.
binder.routine
The routine function is called when an observed attribute on the model changes and is used to update the DOM. When defining a one-way binder as a single function, it is actually the routine function that you're defining.
binder.publishes
Set this to true if you want view.publish() to call publish on these bindings.
binder.block
Blocks the current node and child nodes from being parsed (used for iteration binding as well as the if/unless binders).
binder.priority
Sets the priority of the binder to affect the order in which the binders are executed, binders with higher priority are applied first.
binder.name
The name of the binder, the name specified attribute name (with the prefix rv- by default) which the binder can be used with.
Formatters
Formatters are functions that mutate the incoming and/or outgoing value of a binding. You can use them to format dates, numbers, currencies, etc. and because they work in a similar fashion to the Unix pipeline, the output of each feeds directly as input to the next one, so you can stack as many of them together as you like.
One-way formatters
This is by far the most common and practical way to use formatters — simple read-only mutations to a value. Taking the dates example from above, we can define a date formatter that returns a human-friendly version of a date value.
You can generate a new formatter with the Riba CLI.
riba generate formatter date
This will generate the formatter (and a .spec.ts file for tests) in your ./src/formatters directory and updates your ./src/formatters/index.ts file.
Than you can change the implementation like this:
import { Formatter } from '@ribajs/core';
export const dateFormatter: Formatter = {
name: 'date',
read(value: Date) {
return moment(value).format('MMM DD, YYYY')
},
};
If you use Riba CLI to generate the formatter, you usually do not need to register the formatter yourself because the CLI updates the ./src/formatters/index.ts for you.
Alternatively, you can register the formatter by calling riba.module.formatter.register with your new formatter.
import { Riba } from '@ribajs/core';
import { dateFormatter } from './formatters/date.formatter';
const riba = new Riba();
riba.module.formatter.register(dateFormatter);
You can also register multiple formatters at once by calling riba.module.formatter.registerAll, this is useful when importing multiple formatters like import * as formatters from './formatters';
After you have registered your formatter, formatters are applied by piping them to binding declarations using | as a delimiter.
<span rv-text="event.startDate | date"></span>
Two-way formatters
Two-way formatters are useful when you want to store a value in a particular format, such as a unix epoch time or a cent value, but still let the user input the value in a different format.
Instead of defining the formatter with the single read function, you define it as an object containing read and publish functions. When a formatter is defined only with a read function, riba assumes it to be in the read direction only. When the read and publish functions are defined, riba uses it's read and publish functions to effectively serialize and de-serialize the value.
Using the cent value example from above, let's say we want to store a monetary value as cents but let the user input it in a dollar amount and automatically round to two decimal places when setting the value on the model. For this we can define a two-way currency formatter.
import { Formatter } from '@ribajs/core';
export const CurrencyFormatter: Formatter = {
name: 'currency',
read(value: number) {
return (value / 100).toFixed(2)
},
publish(value: string) {
return Math.round(parseFloat(value) * 100)
}
};
You can then bind using this formatter with any one-way or two-way binder.
<input rv-value="item.price | currency">
Note that you can also chain bidirectional formatters with any other formatters, and in any order. They read from left to right, and publish from right to left, skipping any read-only formatters when publishing the value back to the model.
Formatter arguments
Formatters can accept any number of arguments in the form of keypaths or primitives. Keypath arguments get observed and will recompute the binding when any intermediary key changes. A primitive can be a string, number, boolean, null or undefined.
<span>{ alarm.time | time user.timezone 'hh:mm' }</span>
The value of each argument in the binding declaration will be evaluated and passed into the formatter function as an additional argument.
import { Formatter } from '@ribajs/core';
export const TimeFormatter: Formatter = {
name: 'time',
read(value: Date, timezone: string, format: string) {
return moment(value).tz(timezone).format(format)
}
};
Components
Components let you define reusable views that can be used within any of your templates. For some perspective on where components fit into your templates in relation to binders; binders define custom attributes, while components define custom elements.
Based on Custom Elements
Unlike Rivets.js components, components in Riba.js follow the Custom Elements specification.
Browser support
Riba components require native customElements support and are intended for modern evergreen browsers.
Legacy-browser fallback implementations are not part of the maintained first-party runtime anymore.
Create a New Component
You can generate a new formatter with the Riba CLI.
riba generate component todo-item
This will create a new directory with a new component (and a .spec.ts file for tests) in your ./src/ts/component directory and updates your ./src/ts/components/index.ts file.
A component class typically defines observedAttributes, a scope, and a template function, which returns the template for the component (this can be an HTML string or the actual element). The component is initialized in lifecycle callbacks (for example via connectedCallback and init(...)).
import { Component } from '@ribajs/core';
import { hasChildNodesTrim } from '@ribajs/utils/src/dom';
interface Scope {
description?: string;
}
export class TodoItemComponent extends Component {
public static tagName = 'rv-todo-item';
protected autobind = true;
static get observedAttributes() {
return ['description'];
}
public scope: Scope = {
description: undefined,
};
protected connectedCallback() {
super.connectedCallback();
this.init(TodoItemComponent.observedAttributes);
}
protected requiredAttributes() {
return [];
}
protected async template() {
if (this.el && hasChildNodesTrim(this.el)) {
return null;
} else {
const { default: template } = await import('./todo-item.component.html?raw');
return template;
}
}
}
To use the component inside of a template, simply use an element with the same tag name as the component's tagName. Unlike on binders, the attributes on the element will not evaluated as keypaths, the reason for this is that custom elements are internally registered with the native browser customElements.define('rv-todo-item', TodoItemComponent); method and no external values can be passed over it.
<rv-todo-item description="Buy cat food"></rv-todo-item>
Lifecycle callbacks
This most method here are part of the official custom elements specification. Read more about this methods here.
connectedCallback
Invoked when the custom element is first connected to the document's DOM.
disconnectedCallback
Invoked each time the custom element is disconnected from the document's DOM.
adoptedCallback
Invoked when one of the custom element's attributes is added, removed, or changed.
attributeChangedCallback
Invoked each time one of the custom element's attributes is added, removed, or changed. Which attributes to notice change for is specified in a static get observedAttributes method.
If you override this method you should always call the super method:
protected attributeChangedCallback(attributeName: string, oldValue: any, newValue: any, namespace: string | null) {
super.attributeChangedCallback(attributeName, oldValue, newValue, namespace);
// Do here what ever you want
}
The super method will automatically assign the attribute value to your model / scope, e.g. <my-custom-element option-width="500"></my-custom-element> will automatically assign the attribute value to your model / scope this.scope.optionWidth = 500;, the attribute name is converted into camelCase.
parsedAttributeChangedCallback
Invoked after attributeChangedCallback was invoked, this callback method has the same parameters like attributeChangedCallback with the only difference that the attribute name was converted to camelCase. E.g. option-width would be converted to optionWidth.
Adapters
Riba can observe different object models through adapters. In practice, most
projects use the built-in . adapter for plain JavaScript objects.
Custom adapters are an advanced extension point. Use them when you need to bind to external model APIs (for example event-driven stores) that do not expose plain object properties directly.
Each adapter is registered under a unique interface (a single character). The interfaces used in a keypath determine which adapter is used for each intermediate key.
user.address:city
The above keypath uses the . adapter to access address on user, and the
: adapter to access city on address. This is useful when traversing mixed
model types in one keypath.
Riba ships with a default . adapter based on custom property getters and
setters.
The built-in adapter
Riba ships with a . adapter for subscribing to properties on plain JavaScript objects. The adapter is implemented with Object.defineProperty-based observation in the core runtime.
Object.observe is obsolete and not used by Riba.
Riba targets modern evergreen browsers. If you have additional compatibility requirements, provide app-level polyfills or a custom adapter strategy in your project.
Creating an adapter
Adapters are defined on riba.adapters with the interface as the property name and the adapter object as the value. An adapter is just an object that responds to observe, unobserve, get and set.
For most projects, the built-in . adapter is enough. Create a custom adapter
only if your model API requires custom observe/get/set behavior.
The following : adapter is an example for event-driven model APIs (such as
Backbone.js models / Stapes.js modules).
riba.adapters[':'] = {
observe: function(obj, keypath, callback) {
obj.on('change:' + keypath, callback)
},
unobserve: function(obj, keypath, callback) {
obj.off('change:' + keypath, callback)
},
get: function(obj, keypath) {
return obj.get(keypath)
},
set: function(obj, keypath, value) {
obj.set(keypath, value)
}
}
Iteration binding
Iterate
Use the rv-each-[item] binder to have Riba.js automatically loop over items in an array and append bound instances of that element. Within that element you can bind to the iterated item as well as any contexts that are available in the parent view.
<ul>
<li rv-each-todo="list.todos">
<input type="checkbox" rv-checked="todo.done">
<span>{ todo.summary }</span>
</li>
<ul>
Iteration index
To access the index of the current iteration use the syntax %item%, Where item is the name of the model you provided in rv-each-[item]. You can also access the index of the iteration by using the key index but using this will access only the current iterations index. Note that when nesting rv-each's the parent index is still accessible within the scope via the model name.
<ul>
<li rv-each-user="app.users">
<span>User Index : { %user% }</span>
<ul>
<li rv-each-comment="user.comments">
<span>Comment Index : { %comment% }</span>
<span>User Index : { %user% }</span>
</li>
</ul>
</li>
<ul>