Get started
MotifJS Documentation
What MotifJS does, an index of the sections, and a quick look through a slice of a notes list.
MotifJS is a UI framework for JavaScript and TypeScript, built for modern web applications, that brings speed, reactivity and a clear architecture together under one roof. Views are written in JSX, and the compiler turns them into component calls at build time. The same calls can also be written directly, without JSX, in TypeScript or JavaScript. Every tag in JSX becomes a component object that carries its own DOM element, and these objects live in the controls tree. A value bound to a getter updates only that property of the element it is bound to; the component is not rebuilt.
What does MotifJS do differently?
In MotifJS the interface is a tree of component objects that live for as long as the application stays open. The view is built once, and from then on these objects carry it: bindings update values, the tree changes by hand or through bindings, and lifetimes are managed explicitly.
- Every tag is a component object. A
<div>, a<button>, even a piece of plain text turns into amotifComponentcall in the compiler. The resulting object has its ownelement, acontrolscollection that holds its children, and thebindings,classandattrmembers, plusmotif, which handles events and visibility. The built element is not thrown away as a copy; it stays in hand for the life of the application, and you can find and change it in the tree throughparent,siblingsand$(). view()runs once. It is called while the component is being built, and bindings carry every change after that. On a component that is already built,controls.add(<div/>)builds the child right away and puts its element in place; on a component that is not built yet, the child is built together with its owner.- The tree is edited at run time.
controls.add,insert,move,moveToIndexandremovechange the component tree directly. A component passed to another component'scontrols.addleaves its old place and moves to the new one, taking its state, bindings and events with it.removedisposes the component.detach, on the other hand, plays the leave transition if there is one and only takes the component out of the tree and the DOM; a detached component can be added again later. - Access to the real element. Every component's
elementis created in the constructor, so you can reach it before the component is built and before it is added to the page. In a component without an element of its own (a fragment),elementis a comment node. - Fine-grained reactivity. When a state field changes, only the DOM node or attribute bound to that field is updated, not the whole component.
- Waiting and hidden components keep their place.
x-waitdoes not build the component at all until its condition holds.x-displayis the reverse of the same waiting: if it starts outfalsethe component is not built, and if it turnsfalseafter the component is built, the component is hidden. In both cases the component leaves a comment marker in the DOM unlesshideStrategy: "detach"is given, and it comes back to the same spot when shown. - Lifetimes are managed explicitly. A disposed component shuts down its children, bindings and events, and resources tied to it with
motif.registerormotif.setDisposableare closed along with it. An application subscription opened through the component'scontext.onis removed on its own when the component is disposed. - Service lifetimes follow route changes. When the router is in use, every route change opens its own service scope, and the components built during that change are tied to it. A service registered as
scopedis created once per scope. When the new change completes, the previous change's scope closes together with its services, unless a kept page is still holding it. - A page is not rebuilt every time you return to it. The component of a route given
keepAlive: trueis kept after its first build, and the same instance is shown when you come back. With stack navigation turned on (app.useRouter({ …, stack: true })), pages left behind are kept for up to five steps by default; going back brings the same instances with their scroll positions. A slide transition (animation: "slide") and swiping back from the edge (swipeBack) are options. - Built-in infrastructure. Routing, dependency injection, lifecycle, animation and memory management come with the core.
Frame,Lazy,Transport/TransportTo,ContentBody/ContentBlock,Virtualization,Resilience,Queryand theList,DictionaryandLinkedListcollections are part of the core as well. - JavaScript or TypeScript, with or without JSX. Components are classes, functions or Options API objects, and the view is built with JSX or with the hand-written (imperative) form of the same calls. All three styles produce the same kind of component, and they sit side by side in the same tree.
MotifJS is designed for long-lived, state-heavy interfaces. No planning has been done yet for server-side rendering (SSR); it may be considered in the future.
Contents
| Category | Section | Topic |
|---|---|---|
| Get started | Getting Started | Installation, build setup, the first component and application |
| Core concepts | Components | Class / function / Options API components, the root element, view() and controls, names of class members |
| Core concepts | Creating Components | Defining a component, giving it its element and content, using it; creation and building |
| Core concepts | Props and Children | props, data flow between components, childs |
| Core concepts | controls and the Component Tree | The controls collection, moving around the component tree, moving content somewhere else |
| Core concepts | Element and Appearance | element, ref, onRefCreated; class, attr, style, setText |
| Core concepts | The motif Namespace | show / hide, on / off / trigger, setDisposable, clear, motif.options |
| Core concepts | bindings | What live expressions in JSX turn into, the bindings members, watch, the lifetime of a binding |
| Core concepts | State, Context and Services | State flags, setState / reState, context, reaching services, build / dispose |
| Core concepts | Async Results with using | Dropping a result that reaches a disposed component, handlers, MJX129, when the check happens |
| Core concepts | Async Results with doWork | A promise tied to the component's lifetime, MJX108, catch and finally, how it differs from using |
| Core concepts | JSX and Templates | JSX rules, class/style/attributes, spread, x- directives, fragments, compile diagnostics, explain |
| Core concepts | Views without JSX | The motifComponent and new Component call forms, props keys, text nodes, preconfig, fragments for branches and lists, class components in files without JSX, reading the compiled output |
| Core concepts | Reactivity | reactive, createSignal, createComputed, createLazyComputed, effect, untracked |
| Core concepts | Conditional Rendering and Lists | x-wait/x-display, &&, the ternary operator, .map, key and item identity, switch, the binding API |
| Core concepts | Events | DOM events, modifiers, component, lifecycle and application events, service events (Emitter) |
| Core concepts | Forms and Two-Way Binding | One-way value + oninput, two-way binding with bindings.model and x-model, checked, value, <select>, form submission, a validation pattern |
| Core concepts | Lifecycle | Lifecycle hooks and their order, initializeComponent, visibility, waiting, disposal, the application lifecycle |
| Application structure | Styling and Animation | The class/style/attr helpers, inline <style>, Tailwind, Bootstrap and external CSS, the transition system: CSS class and WAAPI keyframe transitions, the enter/leave mode (mode) |
| Application structure | Memory Management and Disposal | IDisposable, automatic cleanup, motif.register/setDisposable, the dispose methods, detach/silentDetach/silentUnlink, DisposableStore, using/doWork, leak tracking, counting live components |
| Application structure | Error Handling | The throw/report/warn paths, MotifError, errorHandler, setUnexpectedErrorHandler, safeCall*, where an error in user code is caught |
| Application structure | Resilience | The Resilience.create chain and its order, retry, timeout, circuitBreaker, bulkhead, rateLimiter, fallback, decorate, error codes |
| Application structure | Windows | app.windows: opening a component in a new window or in an always-on-top Picture-in-Picture window, taking it back, mirroring styles and theme, Electron |
| Routing | Introduction to Routing | useRouter, navigation modes, the not-found route and the error page |
| Routing | The Route Table | RouteItem fields, nested routes, redirect/alias, path parameters, resolve/href/routes, lazy routes |
| Routing | RouterView | Where a route's output goes, nested and named RouterViews |
| Routing | Links | rel="router", RouterLink, enableRouterClassing |
| Routing | Navigation and Route State | navigate/navigateByName, live route state, direction/state, scrollMemory |
| Routing | Route Hooks and Guards | Route hooks, useGuard, onRouterChanged, app.use, restarting and shutting down |
| Routing | Stack Navigation | stack: kept pages, the transition animation, swiping back |
| Dependency Injection | Introduction to Dependency Injection | Registering a service, using it from a component, a full example |
| Dependency Injection | Registering Services | ServiceCollection registration forms, startup singletons, @Injectable and its options |
| Dependency Injection | Lifetimes and Scopes | singleton / scoped / transient, the navigation scope, the scoped handle |
| Dependency Injection | Resolving Services | provider.get / getAsync, getService, FromService, inject, deps, useApplication |
| Dependency Injection | Disposing Services | app.dispose / provider.dispose, closing a scope, tracking transients |
| Guides | Writing Packages on Motif | The compiler output as an open API; the mechanisms that theme, layer, layout, plugin, data, wrapper, settings and transition packages rely on |
| Guides | Wrapping Third-Party Libraries | Turning libraries that manage their own DOM into components |
| Guides | Publishing an npm Library for MotifJS | Preparing a component/service package: build, types, CSS, the prop contract, pre-publish checks |
| Guides | Writing a Component Library | Rules for a kit that applications use: props tracked by a single binding, data used as it is, untracked internal state, drawing by hand, writing only on change, lifetime traps, measuring |
| Guides | The DevTools Contract | The __MOTIF_DEVTOOLS__ object turned on by useDevelopment(true): the tree, events, reactivity trace, counters, router and error records; registerDevtools for packages |
| Reference | API Reference | A summary of every exported symbol |
| Reference | Error and Warning Codes | Every MJX code, its kind, its console message and what it means |
| Reference | Collection Queries (Query) | Chainable filtering, sorting and grouping with Query.from(...) |
| Reference | Collections | List, Dictionary, NameValuePair, LinkedList |
| Advanced | Frame | A container that holds a single changing piece of content; timing and transitions |
| Advanced | Lazy | Content from a dynamic import() or any Promise; the loading flow, options, retries, cancellation, error reports |
| Advanced | Transport and TransportTo | Showing content in another slot; the replace/merge modes, Transporter |
| Advanced | ContentBody and ContentBlock | A body that several blocks fill together |
| Advanced | Virtual Lists (Virtualization) | Virtual scrolling for large data |
Quick look
A notes list: NotesService keeps the notes in one place, and NotesPane shows the search box, the rows and an editor for the selected note. SettingsService holds the theme and density settings and is created when the application starts. The same component is written in six forms: class, function and Options API, each with JSX and without it.
import { reactive } from "@motifx/core";
export interface Note {
id: number;
title: string;
body: string;
pinned: boolean;
archived: boolean;
createdAt: number;
updatedAt: number;
}
export type Folder = "notes" | "pinned" | "archive";
export const FOLDERS: Record<Folder, { title: string; empty: string }> = {
notes: { title: "All notes", empty: "No notes yet. Write the first one." },
pinned: { title: "Pinned", empty: "No pinned notes. Pin a note you want to keep at the top." },
archive: { title: "Archive", empty: "The archive is empty." },
};
const inFolder: Record<Folder, (note: Note) => boolean> = {
notes: (note) => !note.archived,
pinned: (note) => note.pinned && !note.archived,
archive: (note) => note.archived,
};
export class NotesService {
readonly state = reactive({ items: [] as Note[] });
private nextId = this.state.items.length + 1;
list(folder: Folder, query = ""): Note[] {
const q = query.trim().toLowerCase();
return this.state.items
.filter(inFolder[folder])
.filter((note) => !q || `${note.title}\n${note.body}`.toLowerCase().includes(q))
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt);
}
count(folder: Folder): number {
return this.state.items.filter(inFolder[folder]).length;
}
find(id: number | string): Note | undefined {
const wanted = Number(id);
return this.state.items.find((note) => note.id === wanted);
}
create(): Note {
const now = Date.now();
this.state.items.unshift({ id: this.nextId++, title: "", body: "", pinned: false, archived: false, createdAt: now, updatedAt: now });
return this.state.items[0];
}
touch(note: Note): void {
note.updatedAt = Date.now();
}
togglePin(note: Note): void {
note.pinned = !note.pinned;
this.touch(note);
}
archive(note: Note): void {
note.archived = true;
note.pinned = false;
this.touch(note);
}
restore(note: Note): void {
note.archived = false;
this.touch(note);
}
remove(id: number): boolean {
const index = this.state.items.findIndex((note) => note.id === id);
if (index < 0) return false;
this.state.items.splice(index, 1);
return true;
}
emptyArchive(): number {
let removed = 0;
for (let i = this.state.items.length - 1; i >= 0; i--) {
if (!this.state.items[i].archived) continue;
this.state.items.splice(i, 1);
removed++;
}
return removed;
}
}
import { reactive } from "@motifx/core";
export const FOLDERS = {
notes: { title: "All notes", empty: "No notes yet. Write the first one." },
pinned: { title: "Pinned", empty: "No pinned notes. Pin a note you want to keep at the top." },
archive: { title: "Archive", empty: "The archive is empty." },
};
const inFolder = {
notes: (note) => !note.archived,
pinned: (note) => note.pinned && !note.archived,
archive: (note) => note.archived,
};
export class NotesService {
state = reactive({ items: [] });
nextId = this.state.items.length + 1;
list(folder, query = "") {
const q = query.trim().toLowerCase();
return this.state.items
.filter(inFolder[folder])
.filter((note) => !q || `${note.title}\n${note.body}`.toLowerCase().includes(q))
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt);
}
count(folder) {
return this.state.items.filter(inFolder[folder]).length;
}
find(id) {
const wanted = Number(id);
return this.state.items.find((note) => note.id === wanted);
}
create() {
const now = Date.now();
this.state.items.unshift({ id: this.nextId++, title: "", body: "", pinned: false, archived: false, createdAt: now, updatedAt: now });
return this.state.items[0];
}
touch(note) {
note.updatedAt = Date.now();
}
togglePin(note) {
note.pinned = !note.pinned;
this.touch(note);
}
archive(note) {
note.archived = true;
note.pinned = false;
this.touch(note);
}
restore(note) {
note.archived = false;
this.touch(note);
}
remove(id) {
const index = this.state.items.findIndex((note) => note.id === id);
if (index < 0) return false;
this.state.items.splice(index, 1);
return true;
}
emptyArchive() {
let removed = 0;
for (let i = this.state.items.length - 1; i >= 0; i--) {
if (!this.state.items[i].archived) continue;
this.state.items.splice(i, 1);
removed++;
}
return removed;
}
}
import { reactive } from "@motifx/core";
export type Theme = "system" | "light" | "dark";
export type Density = "comfortable" | "compact";
export const THEMES: readonly Theme[] = ["system", "light", "dark"];
export const DENSITIES: readonly Density[] = ["comfortable", "compact"];
const STORAGE_KEY = "settings";
export class SettingsService {
readonly state = reactive({ theme: "system" as Theme, density: "comfortable" as Density });
constructor() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
if (THEMES.includes(saved.theme)) this.state.theme = saved.theme;
if (DENSITIES.includes(saved.density)) this.state.density = saved.density;
} catch {}
this.apply();
}
setTheme(theme: Theme) {
this.state.theme = theme;
this.save();
}
setDensity(density: Density) {
this.state.density = density;
this.save();
}
private save() {
this.apply();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: this.state.theme, density: this.state.density }));
} catch {}
}
private apply() {
const root = document.documentElement;
if (this.state.theme === "system") delete root.dataset.theme;
else root.dataset.theme = this.state.theme;
if (this.state.density === "comfortable") delete root.dataset.density;
else root.dataset.density = this.state.density;
}
}
import { reactive } from "@motifx/core";
export const THEMES = ["system", "light", "dark"];
export const DENSITIES = ["comfortable", "compact"];
const STORAGE_KEY = "settings";
export class SettingsService {
state = reactive({ theme: "system", density: "comfortable" });
constructor() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
if (THEMES.includes(saved.theme)) this.state.theme = saved.theme;
if (DENSITIES.includes(saved.density)) this.state.density = saved.density;
} catch {}
this.apply();
}
setTheme(theme) {
this.state.theme = theme;
this.save();
}
setDensity(density) {
this.state.density = density;
this.save();
}
save() {
this.apply();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: this.state.theme, density: this.state.density }));
} catch {}
}
apply() {
const root = document.documentElement;
if (this.state.theme === "system") delete root.dataset.theme;
else root.dataset.theme = this.state.theme;
if (this.state.density === "comfortable") delete root.dataset.density;
else root.dataset.density = this.state.density;
}
}
import { Component, reactive } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";
export class NotesPane extends Component {
notes = this.getService(NotesService)!;
state = reactive({ query: "", current: null as Note | null });
get visible() {
return this.notes.list("notes", this.state.query);
}
create() {
this.state.current = this.notes.create();
}
override view() {
return (
<div class="notes">
<header class="list__head">
<h2>Notes</h2>
<button type="button" onclick={() => this.create()}>New note</button>
</header>
<input type="search" placeholder="Search notes" x-model={() => this.state.query} />
<div class="rows">
{this.visible.map((note) => (
<button type="button" key={note.id} class={() => (note === this.state.current ? "row active" : "row")} onclick={() => (this.state.current = note)}>
<i class="pin" x-display={() => note.pinned} />
<span>{() => note.title.trim() || "Untitled"}</span>
</button>
))}
<p class="list__empty" x-display={() => this.visible.length === 0}>
{() => (this.state.query.trim() ? "No search results." : "No notes yet.")}
</p>
</div>
<article class="detail" x-display={() => this.state.current !== null}>
<input placeholder="Title" x-model={() => this.state.current!.title} />
<textarea placeholder="Note" x-model={() => this.state.current!.body} />
<button type="button" onclick={() => this.notes.togglePin(this.state.current!)}>
{() => (this.state.current?.pinned ? "Unpin" : "Pin")}
</button>
</article>
</div>
);
}
}
import { FromService, reactive } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";
export function NotesPane() {
const notes = FromService(NotesService)!;
const state = reactive({ query: "", current: null as Note | null });
const visible = () => notes.list("notes", state.query);
const create = () => (state.current = notes.create());
return (
<div class="notes">
<header class="list__head">
<h2>Notes</h2>
<button type="button" onclick={create}>New note</button>
</header>
<input type="search" placeholder="Search notes" x-model={() => state.query} />
<div class="rows">
{visible().map((note) => (
<button type="button" key={note.id} class={() => (note === state.current ? "row active" : "row")} onclick={() => (state.current = note)}>
<i class="pin" x-display={() => note.pinned} />
<span>{() => note.title.trim() || "Untitled"}</span>
</button>
))}
<p class="list__empty" x-display={() => visible().length === 0}>
{() => (state.query.trim() ? "No search results." : "No notes yet.")}
</p>
</div>
<article class="detail" x-display={() => state.current !== null}>
<input placeholder="Title" x-model={() => state.current!.title} />
<textarea placeholder="Note" x-model={() => state.current!.body} />
<button type="button" onclick={() => notes.togglePin(state.current!)}>
{() => (state.current?.pinned ? "Unpin" : "Pin")}
</button>
</article>
</div>
);
}
import { FromService, motifOptions, reactive } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";
export const NotesPane = () => {
const notes = FromService(NotesService)!;
return motifOptions({
el: "div",
data: reactive({ query: "", current: null as Note | null }),
visible() {
return notes.list("notes", this.data.query);
},
create() {
this.data.current = notes.create();
},
view() {
this.class.add("notes");
return (
<>
<header class="list__head">
<h2>Notes</h2>
<button type="button" onclick={() => this.create()}>New note</button>
</header>
<input type="search" placeholder="Search notes" x-model={() => this.data.query} />
<div class="rows">
{this.visible().map((note) => (
<button type="button" key={note.id} class={() => (note === this.data.current ? "row active" : "row")} onclick={() => (this.data.current = note)}>
<i class="pin" x-display={() => note.pinned} />
<span>{() => note.title.trim() || "Untitled"}</span>
</button>
))}
<p class="list__empty" x-display={() => this.visible().length === 0}>
{() => (this.data.query.trim() ? "No search results." : "No notes yet.")}
</p>
</div>
<article class="detail" x-display={() => this.data.current !== null}>
<input placeholder="Title" x-model={() => this.data.current!.title} />
<textarea placeholder="Note" x-model={() => this.data.current!.body} />
<button type="button" onclick={() => notes.togglePin(this.data.current!)}>
{() => (this.data.current?.pinned ? "Unpin" : "Pin")}
</button>
</article>
</>
);
},
});
};
import { Component, ComponentBase, motifComponent, reactive } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";
export class NotesPane extends Component {
notes = this.getService(NotesService)!;
state = reactive({ query: "", current: null as Note | null });
get visible() {
return this.notes.list("notes", this.state.query);
}
create() {
this.state.current = this.notes.create();
}
override view() {
return motifComponent("div", (root: ComponentBase) => {
root.class.add("notes");
root.controls.add(this.head(), this.search(), this.rows(), this.detail());
});
}
private head() {
return motifComponent("header", (head: ComponentBase) => {
head.class.add("list__head");
const title = motifComponent("h2", (h2: ComponentBase) => {
h2.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Notes")));
});
const newNote = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => this.create());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("New note")));
});
head.controls.add(title, newNote);
});
}
private search() {
return motifComponent("input", (field: ComponentBase) => {
field.attr.add({ type: "search", placeholder: "Search notes" });
field.bindings.model(() => this.state.query, (value: string) => (this.state.query = value));
});
}
private rows() {
return motifComponent("div", (rows: ComponentBase) => {
rows.class.add("rows");
const list = motifComponent({
initializeComponent: (holder: ComponentBase) => {
holder.bindings.list(() => this.visible, (note: Note) => this.row(note));
},
});
const empty = motifComponent("p", {
preconfig: (p: ComponentBase) => p.bindings.display(() => this.visible.length === 0),
initializeComponent: (p: ComponentBase) => {
p.class.add("list__empty");
p.bindings.text(() => (this.state.query.trim() ? "No search results." : "No notes yet."));
},
});
rows.controls.add(list, empty);
});
}
private row(note: Note) {
return motifComponent("button", {
indexkey: () => note.id,
initializeComponent: (row: ComponentBase) => {
row.attr.add({ type: "button" });
row.class.add(() => (note === this.state.current ? "row active" : "row"));
row.motif.on("click", () => (this.state.current = note));
const pin = motifComponent("i", {
preconfig: (i: ComponentBase) => i.bindings.display(() => note.pinned),
initializeComponent: (i: ComponentBase) => i.class.add("pin"),
});
const title = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => note.title.trim() || "Untitled"));
row.controls.add(pin, title);
},
});
}
private detail() {
return motifComponent("article", {
preconfig: (article: ComponentBase) => article.bindings.display(() => this.state.current !== null),
initializeComponent: (article: ComponentBase) => {
article.class.add("detail");
const title = motifComponent("input", (field: ComponentBase) => {
field.attr.add({ placeholder: "Title" });
field.bindings.model(() => this.state.current!.title, (value: string) => (this.state.current!.title = value));
});
const body = motifComponent("textarea", (field: ComponentBase) => {
field.attr.add({ placeholder: "Note" });
field.bindings.model(() => this.state.current!.body, (value: string) => (this.state.current!.body = value));
});
const pin = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => this.notes.togglePin(this.state.current!));
button.bindings.text(() => (this.state.current?.pinned ? "Unpin" : "Pin"));
});
article.controls.add(title, body, pin);
},
});
}
}
import { ComponentBase, FromService, motifComponent, reactive } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";
export function NotesPane() {
const notes = FromService(NotesService)!;
const state = reactive({ query: "", current: null as Note | null });
const visible = () => notes.list("notes", state.query);
const create = () => (state.current = notes.create());
const head = motifComponent("header", (bar: ComponentBase) => {
bar.class.add("list__head");
const title = motifComponent("h2", (h2: ComponentBase) => {
h2.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Notes")));
});
const newNote = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", create);
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("New note")));
});
bar.controls.add(title, newNote);
});
const search = motifComponent("input", (field: ComponentBase) => {
field.attr.add({ type: "search", placeholder: "Search notes" });
field.bindings.model(() => state.query, (value: string) => (state.query = value));
});
const row = (note: Note) =>
motifComponent("button", {
indexkey: () => note.id,
initializeComponent: (item: ComponentBase) => {
item.attr.add({ type: "button" });
item.class.add(() => (note === state.current ? "row active" : "row"));
item.motif.on("click", () => (state.current = note));
const pin = motifComponent("i", {
preconfig: (i: ComponentBase) => i.bindings.display(() => note.pinned),
initializeComponent: (i: ComponentBase) => i.class.add("pin"),
});
const title = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => note.title.trim() || "Untitled"));
item.controls.add(pin, title);
},
});
const rows = motifComponent("div", (box: ComponentBase) => {
box.class.add("rows");
const list = motifComponent({
initializeComponent: (holder: ComponentBase) => holder.bindings.list(visible, row),
});
const empty = motifComponent("p", {
preconfig: (p: ComponentBase) => p.bindings.display(() => visible().length === 0),
initializeComponent: (p: ComponentBase) => {
p.class.add("list__empty");
p.bindings.text(() => (state.query.trim() ? "No search results." : "No notes yet."));
},
});
box.controls.add(list, empty);
});
const detail = motifComponent("article", {
preconfig: (article: ComponentBase) => article.bindings.display(() => state.current !== null),
initializeComponent: (article: ComponentBase) => {
article.class.add("detail");
const title = motifComponent("input", (field: ComponentBase) => {
field.attr.add({ placeholder: "Title" });
field.bindings.model(() => state.current!.title, (value: string) => (state.current!.title = value));
});
const body = motifComponent("textarea", (field: ComponentBase) => {
field.attr.add({ placeholder: "Note" });
field.bindings.model(() => state.current!.body, (value: string) => (state.current!.body = value));
});
const pin = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => notes.togglePin(state.current!));
button.bindings.text(() => (state.current?.pinned ? "Unpin" : "Pin"));
});
article.controls.add(title, body, pin);
},
});
return motifComponent("div", (root: ComponentBase) => {
root.class.add("notes");
root.controls.add(head, search, rows, detail);
});
}
import { ComponentBase, FromService, motifComponent, motifOptions, reactive } from "@motifx/core";
import { NotesService, type Note } from "../services/notes";
export const NotesPane = () => {
const notes = FromService(NotesService)!;
return motifOptions({
el: "div",
data: reactive({ query: "", current: null as Note | null }),
visible() {
return notes.list("notes", this.data.query);
},
create() {
this.data.current = notes.create();
},
view() {
this.class.add("notes");
return [this.head(), this.search(), this.rows(), this.detail()];
},
head() {
return motifComponent("header", (head: ComponentBase) => {
head.class.add("list__head");
const title = motifComponent("h2", (h2: ComponentBase) => {
h2.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Notes")));
});
const newNote = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => this.create());
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("New note")));
});
head.controls.add(title, newNote);
});
},
search() {
return motifComponent("input", (field: ComponentBase) => {
field.attr.add({ type: "search", placeholder: "Search notes" });
field.bindings.model(() => this.data.query, (value: string) => (this.data.query = value));
});
},
rows() {
return motifComponent("div", (rows: ComponentBase) => {
rows.class.add("rows");
const list = motifComponent({
initializeComponent: (holder: ComponentBase) => {
holder.bindings.list(() => this.visible(), (note: Note) => this.row(note));
},
});
const empty = motifComponent("p", {
preconfig: (p: ComponentBase) => p.bindings.display(() => this.visible().length === 0),
initializeComponent: (p: ComponentBase) => {
p.class.add("list__empty");
p.bindings.text(() => (this.data.query.trim() ? "No search results." : "No notes yet."));
},
});
rows.controls.add(list, empty);
});
},
row(note: Note) {
return motifComponent("button", {
indexkey: () => note.id,
initializeComponent: (row: ComponentBase) => {
row.attr.add({ type: "button" });
row.class.add(() => (note === this.data.current ? "row active" : "row"));
row.motif.on("click", () => (this.data.current = note));
const pin = motifComponent("i", {
preconfig: (i: ComponentBase) => i.bindings.display(() => note.pinned),
initializeComponent: (i: ComponentBase) => i.class.add("pin"),
});
const title = motifComponent("span", (span: ComponentBase) => span.bindings.text(() => note.title.trim() || "Untitled"));
row.controls.add(pin, title);
},
});
},
detail() {
return motifComponent("article", {
preconfig: (article: ComponentBase) => article.bindings.display(() => this.data.current !== null),
initializeComponent: (article: ComponentBase) => {
article.class.add("detail");
const title = motifComponent("input", (field: ComponentBase) => {
field.attr.add({ placeholder: "Title" });
field.bindings.model(() => this.data.current!.title, (value: string) => (this.data.current!.title = value));
});
const body = motifComponent("textarea", (field: ComponentBase) => {
field.attr.add({ placeholder: "Note" });
field.bindings.model(() => this.data.current!.body, (value: string) => (this.data.current!.body = value));
});
const pin = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => notes.togglePin(this.data.current!));
button.bindings.text(() => (this.data.current?.pinned ? "Unpin" : "Pin"));
});
article.controls.add(title, body, pin);
},
});
},
});
};
import { Component, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export class NotesPane extends Component {
notes = this.getService(NotesService);
state = reactive({ query: "", current: null });
get visible() {
return this.notes.list("notes", this.state.query);
}
create() {
this.state.current = this.notes.create();
}
view() {
return (
<div class="notes">
<header class="list__head">
<h2>Notes</h2>
<button type="button" onclick={() => this.create()}>New note</button>
</header>
<input type="search" placeholder="Search notes" x-model={() => this.state.query} />
<div class="rows">
{this.visible.map((note) => (
<button type="button" key={note.id} class={() => (note === this.state.current ? "row active" : "row")} onclick={() => (this.state.current = note)}>
<i class="pin" x-display={() => note.pinned} />
<span>{() => note.title.trim() || "Untitled"}</span>
</button>
))}
<p class="list__empty" x-display={() => this.visible.length === 0}>
{() => (this.state.query.trim() ? "No search results." : "No notes yet.")}
</p>
</div>
<article class="detail" x-display={() => this.state.current !== null}>
<input placeholder="Title" x-model={() => this.state.current.title} />
<textarea placeholder="Note" x-model={() => this.state.current.body} />
<button type="button" onclick={() => this.notes.togglePin(this.state.current)}>
{() => (this.state.current?.pinned ? "Unpin" : "Pin")}
</button>
</article>
</div>
);
}
}
import { FromService, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export function NotesPane() {
const notes = FromService(NotesService);
const state = reactive({ query: "", current: null });
const visible = () => notes.list("notes", state.query);
const create = () => (state.current = notes.create());
return (
<div class="notes">
<header class="list__head">
<h2>Notes</h2>
<button type="button" onclick={create}>New note</button>
</header>
<input type="search" placeholder="Search notes" x-model={() => state.query} />
<div class="rows">
{visible().map((note) => (
<button type="button" key={note.id} class={() => (note === state.current ? "row active" : "row")} onclick={() => (state.current = note)}>
<i class="pin" x-display={() => note.pinned} />
<span>{() => note.title.trim() || "Untitled"}</span>
</button>
))}
<p class="list__empty" x-display={() => visible().length === 0}>
{() => (state.query.trim() ? "No search results." : "No notes yet.")}
</p>
</div>
<article class="detail" x-display={() => state.current !== null}>
<input placeholder="Title" x-model={() => state.current.title} />
<textarea placeholder="Note" x-model={() => state.current.body} />
<button type="button" onclick={() => notes.togglePin(state.current)}>
{() => (state.current?.pinned ? "Unpin" : "Pin")}
</button>
</article>
</div>
);
}
import { FromService, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export const NotesPane = () => {
const notes = FromService(NotesService);
return motifOptions({
el: "div",
data: reactive({ query: "", current: null }),
visible() {
return notes.list("notes", this.data.query);
},
create() {
this.data.current = notes.create();
},
view() {
this.class.add("notes");
return (
<>
<header class="list__head">
<h2>Notes</h2>
<button type="button" onclick={() => this.create()}>New note</button>
</header>
<input type="search" placeholder="Search notes" x-model={() => this.data.query} />
<div class="rows">
{this.visible().map((note) => (
<button type="button" key={note.id} class={() => (note === this.data.current ? "row active" : "row")} onclick={() => (this.data.current = note)}>
<i class="pin" x-display={() => note.pinned} />
<span>{() => note.title.trim() || "Untitled"}</span>
</button>
))}
<p class="list__empty" x-display={() => this.visible().length === 0}>
{() => (this.data.query.trim() ? "No search results." : "No notes yet.")}
</p>
</div>
<article class="detail" x-display={() => this.data.current !== null}>
<input placeholder="Title" x-model={() => this.data.current.title} />
<textarea placeholder="Note" x-model={() => this.data.current.body} />
<button type="button" onclick={() => notes.togglePin(this.data.current)}>
{() => (this.data.current?.pinned ? "Unpin" : "Pin")}
</button>
</article>
</>
);
},
});
};
import { Component, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export class NotesPane extends Component {
notes = this.getService(NotesService);
state = reactive({ query: "", current: null });
get visible() {
return this.notes.list("notes", this.state.query);
}
create() {
this.state.current = this.notes.create();
}
view() {
return motifComponent("div", (root) => {
root.class.add("notes");
root.controls.add(this.head(), this.search(), this.rows(), this.detail());
});
}
head() {
return motifComponent("header", (head) => {
head.class.add("list__head");
const title = motifComponent("h2", (h2) => {
h2.controls.add(motifComponent("text", (node) => node.setText("Notes")));
});
const newNote = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => this.create());
button.controls.add(motifComponent("text", (node) => node.setText("New note")));
});
head.controls.add(title, newNote);
});
}
search() {
return motifComponent("input", (field) => {
field.attr.add({ type: "search", placeholder: "Search notes" });
field.bindings.model(() => this.state.query, (value) => (this.state.query = value));
});
}
rows() {
return motifComponent("div", (rows) => {
rows.class.add("rows");
const list = motifComponent({
initializeComponent: (holder) => {
holder.bindings.list(() => this.visible, (note) => this.row(note));
},
});
const empty = motifComponent("p", {
preconfig: (p) => p.bindings.display(() => this.visible.length === 0),
initializeComponent: (p) => {
p.class.add("list__empty");
p.bindings.text(() => (this.state.query.trim() ? "No search results." : "No notes yet."));
},
});
rows.controls.add(list, empty);
});
}
row(note) {
return motifComponent("button", {
indexkey: () => note.id,
initializeComponent: (row) => {
row.attr.add({ type: "button" });
row.class.add(() => (note === this.state.current ? "row active" : "row"));
row.motif.on("click", () => (this.state.current = note));
const pin = motifComponent("i", {
preconfig: (i) => i.bindings.display(() => note.pinned),
initializeComponent: (i) => i.class.add("pin"),
});
const title = motifComponent("span", (span) => span.bindings.text(() => note.title.trim() || "Untitled"));
row.controls.add(pin, title);
},
});
}
detail() {
return motifComponent("article", {
preconfig: (article) => article.bindings.display(() => this.state.current !== null),
initializeComponent: (article) => {
article.class.add("detail");
const title = motifComponent("input", (field) => {
field.attr.add({ placeholder: "Title" });
field.bindings.model(() => this.state.current.title, (value) => (this.state.current.title = value));
});
const body = motifComponent("textarea", (field) => {
field.attr.add({ placeholder: "Note" });
field.bindings.model(() => this.state.current.body, (value) => (this.state.current.body = value));
});
const pin = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => this.notes.togglePin(this.state.current));
button.bindings.text(() => (this.state.current?.pinned ? "Unpin" : "Pin"));
});
article.controls.add(title, body, pin);
},
});
}
}
import { FromService, motifComponent, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export function NotesPane() {
const notes = FromService(NotesService);
const state = reactive({ query: "", current: null });
const visible = () => notes.list("notes", state.query);
const create = () => (state.current = notes.create());
const head = motifComponent("header", (bar) => {
bar.class.add("list__head");
const title = motifComponent("h2", (h2) => {
h2.controls.add(motifComponent("text", (node) => node.setText("Notes")));
});
const newNote = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", create);
button.controls.add(motifComponent("text", (node) => node.setText("New note")));
});
bar.controls.add(title, newNote);
});
const search = motifComponent("input", (field) => {
field.attr.add({ type: "search", placeholder: "Search notes" });
field.bindings.model(() => state.query, (value) => (state.query = value));
});
const row = (note) =>
motifComponent("button", {
indexkey: () => note.id,
initializeComponent: (item) => {
item.attr.add({ type: "button" });
item.class.add(() => (note === state.current ? "row active" : "row"));
item.motif.on("click", () => (state.current = note));
const pin = motifComponent("i", {
preconfig: (i) => i.bindings.display(() => note.pinned),
initializeComponent: (i) => i.class.add("pin"),
});
const title = motifComponent("span", (span) => span.bindings.text(() => note.title.trim() || "Untitled"));
item.controls.add(pin, title);
},
});
const rows = motifComponent("div", (box) => {
box.class.add("rows");
const list = motifComponent({
initializeComponent: (holder) => holder.bindings.list(visible, row),
});
const empty = motifComponent("p", {
preconfig: (p) => p.bindings.display(() => visible().length === 0),
initializeComponent: (p) => {
p.class.add("list__empty");
p.bindings.text(() => (state.query.trim() ? "No search results." : "No notes yet."));
},
});
box.controls.add(list, empty);
});
const detail = motifComponent("article", {
preconfig: (article) => article.bindings.display(() => state.current !== null),
initializeComponent: (article) => {
article.class.add("detail");
const title = motifComponent("input", (field) => {
field.attr.add({ placeholder: "Title" });
field.bindings.model(() => state.current.title, (value) => (state.current.title = value));
});
const body = motifComponent("textarea", (field) => {
field.attr.add({ placeholder: "Note" });
field.bindings.model(() => state.current.body, (value) => (state.current.body = value));
});
const pin = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => notes.togglePin(state.current));
button.bindings.text(() => (state.current?.pinned ? "Unpin" : "Pin"));
});
article.controls.add(title, body, pin);
},
});
return motifComponent("div", (root) => {
root.class.add("notes");
root.controls.add(head, search, rows, detail);
});
}
import { FromService, motifComponent, motifOptions, reactive } from "@motifx/core";
import { NotesService } from "../services/notes";
export const NotesPane = () => {
const notes = FromService(NotesService);
return motifOptions({
el: "div",
data: reactive({ query: "", current: null }),
visible() {
return notes.list("notes", this.data.query);
},
create() {
this.data.current = notes.create();
},
view() {
this.class.add("notes");
return [this.head(), this.search(), this.rows(), this.detail()];
},
head() {
return motifComponent("header", (head) => {
head.class.add("list__head");
const title = motifComponent("h2", (h2) => {
h2.controls.add(motifComponent("text", (node) => node.setText("Notes")));
});
const newNote = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => this.create());
button.controls.add(motifComponent("text", (node) => node.setText("New note")));
});
head.controls.add(title, newNote);
});
},
search() {
return motifComponent("input", (field) => {
field.attr.add({ type: "search", placeholder: "Search notes" });
field.bindings.model(() => this.data.query, (value) => (this.data.query = value));
});
},
rows() {
return motifComponent("div", (rows) => {
rows.class.add("rows");
const list = motifComponent({
initializeComponent: (holder) => {
holder.bindings.list(() => this.visible(), (note) => this.row(note));
},
});
const empty = motifComponent("p", {
preconfig: (p) => p.bindings.display(() => this.visible().length === 0),
initializeComponent: (p) => {
p.class.add("list__empty");
p.bindings.text(() => (this.data.query.trim() ? "No search results." : "No notes yet."));
},
});
rows.controls.add(list, empty);
});
},
row(note) {
return motifComponent("button", {
indexkey: () => note.id,
initializeComponent: (row) => {
row.attr.add({ type: "button" });
row.class.add(() => (note === this.data.current ? "row active" : "row"));
row.motif.on("click", () => (this.data.current = note));
const pin = motifComponent("i", {
preconfig: (i) => i.bindings.display(() => note.pinned),
initializeComponent: (i) => i.class.add("pin"),
});
const title = motifComponent("span", (span) => span.bindings.text(() => note.title.trim() || "Untitled"));
row.controls.add(pin, title);
},
});
},
detail() {
return motifComponent("article", {
preconfig: (article) => article.bindings.display(() => this.data.current !== null),
initializeComponent: (article) => {
article.class.add("detail");
const title = motifComponent("input", (field) => {
field.attr.add({ placeholder: "Title" });
field.bindings.model(() => this.data.current.title, (value) => (this.data.current.title = value));
});
const body = motifComponent("textarea", (field) => {
field.attr.add({ placeholder: "Note" });
field.bindings.model(() => this.data.current.body, (value) => (this.data.current.body = value));
});
const pin = motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.motif.on("click", () => notes.togglePin(this.data.current));
button.bindings.text(() => (this.data.current?.pinned ? "Unpin" : "Pin"));
});
article.controls.add(title, body, pin);
},
});
},
});
};
import { Application } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", new NotesPane());
import { Application } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", <NotesPane />);
import { Application } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", new NotesPane());
import { Application } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", NotesPane());
import { Application, motifComponent } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", motifComponent(NotesPane));
import { Application } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", new NotesPane());
import { Application } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", <NotesPane />);
import { Application } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", new NotesPane());
import { Application } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", NotesPane());
import { Application, motifComponent } from "@motifx/core";
import { NotesPane } from "./pages/NotesPane";
import { NotesService } from "./services/notes";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(NotesService, { useClass: NotesService });
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", motifComponent(NotesPane));
A few things worth noticing in the example:
- Each note is a single reactive object. When the title changes in the editor, the text in the row updates, and when pinning changes, so does the icon in the row; the list is not rebuilt.
NotesService.createadds to the front of the list, and the rows follow that on their own. - Bindings are fine-grained.
x-model/bindings.modelis a two-way binding,{() => …}/bindings.textis live text,x-display/bindings.displayis visibility, and.map+key/bindings.list+indexkeyis a list of rows. Rows are matched to the item object itself;keydoes not change that matching, and if the same key is used twice, development mode prints an MJX202 warning. In JSX, the compiler turns a field read such as{note.title}into a binding by itself; when writing without JSX, a value that has to stay live is passed as a getter, whilesetText("…")and plain values are written once. - Services are registered while the application is being built.
builder.services.addSingleton(NotesService, { useClass: NotesService })registers the service beforebuild(). A singleton givenstartup: trueis created at the start ofapp.run(), before the root component is built, without waiting for a component to ask for it; on a service that is not a singleton this option is ignored and an MJX415 warning is printed. The other way to register is to mark the class with@Injectable({ lifetime: "singleton" }). That mark enters the registry when the class's module is loaded:build()registers only the marked classes loaded up to that point, so a service whose module has not been loaded yet is not in the registry until the module loads and it is asked for the first time, and it is not created at startup even withstartup: true. On a class that was registered in the builder, the mark is ignored. In the example the class component gets the service withthis.getService, and the function and Options components withFromService; the Options component can usethis.getServicetoo. - The imperative form is the same core calls written by hand.
motifComponent(tag, setup)creates a component, and the setup function runs once asinitializeComponent. For an element that starts out hidden, thedisplaybinding is set up inpreconfigso the element is not built until it becomes visible. Plain text is a"text"component:motifComponent("text", (node) => node.setText("…")). The compiler produces whichever binding it picks for each expression (bindings.methodfor live text,bindings.ternaryfor a ternary); when writing by hand,bindings.textis enough for live text.npx motif-explain file.tsxprints which call a JSX expression comes down to.
Next step
To begin, go on to Getting Started.