Skip to content

Layouts

A layout is the HTML of a view. It is plain HTML: no JSX, no template language, class= instead of className=. JOPS turns it into DOM elements when the view is inflated, and any jops tags inside it become subviews.

You can keep a layout in three places, and pick whichever fits the view.

Set this.layout in the constructor with the layout tagged template, then call this.inflate(). Best for components and views built from data.

src/Card.js
import { View, layout } from "jops-core";
export default class Card extends View {
constructor(title) {
super();
this.title = title;
this.layout = layout`<article class="card"><h3>${title}</h3></article>`;
this.inflate();
}
}

layout is a tagged template literal. Every ${value} you interpolate is HTML-escaped (&, <, > and "), so data from users or the network cannot inject markup:

import { layout } from "jops-core";
const name = '<img src=x onerror="alert(1)">';
layout`<p>Hello, ${name}</p>`;
// <p>Hello, &lt;img src=x onerror=&quot;alert(1)&quot;&gt;</p>

null and undefined render as empty, and layout returns a plain string, so you can use it anywhere you need safe HTML, including with Element.set().

layout`<li class="${task.done ? "done" : ""}">${task.title}</li>`

To insert HTML that you trust, wrap it in raw(). It is passed through as-is:

import { layout, raw } from "jops-core";
layout`<p>${raw("<b>trusted</b>")}</p>`;

A common use is a list inside a single template: escape each item with layout, join them, and wrap the result in raw():

const html = layout`<ul>${raw(items.map((i) => layout`<li>${i}</li>`).join(""))}</ul>`;

Use subviews instead when the items need their own events or state.

loadLayout() accepts a path to a plain .html file. JOPS fetches the file, keeps it in an in-memory cache for the lifetime of the page, and uses its contents as the view’s layout.

There are two ways to use one:

<!-- declarative: the layout attribute on a jops tag -->
<View jops layout="/src/layout/header.html"></View>
// programmatic: load, then render the root
const root = new View();
await root.loadLayout("/src/layout/main.html");
root.render();

The layout attribute works with .js layout modules too. On a tag that also has src, the class comes from src and the layout from layout.

A view class can choose its own layout by overriding loadLayout(). When you pass a path, you must return or await the call to super.loadLayout():

class QuoteRow extends View {
loadLayout() {
return super.loadLayout("/quotes/layout/quote_row.html"); // returns the promise
}
}
// Equivalent, and handy when you do more after loading:
class QuoteRow extends View {
async loadLayout() {
await super.loadLayout("/quotes/layout/quote_row.html");
}
}

JOPS waits for the promise that loadLayout() returns before it calls inflate(). Loading a path is asynchronous (fetch() for .html, a dynamic import() for .js), so without return or await the layout arrives too late and the view renders empty.

Passed to super.loadLayout() Loading await needed
An .html path fetch() yes
A .js path dynamic import() yes
A string (inline HTML, or a layout module imported at the top of the file) synchronous no

Because props are set before the layout loads, an override can pick a layout from them:

class Badge extends View {
loadLayout() {
return super.loadLayout(`/src/layout/badge_${this.props.variant}.html`);
}
}

loadLayout() loads once per view. Calls made while a load is in progress, with or without a path, wait for it; once a layout is loaded, later calls return at once. A failed load (for example a 404) leaves the view without a layout, so it can be retried.

This is also why new View("/path/to/layout.html") followed by addSubView() works: addSubView() waits for the layout that the constructor started loading.

A layout does not need a single wrapper element. This is a valid layout:

src/layout/quotes.html
<h2 id="title">Quotes</h2>
<p id="summary"></p>

this.domNode is the first top-level element and this.domNodes holds all of them. get(), find(id), the addSubView() container lookup, event binding, show(), hide() and destroy() act on all of them, and a view added with addSubView() or insertSubView() has all of its top-level elements placed, in order. Top-level text is ignored.

Two things to keep in mind:

  • Inserting by position: insertSubView(view, index) counts the children of the first top-level element. addSubView(containerId, view) finds its container in any top-level element (or a top-level element itself), like get().
  • Animations: the Router’s slide and fade animations need single-root views. A stacked view with several top-level elements switches instantly, unless the Router uses screens mode, which animates them too.