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) }).

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.