Attributes
elements man html/attributes Read as markdownTyped attributes on a template constructor, reactive expression blocks, and the two-way bindings Elements installs on form controls.
Attributes go in () on the opening tag. Each attribute is public (the
default) or private. Optional attributes use ?. Attributes can have
initializers that reference other attributes, and declaration order does not
matter. private attributes cannot be provided by callers, but they can be
mutated inside the template scope.
<html (
title: string,
active: boolean = true,
greeting: string = `Hello ${title}`,
private toggle: boolean = false,
)>
<h1>{greeting}</h1>
</html>
Attributes are fully typed and integrated with the codebase's tooling: rename, find-references, go-to-definition, and type checking work across templates and TypeScript alike.
Layout
One or two attributes sit on the tag line.
<Badge (label: string)>
<Panel class="card" (title: string, open: boolean = false)>
Three or more go one per line. The ( stays on the tag line, each attribute
gets a line and a trailing comma, and )> closes at the tag's indent.
<html class="page-signup" (
private handle: string = "",
private password: string = "",
private error: string = "",
)>
The trailing comma is optional and worth writing: adding an attribute then touches one line instead of two.
Regular attributes stay on the tag line ahead of the (, and a long one goes
after the ).
<html class="page-todos" (
todos: LiveView<Todo>,
view: View,
private draft: Draft = { title: "" },
) oninit={() => window.addEventListener("popstate", () => onPopState(view))}>
Do not hang the list off the tag name's indent:
<!-- wrong: do not do this -->
<html class="page-signup"
(private handle: string = "",
private password: string = "",
private error: string = "")>
Every line there starts at a different column, so the constructor does not read
as a list and class does not read as separate from it. The rule is the one
TypeScript uses for a parameter list: open on the declaration line, one item per
line, close at the declaration's indent.
Attributes are reactive automatically, including across template boundaries:
<TaskList (tasks: Task[])>
<Task e:for={task of tasks} {task}/>
done: {tasks.filter(t => t.done).length}
</TaskList>
<Task (task: Task)>
<input value={task.desc}>
<input type="checkbox" checked={task.done}>
</Task>
Toggling the checkbox in <Task> updates the count in <TaskList> immediately.
Attributes live on the root tag only. An attribute list on a non-root element
(e.g. <div (form: Form)>) is invalid.
A union inside an object default needs a cast. The declared type does not currently reach into an object literal used as a default, so a union field in one is inferred as its widened type and rejected:
<Panel (mode: Mode = "light")> <!-- fine -->
<Panel (state: { mode: Mode } = { mode: "light" })> <!-- Type 'string' is not assignable to type 'Mode' -->
<Panel (state: { mode: Mode } = { mode: "light" as Mode })> <!-- the workaround -->
Grouping template state into one object is the usual way to keep it mutable: a handler can mutate an object's fields and the change is live, while reassigning a plain attribute inside a handler is not (see Reactivity). The object is therefore often the right shape, and the cast is the price until this is fixed.
Expressions
{ ... } is a TypeScript expression. Allowed as element children and attribute
values.
<h1>{title}</h1>
<a href={url}>{label}</a>
<p>{users.length === 0 ? "empty" : `${users.length} users`}</p>
For raw HTML output without escaping, use raw():
<div>{raw(post.bodyHtml)}</div>
Attributes
Attribute values follow normal HTML rules. String literals work. Expression blocks work.
<div class="card">
<div class={className}>
<input type="text" required>
class accepts an array. Falsy entries drop. The rest join with spaces. Useful
for stringing together static and dynamic classes.
<div class={["card", "card-" + theme, active && "active"]}>
style accepts an object.
<div style={{ background: "#fff", "font-family": "courier" }}>
Identity shortcut: <Task {title}> is <Task title={title}>. Spread:
<Task {...props}>.
Form Bindings
value, checked, and group create two-way bindings. Works on <input>,
<textarea>, <select>, and contenteditable elements. group is the
exception: it binds a set of checkbox or radio inputs to one variable, and does
not apply to <select>, which binds with value.
<input value={form.name}>
<input type="checkbox" checked={form.subscribed}>
<input type="number" value={form.age}>
<textarea value={form.bio}/>
<select value={form.country}>
<option value="us">us</option>
<option value="ca">ca</option>
</select>
<label><input type="checkbox" group={form.contacts} value="email"> email</label>
<label><input type="checkbox" group={form.contacts} value="phone"> phone</label>
<div contenteditable value={form.bio}/>
contenteditable must use value=, not text content.
<div contenteditable>{body}</div> renders once and stays static.
<div contenteditable value={body}> is reactive.
Read and write data, not the DOM. Calling el.value = "..." or otherwise
mutating a bound input fights the runtime.
File Inputs
<input type="file"> uses the same value= binding, typed as File[]. When
the user picks files, the runtime reads their bytes and assigns the bound
variable. Browsers can't programmatically set file inputs, so this binding is
effectively one-way (from picker → variable); assigning [] clears the input.
import { File } from "@elements/app";
/** @rpc */
function upload(files: File[]) {
for (const f of files) {
sql`insert into uploads (name, content_type, data) values (${f.name}, ${f.contentType}, ${f.data})`;
}
}
<html (private files: File[] = [])>
<form onsubmit={() => upload(files)}>
<input type="file" multiple value={files}>
<button type="submit">upload</button>
</form>
</html>
File.read() is asynchronous (the browser has to read bytes off disk). The
form's submit handler automatically awaits any in-flight reads before
running, so the files array is always populated by the time upload(files) is
called. No state machine needed for the simple case.
File is a wire-format class (name, size, contentType,
data: Uint8Array, lastModified) that serializes across @rpc. The data
field maps directly to a Postgres bytea column.
Progress / preview UI: drop out of the binding into an explicit handler.
When you need the file bytes available before submit (e.g. to show a thumbnail
or a "reading…" spinner), use File.read() directly inside an onchange
handler. The sync-style transform makes the state machine read top-to-bottom:
interface Upload {
files: File[];
state: "idle" | "reading" | "ready" | "uploading" | "done";
}
function onChange(e: Event, form: Upload) {
form.state = "reading";
form.files = File.read(e.target as HTMLInputElement);
form.state = "ready";
}
function onSubmit(form: Upload) {
form.state = "uploading";
upload(form.files);
form.state = "done";
}
<html (private form: Upload = { files: [], state: "idle" })>
<form onsubmit={() => onSubmit(form)}>
<input type="file" multiple onchange={(e) => onChange(e, form)}>
<span e:if={form.state === "reading"}>reading…</span>
<ul e:if={form.state === "ready" || form.state === "uploading"}>
<li e:for={f of form.files}>{f.name} ({f.size} bytes)</li>
</ul>
<button type="submit"
disabled={form.state === "reading" || form.state === "uploading"}>upload</button>
</form>
</html>
The state and the files are one object so the handlers can write to them: a
handler mutates form.state, while reassigning a bare state attribute from
inside a function updates nothing (see Reactivity).
The two patterns interop with the same File.read() primitive: the value=
binding just calls it for you on change.