Core concepts
Element and Appearance
Reaching a component's element and its inner tags: `element`, `ref`, `onRefCreated`; `class`, `attr`, `style` and `setText`.
Element access inside a component (element and ref)
Every component's real DOM node is reached through element, and the element is ready as soon as the component is created. To hold the component of one of the inner tags, write ref on that tag: in JSX ref={this.title} (ref={title} in a function component) or ref={(c) => …}, and without JSX the ref key of the props object.
The editing area of the note page holds its title and body fields through ref. When the page opens, it sizes the body field to the height of its content and, if the note is new, puts the cursor in the title; the body field grows as you type. onEdit is a callback prop called on every change:
import { Component } from "@motifx/core";
import type { Note } from "../services/notes";
export class NoteFields extends Component<HTMLDivElement, { note: Note; onEdit?: () => void }> {
title!: Component<HTMLInputElement>;
body!: Component<HTMLTextAreaElement>;
fit() {
this.body.style({ height: "auto" });
this.body.style({ height: `${this.body.element.scrollHeight}px` });
}
override onConfigured() {
this.class.add("editor");
}
override onMounted() {
const { note } = this.props;
this.fit();
if (!note.title && !note.body) this.title.element.focus();
}
override view() {
const { note, onEdit } = this.props;
return (
<>
<input ref={this.title} class="editor__title" placeholder="Untitled" aria-label="Title" autocomplete="off" x-model={() => note.title} oninput={() => onEdit?.()} />
<textarea
ref={this.body}
class="editor__body"
rows={1}
placeholder="Start writing…"
aria-label="Text"
x-model={() => note.body}
oninput={() => {
this.fit();
onEdit?.();
}}
/>
</>
);
}
}
import { Component, ComponentBase, motifComponent, motifFragment } from "@motifx/core";
import type { Note } from "../services/notes";
export class NoteFields extends Component<HTMLDivElement, { note: Note; onEdit?: () => void }> {
static override elementTag = "div";
title!: Component<HTMLInputElement>;
body!: Component<HTMLTextAreaElement>;
fit() {
this.body.style({ height: "auto" });
this.body.style({ height: `${this.body.element.scrollHeight}px` });
}
override onConfigured() {
this.class.add("editor");
}
override onMounted() {
const { note } = this.props;
this.fit();
if (!note.title && !note.body) this.title.element.focus();
}
override view() {
const { note, onEdit } = this.props;
const title = motifComponent("input", {
ref: (field: Component<HTMLInputElement>) => (this.title = field),
initializeComponent: (field: ComponentBase) => {
field.class.add("editor__title");
field.attr.add({ placeholder: "Untitled", "aria-label": "Title", autocomplete: "off" });
field.bindings.model(() => note.title, (value: string) => (note.title = value));
field.motif.on("input", () => onEdit?.());
},
});
const body = motifComponent("textarea", {
ref: (field: Component<HTMLTextAreaElement>) => (this.body = field),
initializeComponent: (field: ComponentBase) => {
field.class.add("editor__body");
field.attr.add({ rows: 1, placeholder: "Start writing…", "aria-label": "Text" });
field.bindings.model(() => note.body, (value: string) => (note.body = value));
field.motif.on("input", () => {
this.fit();
onEdit?.();
});
},
});
return motifFragment({ childs: [title, body] });
}
}
import type { Component } from "@motifx/core";
import type { Note } from "../services/notes";
export function NoteFields(props: { note: Note; onEdit?: () => void }) {
const { note, onEdit } = props;
let title!: Component<HTMLInputElement>;
let body!: Component<HTMLTextAreaElement>;
const fit = () => {
body.style({ height: "auto" });
body.style({ height: `${body.element.scrollHeight}px` });
};
return (
<div
class="editor"
onMounted={() => {
fit();
if (!note.title && !note.body) title.element.focus();
}}
>
<input ref={title} class="editor__title" placeholder="Untitled" aria-label="Title" autocomplete="off" x-model={() => note.title} oninput={() => onEdit?.()} />
<textarea
ref={body}
class="editor__body"
rows={1}
placeholder="Start writing…"
aria-label="Text"
x-model={() => note.body}
oninput={() => {
fit();
onEdit?.();
}}
/>
</div>
);
}
import { Component, ComponentBase, motifComponent } from "@motifx/core";
import type { Note } from "../services/notes";
export function NoteFields(props: { note: Note; onEdit?: () => void }) {
const { note, onEdit } = props;
let title!: Component<HTMLInputElement>;
let body!: Component<HTMLTextAreaElement>;
const fit = () => {
body.style({ height: "auto" });
body.style({ height: `${body.element.scrollHeight}px` });
};
return motifComponent("div", {
class: "editor",
onMounted: () => {
fit();
if (!note.title && !note.body) title.element.focus();
},
initializeComponent: (editor: ComponentBase) => {
const titleField = motifComponent("input", {
ref: (field: Component<HTMLInputElement>) => (title = field),
initializeComponent: (field: ComponentBase) => {
field.class.add("editor__title");
field.attr.add({ placeholder: "Untitled", "aria-label": "Title", autocomplete: "off" });
field.bindings.model(() => note.title, (value: string) => (note.title = value));
field.motif.on("input", () => onEdit?.());
},
});
const bodyField = motifComponent("textarea", {
ref: (field: Component<HTMLTextAreaElement>) => (body = field),
initializeComponent: (field: ComponentBase) => {
field.class.add("editor__body");
field.attr.add({ rows: 1, placeholder: "Start writing…", "aria-label": "Text" });
field.bindings.model(() => note.body, (value: string) => (note.body = value));
field.motif.on("input", () => {
fit();
onEdit?.();
});
},
});
editor.controls.add(titleField, bodyField);
},
});
}
import { motifOptions, type Component } from "@motifx/core";
import type { Note } from "../services/notes";
export const NoteFields = (props: { note: Note; onEdit?: () => void }) => motifOptions({
el: "div",
title: undefined as Component<HTMLInputElement> | undefined,
body: undefined as Component<HTMLTextAreaElement> | undefined,
fit() {
this.body!.style({ height: "auto" });
this.body!.style({ height: `${this.body!.element.scrollHeight}px` });
},
onConfigured() {
this.class.add("editor");
},
onMounted() {
this.fit();
if (!props.note.title && !props.note.body) this.title!.element.focus();
},
view() {
const { note, onEdit } = props;
return (
<>
<input ref={this.title} class="editor__title" placeholder="Untitled" aria-label="Title" autocomplete="off" x-model={() => note.title} oninput={() => onEdit?.()} />
<textarea
ref={this.body}
class="editor__body"
rows={1}
placeholder="Start writing…"
aria-label="Text"
x-model={() => note.body}
oninput={() => {
this.fit();
onEdit?.();
}}
/>
</>
);
},
});
import { Component, ComponentBase, motifComponent, motifFragment, motifOptions } from "@motifx/core";
import type { Note } from "../services/notes";
export const NoteFields = (props: { note: Note; onEdit?: () => void }) => motifOptions({
el: "div",
title: undefined as Component<HTMLInputElement> | undefined,
body: undefined as Component<HTMLTextAreaElement> | undefined,
fit() {
this.body!.style({ height: "auto" });
this.body!.style({ height: `${this.body!.element.scrollHeight}px` });
},
onConfigured() {
this.class.add("editor");
},
onMounted() {
this.fit();
if (!props.note.title && !props.note.body) this.title!.element.focus();
},
view() {
const { note, onEdit } = props;
const title = motifComponent("input", {
ref: (field: Component<HTMLInputElement>) => (this.title = field),
initializeComponent: (field: ComponentBase) => {
field.class.add("editor__title");
field.attr.add({ placeholder: "Untitled", "aria-label": "Title", autocomplete: "off" });
field.bindings.model(() => note.title, (value: string) => (note.title = value));
field.motif.on("input", () => onEdit?.());
},
});
const body = motifComponent("textarea", {
ref: (field: Component<HTMLTextAreaElement>) => (this.body = field),
initializeComponent: (field: ComponentBase) => {
field.class.add("editor__body");
field.attr.add({ rows: 1, placeholder: "Start writing…", "aria-label": "Text" });
field.bindings.model(() => note.body, (value: string) => (note.body = value));
field.motif.on("input", () => {
this.fit();
onEdit?.();
});
},
});
return motifFragment({ childs: [title, body] });
},
});
import { Component } from "@motifx/core";
export class NoteFields extends Component {
static elementTag = "div";
title;
body;
fit() {
this.body.style({ height: "auto" });
this.body.style({ height: `${this.body.element.scrollHeight}px` });
}
onConfigured() {
this.class.add("editor");
}
onMounted() {
const { note } = this.props;
this.fit();
if (!note.title && !note.body) this.title.element.focus();
}
view() {
const { note, onEdit } = this.props;
return (
<>
<input ref={this.title} class="editor__title" placeholder="Untitled" aria-label="Title" autocomplete="off" x-model={() => note.title} oninput={() => onEdit?.()} />
<textarea
ref={this.body}
class="editor__body"
rows={1}
placeholder="Start writing…"
aria-label="Text"
x-model={() => note.body}
oninput={() => {
this.fit();
onEdit?.();
}}
/>
</>
);
}
}
import { Component, motifComponent, motifFragment } from "@motifx/core";
export class NoteFields extends Component {
static elementTag = "div";
title;
body;
fit() {
this.body.style({ height: "auto" });
this.body.style({ height: `${this.body.element.scrollHeight}px` });
}
onConfigured() {
this.class.add("editor");
}
onMounted() {
const { note } = this.props;
this.fit();
if (!note.title && !note.body) this.title.element.focus();
}
view() {
const { note, onEdit } = this.props;
const title = motifComponent("input", {
ref: (field) => (this.title = field),
initializeComponent: (field) => {
field.class.add("editor__title");
field.attr.add({ placeholder: "Untitled", "aria-label": "Title", autocomplete: "off" });
field.bindings.model(() => note.title, (value) => (note.title = value));
field.motif.on("input", () => onEdit?.());
},
});
const body = motifComponent("textarea", {
ref: (field) => (this.body = field),
initializeComponent: (field) => {
field.class.add("editor__body");
field.attr.add({ rows: 1, placeholder: "Start writing…", "aria-label": "Text" });
field.bindings.model(() => note.body, (value) => (note.body = value));
field.motif.on("input", () => {
this.fit();
onEdit?.();
});
},
});
return motifFragment({ childs: [title, body] });
}
}
export function NoteFields(props) {
const { note, onEdit } = props;
let title;
let body;
const fit = () => {
body.style({ height: "auto" });
body.style({ height: `${body.element.scrollHeight}px` });
};
return (
<div
class="editor"
onMounted={() => {
fit();
if (!note.title && !note.body) title.element.focus();
}}
>
<input ref={title} class="editor__title" placeholder="Untitled" aria-label="Title" autocomplete="off" x-model={() => note.title} oninput={() => onEdit?.()} />
<textarea
ref={body}
class="editor__body"
rows={1}
placeholder="Start writing…"
aria-label="Text"
x-model={() => note.body}
oninput={() => {
fit();
onEdit?.();
}}
/>
</div>
);
}
import { motifComponent } from "@motifx/core";
export function NoteFields(props) {
const { note, onEdit } = props;
let title;
let body;
const fit = () => {
body.style({ height: "auto" });
body.style({ height: `${body.element.scrollHeight}px` });
};
return motifComponent("div", {
class: "editor",
onMounted: () => {
fit();
if (!note.title && !note.body) title.element.focus();
},
initializeComponent: (editor) => {
const titleField = motifComponent("input", {
ref: (field) => (title = field),
initializeComponent: (field) => {
field.class.add("editor__title");
field.attr.add({ placeholder: "Untitled", "aria-label": "Title", autocomplete: "off" });
field.bindings.model(() => note.title, (value) => (note.title = value));
field.motif.on("input", () => onEdit?.());
},
});
const bodyField = motifComponent("textarea", {
ref: (field) => (body = field),
initializeComponent: (field) => {
field.class.add("editor__body");
field.attr.add({ rows: 1, placeholder: "Start writing…", "aria-label": "Text" });
field.bindings.model(() => note.body, (value) => (note.body = value));
field.motif.on("input", () => {
fit();
onEdit?.();
});
},
});
editor.controls.add(titleField, bodyField);
},
});
}
import { motifOptions } from "@motifx/core";
export const NoteFields = (props) => motifOptions({
el: "div",
title: undefined,
body: undefined,
fit() {
this.body.style({ height: "auto" });
this.body.style({ height: `${this.body.element.scrollHeight}px` });
},
onConfigured() {
this.class.add("editor");
},
onMounted() {
this.fit();
if (!props.note.title && !props.note.body) this.title.element.focus();
},
view() {
const { note, onEdit } = props;
return (
<>
<input ref={this.title} class="editor__title" placeholder="Untitled" aria-label="Title" autocomplete="off" x-model={() => note.title} oninput={() => onEdit?.()} />
<textarea
ref={this.body}
class="editor__body"
rows={1}
placeholder="Start writing…"
aria-label="Text"
x-model={() => note.body}
oninput={() => {
this.fit();
onEdit?.();
}}
/>
</>
);
},
});
import { motifComponent, motifFragment, motifOptions } from "@motifx/core";
export const NoteFields = (props) => motifOptions({
el: "div",
title: undefined,
body: undefined,
fit() {
this.body.style({ height: "auto" });
this.body.style({ height: `${this.body.element.scrollHeight}px` });
},
onConfigured() {
this.class.add("editor");
},
onMounted() {
this.fit();
if (!props.note.title && !props.note.body) this.title.element.focus();
},
view() {
const { note, onEdit } = props;
const title = motifComponent("input", {
ref: (field) => (this.title = field),
initializeComponent: (field) => {
field.class.add("editor__title");
field.attr.add({ placeholder: "Untitled", "aria-label": "Title", autocomplete: "off" });
field.bindings.model(() => note.title, (value) => (note.title = value));
field.motif.on("input", () => onEdit?.());
},
});
const body = motifComponent("textarea", {
ref: (field) => (this.body = field),
initializeComponent: (field) => {
field.class.add("editor__body");
field.attr.add({ rows: 1, placeholder: "Start writing…", "aria-label": "Text" });
field.bindings.model(() => note.body, (value) => (note.body = value));
field.motif.on("input", () => {
this.fit();
onEdit?.();
});
},
});
return motifFragment({ childs: [title, body] });
},
});
The note page places the fields with <NoteFields note={note} onEdit={() => this.edited(note)} />.
The note page places the fields with new NoteFields({ note, onEdit: () => this.edited(note) }).
The note page places the fields with motifComponent(NoteFields, { note, onEdit: () => this.edited(note) }).
ref={(s) => ...}— the callback form;sis the tag's component (on a plain DOM tag, theComponentthat wraps the element; on a component tag, the component instance; on a function component, the returned root). Without JSX this is the only form:motifComponent("input", { ref: (s) => (this.title = s), … })((title = s)in a function component).ref={this.x}/ref={name}— the compiler resolves the target. If the target is a method, a field holding a function or a local function, it is called with the tag's component. If it is a declared field (without a value or with a plain value) or a variable without an initial value, the component is assigned to it. For targets that cannot be decided at compile time (a member that is not declared or comes from a base class, a parameter,props.x, a field typed as a function, a getter), the decision is made at run time: if the value is a function it is called, otherwise it is assigned. To pass an imported function as a ref, write the callback form:ref={(s) => registerField(s)}. Withref={registerField}the generated code also contains a branch that assigns to the imported name; that works on the development server, butvite build(Rollup) stops with an "Illegal reassignment of import" error.- Both forms mean the same on a plain DOM tag and on a component tag;
x-ref(andx:ref) is the same asref. refis called exactly once, while the tag's component is being built (beforebuild()), and it is not written to the DOM as an attribute. On a class or Options component tag, by the time the ref is called,props, the tag's common attributes (class,id…) andoptionshave been applied. On a plain DOM tag, at the moment of the ref,optionsand the attributes that go to the constructor (those spread with{...p}, and without JSX those put directly in the object) have been applied; attributes written directly on the tag in JSX (class,id,placeholder…) and, without JSX, those added insideinitializeComponentare written during the build, after the ref. On a function component tag, the ref is called as soon as the function returns its root. In a class component the ref runs inside the base class's constructor; fields defined in the subclass body (marker = 'ready') are set up after the constructor returns, as JavaScript requires, so they do not exist yet inside the ref. A ref is meant for assigning the component to a field in the parent component (ref={(b) => (this.okButton = b)}), not for writing to the class's own fields.- If
refandx-refare both written on the same tag, each runs once, in source order. refis not a prop; it works only on the component it is given to: on a class component it applies to the component itself, and on a function component to the returned root. It is not part of thepropspassed to the function, nor ofthis.props, so spreading with{...props}/{...this.props}does not carry it to an inner component.- It is the same when building without JSX:
motifComponent(Setting, { ref: (c) => ... })applies the ref to the component itself (to the returned root for a function component), and the ref does not stay in thepropspassed to the function or inthis.props. In the class form,new Setting({ ref: (c) => ... })is the same; in the function and Options forms you usemotifComponent, notnew. - The rule is simple: if
refis a function, it is called with the component and removed from the props. On a class, an Options component or a plain tag, if it is an object, the component is assigned to that field (props.ref): for the class-styleSetting, afterconst p = { name: "Archive", hint: "", ref: {} }; const s = new Setting(p);,p.ref === s. On a function component no assignment is made to an object-style ref; use the callback form. elementexists before mounting too; its content arrives when the component is built. Work that needs the element to be attached to the document (focus, measurements) is done inonMounted.
To expose an inner item to the outside, use a separate prop name (for example titleRef) and call it from the inner tag's ref: <input ref={(c) => props.titleRef?.(c)} … />. Outside, write <NoteFields note={note} titleRef={(c) => (this.title = c)} /> (title = c in a function component). A ref written on a component tag, on the other hand, gives the component itself; in a function component that is the returned root div. Writing ref={props.titleRef} also works: if the value is a function, it is called with the inner component.
onRefCreated — a shared hook for refs
If a class component defines an onRefCreated(sender) method, it is called right after each ref={this.x} / ref={name} inside view() has been applied to its target. sender is the component of the tag that got the ref; by then the target field has been assigned, or the target method called. It is not called for the callback form ref={(s) => ...}. Without JSX, ref is always a callback, so this hook only works in JSX.
Instead of writing oninput on every field separately, the editing area can attach the listener in one place:
import { Component } from "@motifx/core";
import type { Note } from "../services/notes";
export class NoteFields extends Component<HTMLDivElement, { note: Note; onEdit?: () => void }> {
title!: Component<HTMLInputElement>;
body!: Component<HTMLTextAreaElement>;
override view() {
const { note } = this.props;
return (
<>
<input ref={this.title} class="editor__title" placeholder="Untitled" x-model={() => note.title} />
<textarea ref={this.body} class="editor__body" placeholder="Start writing…" x-model={() => note.body} />
</>
);
}
onRefCreated(field: Component) {
field.motif.on("input", () => this.props.onEdit?.());
}
}
import { Component } from "@motifx/core";
export class NoteFields extends Component {
static elementTag = "div";
title;
body;
view() {
const { note } = this.props;
return (
<>
<input ref={this.title} class="editor__title" placeholder="Untitled" x-model={() => note.title} />
<textarea ref={this.body} class="editor__body" placeholder="Start writing…" x-model={() => note.body} />
</>
);
}
onRefCreated(field) {
field.motif.on("input", () => this.props.onEdit?.());
}
}
In the form you picked this hook does not run; every ref is given in the callback form, and the shared work is done inside that callback.
Each ref is applied the moment the tag's component is created, and onRefCreated runs once after each of them. Tags inside a plain tag are created while that tag is being built, that is, after its siblings have been created, so with nesting the order can differ from the order in the source: in <form><label><input ref={this.title} /></label><textarea ref={this.body} /></form>, body is applied first and title second. The hook is a member of the class that writes the JSX (the this of view()), not of the component that got the ref. It is called for JSX in the class's methods and fields (arrow functions included); it is not called in function components, in module-level JSX, in function expressions inside a class method, or in object methods. An Options component's view() is an object method too: a ref={this.x} there is assigned, but an onRefCreated member written on the object is not called.
Appearance: class, attr, style, setText
The class, attributes, inline style and text of a component's element are managed through the component.
In the example above, these are this.class.add("editor"), field.attr.add({ … }) and this.body.style({ height: … }).
In the example above, these are class="editor" given on the root element (the class: "editor" key without JSX), field.attr.add({ … }) and body.style({ height: … }).
| Call | What it does |
|---|---|
class.add("a", "b") |
Adds classes. A function (() => (note.pinned ? "pinned" : "")) and an object such as { active: () => open() } are live; an array can be given too. |
class.remove("a"), class.remove("**") |
Removes a class; "**" removes every class and every live source. |
class.has("a") |
Whether the element has the class. |
attr.add({ type: "button", disabled: () => busy }) |
Writes attributes. A function value is live; false, null and undefined remove the attribute, and true writes it with an empty value. |
attr.remove("disabled"), attr.has("disabled"), attr.get("href") |
Removes an attribute, asks whether it exists, reads its value. |
attr.hold({ disabled: true }) |
Holds the value over the component's own and returns an object that can be disposed; dispose() gives the key back to the component's own value. |
style({ height: "120px" }), style("color: red") |
Writes the inline style; an object changes only the properties it gives, and a string replaces the whole style. |
style(() => ({ opacity: … })) |
A live style, written again when a value the function reads changes. |
setText("Saved") |
Changes the element's text (textContent); the child nodes inside it are removed. For live text use bindings.text(() => …). |
class, attr and style do nothing on a component without an element, because its root is a comment node (see Root element). class.add keeps a count of the same class coming from several sources; the details and their JSX counterparts are on the Styling and Animation page.
When attr.add writes a key again, the earlier value goes; if the earlier value was a function, its binding goes too. To cover an attribute with another value for a while and then return to the component's own value, there is attr.hold: while it is held, what the component writes to that key does not reach the element, and when dispose() is called the key returns to the component's own value at that moment. The rules are on the Writing a Component Library page.
Next step
To show and hide a component, listen to events and release its resources, go on to The motif Namespace.