Core concepts
controls and the Component Tree
The child control collection `controls`, moving around the component tree, and moving content somewhere else.
controls — the child control collection
Every component has a controls collection. It is a live list that manages the component's children, and every change to the list shows up in the DOM right away.
| Call | What it does |
|---|---|
controls.add(a, b) |
Appends at the end and returns what was added as an array. |
controls.add(2, a), controls.insert(2, a, b) |
Inserts at the given index. |
controls.remove(a) |
Removes the child and disposes it. |
await controls.detach(a) |
Plays the leave transition and takes the child out of the tree and the DOM; does not dispose it. |
controls.clear() |
Disposes every child. |
await controls.clearAsync() |
The same; resolves when every disposal is finished. |
controls.move(a, b) |
Moves a in front of b; to the end if b is not given. |
controls.moveToIndex(a, 0) |
Moves a to the given index. The index is the resulting position; length - 1 and above moves it to the end. |
controls.items, controls.length |
The array of children and the number of children. |
controls.forEach(fn), controls.map(fn) |
Walk over the children; map returns the results as an array. |
The activity log adds a row for every saved note. The newest row goes to the top, and the log keeps the last five rows; anything beyond that is disposed:
import { Component } from "@motifx/core";
const LIMIT = 5;
export class ActivityLog extends Component<HTMLUListElement> {
override onConfigured() {
this.class.add("log");
}
override onBuilt() {
this.context.on("noteSaved", (title?: string) => {
this.controls.add(0, <li>{title?.trim() || "Untitled"} saved</li>);
while (this.controls.length > LIMIT) this.controls.remove(this.controls.items[LIMIT]);
});
}
}
import { Component, ComponentBase, motifComponent } from "@motifx/core";
const LIMIT = 5;
export class ActivityLog extends Component<HTMLUListElement> {
static override elementTag = "ul";
override onConfigured() {
this.class.add("log");
}
override onBuilt() {
this.context.on("noteSaved", (title?: string) => {
const row = motifComponent("li", (li: ComponentBase) => li.setText(`${title?.trim() || "Untitled"} saved`));
this.controls.add(0, row);
while (this.controls.length > LIMIT) this.controls.remove(this.controls.items[LIMIT]);
});
}
}
import type { ComponentBase } from "@motifx/core";
const LIMIT = 5;
export function ActivityLog() {
return (
<ul
class="log"
onBuilt={(log: ComponentBase) => {
log.context.on("noteSaved", (title?: string) => {
log.controls.add(0, <li>{title?.trim() || "Untitled"} saved</li>);
while (log.controls.length > LIMIT) log.controls.remove(log.controls.items[LIMIT]);
});
}}
/>
);
}
import { ComponentBase, motifComponent } from "@motifx/core";
const LIMIT = 5;
export function ActivityLog() {
return motifComponent("ul", {
class: "log",
onBuilt: (log: ComponentBase) => {
log.context.on("noteSaved", (title?: string) => {
const row = motifComponent("li", (li: ComponentBase) => li.setText(`${title?.trim() || "Untitled"} saved`));
log.controls.add(0, row);
while (log.controls.length > LIMIT) log.controls.remove(log.controls.items[LIMIT]);
});
},
});
}
import { motifOptions } from "@motifx/core";
const LIMIT = 5;
export const ActivityLog = () => motifOptions({
el: "ul",
onConfigured() {
this.class.add("log");
},
onBuilt() {
this.context.on("noteSaved", (title?: string) => {
this.controls.add(0, <li>{title?.trim() || "Untitled"} saved</li>);
while (this.controls.length > LIMIT) this.controls.remove(this.controls.items[LIMIT]);
});
},
});
import { ComponentBase, motifComponent, motifOptions } from "@motifx/core";
const LIMIT = 5;
export const ActivityLog = () => motifOptions({
el: "ul",
onConfigured() {
this.class.add("log");
},
onBuilt() {
this.context.on("noteSaved", (title?: string) => {
const row = motifComponent("li", (li: ComponentBase) => li.setText(`${title?.trim() || "Untitled"} saved`));
this.controls.add(0, row);
while (this.controls.length > LIMIT) this.controls.remove(this.controls.items[LIMIT]);
});
},
});
import { Component } from "@motifx/core";
const LIMIT = 5;
export class ActivityLog extends Component {
static elementTag = "ul";
onConfigured() {
this.class.add("log");
}
onBuilt() {
this.context.on("noteSaved", (title) => {
this.controls.add(0, <li>{title?.trim() || "Untitled"} saved</li>);
while (this.controls.length > LIMIT) this.controls.remove(this.controls.items[LIMIT]);
});
}
}
import { Component, motifComponent } from "@motifx/core";
const LIMIT = 5;
export class ActivityLog extends Component {
static elementTag = "ul";
onConfigured() {
this.class.add("log");
}
onBuilt() {
this.context.on("noteSaved", (title) => {
const row = motifComponent("li", (li) => li.setText(`${title?.trim() || "Untitled"} saved`));
this.controls.add(0, row);
while (this.controls.length > LIMIT) this.controls.remove(this.controls.items[LIMIT]);
});
}
}
const LIMIT = 5;
export function ActivityLog() {
return (
<ul
class="log"
onBuilt={(log) => {
log.context.on("noteSaved", (title) => {
log.controls.add(0, <li>{title?.trim() || "Untitled"} saved</li>);
while (log.controls.length > LIMIT) log.controls.remove(log.controls.items[LIMIT]);
});
}}
/>
);
}
import { motifComponent } from "@motifx/core";
const LIMIT = 5;
export function ActivityLog() {
return motifComponent("ul", {
class: "log",
onBuilt: (log) => {
log.context.on("noteSaved", (title) => {
const row = motifComponent("li", (li) => li.setText(`${title?.trim() || "Untitled"} saved`));
log.controls.add(0, row);
while (log.controls.length > LIMIT) log.controls.remove(log.controls.items[LIMIT]);
});
},
});
}
import { motifOptions } from "@motifx/core";
const LIMIT = 5;
export const ActivityLog = () => motifOptions({
el: "ul",
onConfigured() {
this.class.add("log");
},
onBuilt() {
this.context.on("noteSaved", (title) => {
this.controls.add(0, <li>{title?.trim() || "Untitled"} saved</li>);
while (this.controls.length > LIMIT) this.controls.remove(this.controls.items[LIMIT]);
});
},
});
import { motifComponent, motifOptions } from "@motifx/core";
const LIMIT = 5;
export const ActivityLog = () => motifOptions({
el: "ul",
onConfigured() {
this.class.add("log");
},
onBuilt() {
this.context.on("noteSaved", (title) => {
const row = motifComponent("li", (li) => li.setText(`${title?.trim() || "Untitled"} saved`));
this.controls.add(0, row);
while (this.controls.length > LIMIT) this.controls.remove(this.controls.items[LIMIT]);
});
},
});
- At run time
add/insertaccept plain values too: strings, numbers, booleans and bigints are each wrapped in a text node. The TypeScript signature expectsComponentBase, so in typed code build the text node withmotifComponent("text", …). A child that already has a parent is first taken from there (detach) and then added. A disposed child is not added. - A child added to a built component is built and put in place inside the
addcall; there is no render or flush to wait for. A child added to a component that is not built yet is built together with its owner. If the component is held withisWait, the child is recorded and built when the wait is lifted. If the container carriesx-chunk, the first 100 of the children added together (the first n if a number is given) are built right away; the rest leave a marker and are built over the following frames, as far as the frame time allows (see Large lists). detachtakes the child out of the tree and the DOM but does not dispose it, so it can be added somewhere else withadd.removeandcleardo dispose.moveworks only between children of the same parent, and it moves them into the same order in the DOM (fragment-rooted children together with their range). If the position does not change, it does nothing.- The
onAdd/onRemove/onAddBeforeBuildcallbacks belong to the framework and are not assigned by hand. To listen for a child being added, usemotif.on('controladded', fn).motif.on('controlremoved', fn)arrives only when the child leaves this component throughdetach(including when another component'scontrols.addtakes the child from here); the event arrives after the leave transition and the removal from the DOM are finished.remove,clear,clearAsyncand the child's owndispose()do not trigger this event. In both eventse.controlis the component that was added or that left.
Moving around the component tree
Components form a live object tree, and from any component you can reach the tree in every direction. The element, props, public fields and methods of a component you find are used like those of any ordinary object.
| Member | What it gives |
|---|---|
parent |
The parent component; null on a component that has not been added anywhere. |
controls.items |
The children. |
siblings.next(), siblings.prev() |
The next and previous sibling; undefined at the end and at the start. |
siblings.all() |
The parent's children, the component itself included. |
siblings.nextAll(), siblings.prevAll() |
The following and preceding siblings. |
$(selector).fromComponent() |
Descendant components that match the selector (ComponentBase[]). |
$(selector).fromDom() |
DOM nodes inside the element that match the selector (NodeList). |
A plain tag in JSX is a component too. The first parameter of a two-parameter event handler (sender) is that tag's component, and you can walk the tree from there: sender.parent, sender.siblings.all(). A one-parameter handler receives only the event (see Events).
siblingsreads the parent'scontrols.itemsarray; if there is no parent, every method returnsundefined.- The
parentof the root component passed toapp.runis notnull: it is the application shell thatrun()builds around the host element (app.getAppShell()). The topmost component whoseparentisnullis this shell; for content moved into a window it is the window'shost, and for content in the page layer it isapp.layer(see Page layer). fromComponent()walks the component tree depth-first starting from this component (it is in the result itself if it matches) and compares each component's element with the selector. In a component whose root is a fragment, the search starts from the nearest parent component that has a real element, so the result covers all of that parent's content: matching items of sibling components come back too, and infromComponent()the parent itself if it matches. To search only your own content, search from an inner tag you hold throughref, or from a class that declares its element.fromDom()runsquerySelectorAllinside the same element. On a disposed component it returnsnull/[].- A component obtained through
refis used the same way (see Element access inside a component).
Moving content somewhere else
There are two built-in pairs of components for showing a component's content outside its own tree, and each is covered on its own page:
- Transport and TransportTo — shows a sender's content in a named slot. When several senders come to the same slot,
mode: 'replace'(the default) keeps only the content of the one built last, and'merge'adds the content of all of them in the order they were built. TheTransporterhelper for moving content in code is there as well. - ContentBody and ContentBlock — gathers the parts of several blocks in a single body.
Page layer
Content such as notifications, dialogs and drop-down menus belongs to the component that opens it, but it has to appear above everything else on the page. app.layer is the component set aside for that: the first time it is read, it puts a plain <div> at the end of <body>, after the application's host element, and returns the component that wraps that element; later reads return the same component. If the host is <body> itself or contains the layer, the layer stays at the end of the host: the shell's root and every component added later go in front of the layer. It can be read before run() too; if <body> does not exist yet, the element is placed when the document loads, content added up to that point counts as placed at that moment, and onMounted runs then. If the element is later taken out of the page, for example because startup code emptied the body, the layer is put back at the end of the body together with its contents the first time content asks for a place, or in run(); what was added until then is placed at that point. The core writes no styles or attributes on this element; positioning, z-index and markers such as aria-live are up to the application and are given the way they would be on any ordinary component: app.layer.class.add("overlays"), app.layer.attr.add({ "aria-live": "polite" }). app.dispose() disposes the layer together with its contents and removes the element. When the application is started again with run(), the layer is built again on its first read; if a layer was read after dispose, run() moves it after the host and the next app.dispose() removes it. If the application will not be started again, call app.dispose() once more and the layer is removed right then.
Content in the layer behaves like any other member of the tree: it goes in with controls.add; it receives onMounted, onActivated and onDeactivated; it sees the application's services and router through context; remove disposes it and detach keeps it. The parent of an added component is the layer, and the layer itself, like the shell, is a root whose parent is null.
app.layerOf(target?, ...components) puts content in the layer of the document the target is in. If the target is a component or a DOM node, its document is checked: if the document belongs to a window opened or connected through app.windows, that window's host is returned, otherwise app.layer; if no target is given or it is null, app.layer is returned. The components that follow are added to the layer found with controls.add, and the layer is returned. If the first argument is a component that has no parent and whose element is not yet in any document, it counts as content rather than a target and is added to the page layer; a component without a parent whose element already sits in a document, such as a root wrapped with new Component(document.body), is a target and stays where it is. So this.context.layerOf(this, new SaveToast()) puts the content in the window the component is in, and this.context.layerOf(new SaveToast()) puts it on the page. A target that is neither a component nor a node throws MJX424. Content placed directly has the layer's lifetime; if it has to go away together with the component that opened it, put a Transport slot in the layer and write the content with TransportTo (see Transport and TransportTo).
Moving a component to another window
A component's element can also be moved into another document of the same origin: a window opened with window.open, an always-on-top Picture-in-Picture window or an <iframe>. The component is not rebuilt; its state, bindings and services stay on the page, and only the elements move. app.windows manages this: this.context.windows.open(editor, { width: 720, height: 560 }) opens the component in a window, puts it back in its old place when the window closes, and carries the style sheets and the theme over to the window. For the options, the closing rules and Electron, see the Windows page. If you want to open the window yourself and only introduce the document to the application, app.connectDocument(win.document) is there as the lower layer; then wrapping the body with new Component(win.document.body), providing the content with controls.add and taking it back on close are up to you.
Next step
To reach a component's element and its inner tags, go on to Element and Appearance.