Components
A component in rooted is a plain object with a name, an optional styles field, and an onMount function. The runtime wraps it in a custom element when you mount it.
import { component } from '@rooted/components'
import styles from './example.css'
export const Example = component({
name: 'example',
styles,
onMount({ append, element }) {
append(
element('p', {
textContent: 'Hello',
})
)
},
})The mount context
onMount receives a context object. Its methods build DOM under the component's host element.
| Method | What it does |
|---|---|
append(...nodes) | Append nodes (or other components, or strings) to the host. |
prepend(...nodes) | Prepend to the host. |
insertBefore(node, ref) | Insert a node before a given child. |
swap(newNode, oldChild) | Replace a specific child. |
replace(...nodes) | Replace all children. |
remove(...nodes) | Remove specific children. |
element(tag, props) | Build a typed HTML or SVG element. Does not mount it. |
create(Component, options?) | Instantiate another component without appending it. |
signal | An AbortSignal that aborts when the component unmounts. |
on | Bind global event listeners that auto-clean on unmount. |
append, prepend, and replace accept three kinds of inputs:
- A string (becomes a text node).
- A
Nodeproduced byelement(...). - A component value created by
component(...)(mounted automatically).
This is the entire DOM API. There is no template language, no JSX, and no virtual DOM. The methods do exactly what their names say.
Building elements
element is the typed factory. It returns an HTMLElement or SVGElement and does not mount it. Use it when you want to build a tree and append it in one shot, or when you need a reference to the node.
onMount({ append, element }) {
const list = element('ul', {
classes: styles.list,
children: [
element('li', {
textContent: 'one',
}),
element('li', {
textContent: 'two',
}),
],
})
append(list)
}The props object is typed against the DOM interface for the tag, so tabIndex, className, and friends are auto-completed and typo-proof.
For the difference between element and writing your own custom element from scratch, see advanced/elements.
Conditional children
undefined and null entries in a children array are skipped, so a child that isn't always there doesn't need a branch around the whole element:
element('label', {
children: [
element('span', {
textContent: 'Name',
}),
required
? element('abbr', {
title: 'required',
textContent: '*',
})
: undefined,
],
})optional(condition, value) says the same thing without the ternary:
import { optional } from '@rooted/components'
element('label', {
children: [
element('span', {
textContent: 'Name',
}),
optional(required,
element('abbr', {
title: 'required',
textContent: '*',
})
),
],
})Only undefined and null are skipped. false is not a valid child, so required && element(...) won't type-check, use optional, choice, or a ternary.
Picking a value
optional covers "include it or leave it out". choice(condition, matched, notMatched) picks between two values you already have:
import { choice } from '@rooted/components'
element('button', {
tabIndex: choice(selected,
0,
-1
),
aria: {
selected: choice(selected,
'true',
'false'
),
},
})The condition has to be exactly true, same as optional and cssClass. The two values don't have to share a type: choice(selected, 'page', undefined) gives back 'page' | undefined.
For more than two options there's match, which takes either a record or an array. The record form has to cover every key the type allows, so adding a case to the union breaks the build until you handle it:
import { match } from '@rooted/components'
type Status = 'draft' | 'review' | 'published'
element('p', {
textContent: match(status, {
draft: 'Not shared yet',
review: 'Waiting on a reviewer',
published: 'Live',
}),
})The array form reads an index, and gives undefined when the index is out of range:
match(place, [
'first',
'second',
'third'
])Two things to watch with match:
- The exhaustiveness only works when the key is a union of literals. A key typed as plain
stringornumbercan't be exhausted, so a partial record type-checks and hands youundefinedwhere the type promises a value. - If you narrowed the key down to a single literal, annotate it with the full union. Otherwise the other entries in the record are read as excess properties.
Both of these pick between values you already have. Every value gets evaluated, because that is what passing arguments to a function does, and no amount of typing changes it. So a branch that has to compute something, or that reads through something which might not be there, is a ternary's job and stays one:
// choice would call create() for every segment, text or not
segments.map(segment => segment.type === 'text'
? segment.text
: create(Measurement, {
value: segment.value
}),
)Class names
Class names use the classes prop, not class or className. It accepts a string, a list of strings, or a CSS module value. CSS module imports give you the original class name, and the CSS loader ensures those class names only match inside this component's subtree.
element('p', {
classes: [
styles.message,
'highlight',
],
})Conditional classes
Falsy entries in a classes list are dropped, so conditional classes work the same way conditional children do. cssClass(condition, className) returns the class only when the condition is exactly true:
import { cssClass } from '@rooted/components'
element('button', {
classes: [
styles.button,
cssClass(isActive, styles.active),
cssClass(isDisabled, styles.disabled),
],
})Called with one argument it just returns the class, which reads better when the rest of the list is conditional: cssClass(styles.button).
cssClasses joins several names into one and gives back undefined when nothing is left. It has three forms:
cssClasses(styles.button, styles.rounded) // 'button rounded'
cssClasses(isActive, styles.active, styles.raised) // both, or undefined
cssClasses({ 'is-active': isActive }) // the keys mapped to trueOne thing to watch: a leading undefined or null is read as the condition, so cssClasses(undefined, styles.active) is undefined and not the class. A class name that might be undefined belongs after the condition, or in cssClass.
Typed options
A component can take options. Set them as the second argument to create (or append(MyComponent, options)).
type GreetingOptions = { name: string }
export const Greeting = component<GreetingOptions>({
name: 'greeting',
onMount({ append, element, options }) {
append(
element('p', {
textContent: `Hello, ${options.name}`,
})
)
},
})append(Greeting, { name: 'world' })The options type flows through. If you forget a required field, TypeScript complains.
Options are passed in once, at mount time. They do not update. If you need a value to change, use a store.
Listeners and the signal
Pass on as a property of an element() call to attach a listener. The listener is removed automatically when the component unmounts.
onMount({ append, element }) {
append(
element('button', {
textContent: 'click me',
on: {
click() { console.log('clicked') },
},
})
)
}For window or document listeners, use on from the mount context:
onMount({ on }) {
on('window', 'popstate', () => { /* ... */ })
on('document', 'visibilitychange', () => { /* ... */ })
}If you need the raw AbortSignal (for example, to pass to fetch), it's available as signal in the mount context.
All forms are removed automatically. You don't need to track them.
Async onMount
onMount can be async. The component is mounted right away; the body runs after.
onMount: async ({ append, element, signal }) => {
const data = await fetch('/api/data', { signal }).then(r => r.json())
append(
element('p', {
textContent: data.message,
})
)
}Two things to know:
- The host element is in the DOM immediately. If your
onMountis slow, there is a brief moment with an empty host. Render a placeholder first if that matters. - Throwing inside an async
onMountis caught as an unhandled rejection. In development, the error is also rendered into the DOM. Wrap calls intry/catchif you need to handle the error yourself.
Dev mode help
A few small things rooted does in dev mode:
- Each component instance gets an
r-component="<name>"attribute on its host element so you can spot it in DevTools. - The host tag is the readable
<rooted-component>instead of the production<r-->. - Duplicate component names and other component issues log a warning with source locations.
In production these checks are dropped.